# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
JSON/schema contract tests for the backend response views.

These guard the serialized wire shape of the canonical-model-backed response
DTOs (``ScoreView``/``MessagePieceView``/``MessageView``/``AttackSummary``):
canonical fields plus presentation computed fields must appear in
``model_dump(mode="json")``, ``related_conversations`` must serialize in a
stable (sorted) order, and the removed wire aliases (``score_id``,
``scored_at``, ``piece_id``, ``pieces``) must no longer appear.
"""

import uuid
from datetime import datetime, timezone

from pyrit.backend.models.attacks import (
    AttackSummary,
    MessagePieceView,
    MessageView,
    ScoreView,
)
from pyrit.models import (
    AtomicAttackIdentifier,
    AttackResult,
    ComponentIdentifier,
    ConversationReference,
    ConversationType,
    MessagePiece,
    RetryEvent,
    Score,
)


def _make_score() -> Score:
    return Score(
        score_value="0.5",
        score_type="float_scale",
        score_rationale="because",
        message_piece_id=str(uuid.uuid4()),
        scorer_class_identifier=ComponentIdentifier(class_name="FloatScaleScorer", class_module="pyrit.score"),
    )


def _make_piece(*, sequence: int = 0, role: str = "user") -> MessagePiece:
    return MessagePiece(
        role=role,
        original_value="hello",
        converted_value="hello",
        original_value_data_type="text",
        converted_value_data_type="text",
        conversation_id="conv-1",
        sequence=sequence,
    )


def _make_attack_result(*, name: str = "CrescendoAttack") -> AttackResult:
    target = ComponentIdentifier(class_name="OpenAIChatTarget", class_module="pyrit.prompt_target")
    return AttackResult(
        conversation_id="attack-1",
        objective="test objective",
        attack_result_id="ar-attack-1",
        atomic_attack_identifier=AtomicAttackIdentifier.build(
            attack_identifier=ComponentIdentifier(
                class_name=name,
                class_module="pyrit.attacks",
                children={"objective_target": target},
            ),
        ),
    )


class TestScoreViewContract:
    """JSON contract for ScoreView."""

    def test_dump_has_canonical_and_computed_fields(self) -> None:
        """Test that the serialized score exposes canonical fields plus scorer_type."""
        view = ScoreView.from_domain(_make_score(), is_objective_score=True)
        dumped = view.model_dump(mode="json")

        assert dumped["score_value"] == "0.5"
        assert dumped["score_type"] == "float_scale"
        assert dumped["scorer_type"] == "FloatScaleScorer"
        assert dumped["is_objective_score"] is True
        assert "scorer_class_identifier" in dumped

    def test_schema_builds(self) -> None:
        """Test that ScoreView's serialization schema includes the computed field."""
        properties = ScoreView.model_json_schema(mode="serialization")["properties"]
        assert "scorer_type" in properties
        assert "is_objective_score" in properties


class TestMessagePieceViewContract:
    """JSON contract for MessagePieceView."""

    def test_dump_has_canonical_and_presentation_fields(self) -> None:
        """Test that the serialized piece exposes canonical and derived presentation fields."""
        piece = _make_piece()
        view = MessagePieceView.from_domain(piece)
        dumped = view.model_dump(mode="json")

        assert dumped["role"] == "user"
        assert dumped["original_value"] == "hello"
        assert "original_value_url" in dumped
        assert "converted_value_url" in dumped
        assert "original_value_mime_type" in dumped
        assert "converted_value_mime_type" in dumped
        assert "original_filename" in dumped
        assert "converted_filename" in dumped
        assert "response_error_description" in dumped
        assert dumped["scores"] == []

    def test_scores_are_score_views(self) -> None:
        """Test that nested scores serialize with the ScoreView computed field."""
        piece = _make_piece()
        score = _make_score()
        view = MessagePieceView.from_domain(piece, scores=[score], objective_score_id=score.id)
        dumped = view.model_dump(mode="json")

        assert dumped["scores"][0]["scorer_type"] == "FloatScaleScorer"
        assert dumped["scores"][0]["is_objective_score"] is True

    def test_string_score_id_matches_uuid_objective_score_id(self) -> None:
        """Test that equivalent string and UUID score IDs identify the objective score."""
        piece = _make_piece()
        score = _make_score()
        objective_score_id = uuid.UUID(str(score.id))
        score.id = str(score.id)

        view = MessagePieceView.from_domain(
            piece,
            scores=[score],
            objective_score_id=objective_score_id,
        )

        assert view.scores[0].is_objective_score is True


class TestMessageViewContract:
    """JSON contract for MessageView."""

    def test_dump_has_turn_metadata_and_pieces(self) -> None:
        """Test that the serialized message exposes turn metadata and piece views."""
        piece = MessagePieceView.from_domain(_make_piece(sequence=3, role="assistant"))
        view = MessageView.model_construct(message_pieces=[piece])
        dumped = view.model_dump(mode="json")

        assert dumped["turn_number"] == 3
        assert dumped["role"] == "assistant"
        assert "created_at" in dumped
        assert len(dumped["message_pieces"]) == 1
        assert dumped["message_pieces"][0]["role"] == "assistant"


class TestAttackSummaryContract:
    """JSON contract for AttackSummary, including set-ordering (R1)."""

    def _summary(self, ar: AttackResult) -> AttackSummary:
        now = datetime.now(timezone.utc)
        data = {name: getattr(ar, name) for name in AttackResult.model_fields}
        data.update(
            last_response=None,
            last_score=None,
            labels={"env": "prod"},
            message_count=2,
            last_message_preview="hi",
            created_at=now,
            updated_at=now,
        )
        return AttackSummary.model_construct(**data)

    def test_dump_has_canonical_computed_and_stats_fields(self) -> None:
        """Test that the serialized summary exposes canonical, computed, and stats fields."""
        dumped = self._summary(_make_attack_result()).model_dump(mode="json")

        assert dumped["conversation_id"] == "attack-1"
        assert dumped["objective"] == "test objective"
        assert dumped["attack_type"] == "CrescendoAttack"
        assert dumped["target"]["target_type"] == "OpenAIChatTarget"
        assert dumped["converters"] == []
        assert dumped["message_count"] == 2
        assert dumped["last_message_preview"] == "hi"
        assert dumped["labels"] == {"env": "prod"}
        assert "retry_events" in dumped

    def test_related_conversations_serialize_sorted(self) -> None:
        """Test that related_conversations serialize in a stable, sorted order (R1)."""
        ar = _make_attack_result()
        ar.related_conversations = {
            ConversationReference(conversation_id="zeta", conversation_type=ConversationType.PRUNED),
            ConversationReference(conversation_id="alpha", conversation_type=ConversationType.ADVERSARIAL),
            ConversationReference(conversation_id="mid", conversation_type=ConversationType.PRUNED),
        }

        dumped = self._summary(ar).model_dump(mode="json")

        ordered_ids = [ref["conversation_id"] for ref in dumped["related_conversations"]]
        assert ordered_ids == ["alpha", "mid", "zeta"]
        assert dumped["related_conversation_ids"] == ["alpha", "mid", "zeta"]

    def test_retry_events_round_trip(self) -> None:
        """Test that inherited retry_events serialize with their canonical payload."""
        ar = _make_attack_result()
        ar.retry_events = [RetryEvent(attempt_number=1, exception_type="RateLimitError")]

        dumped = self._summary(ar).model_dump(mode="json")

        assert dumped["retry_events"][0]["attempt_number"] == 1
        assert dumped["retry_events"][0]["exception_type"] == "RateLimitError"


class TestRemovedWireAliases:
    """Old wire field names were removed for 1.0.0 and must no longer be emitted."""

    def test_score_view_omits_removed_aliases(self) -> None:
        """Test that ScoreView no longer emits score_id/scored_at."""
        view = ScoreView.from_domain(_make_score())
        dumped = view.model_dump(mode="json")

        assert "score_id" not in dumped
        assert "scored_at" not in dumped

    def test_message_piece_view_omits_removed_alias(self) -> None:
        """Test that MessagePieceView no longer emits piece_id."""
        view = MessagePieceView.from_domain(_make_piece())
        dumped = view.model_dump(mode="json")

        assert "piece_id" not in dumped

    def test_message_view_does_not_emit_pieces_alias(self) -> None:
        """The ``pieces`` alias was dropped; only ``message_pieces`` is emitted."""
        piece = MessagePieceView.from_domain(_make_piece())
        dumped = MessageView.model_construct(message_pieces=[piece]).model_dump(mode="json")

        assert "pieces" not in dumped
        assert "message_pieces" in dumped

    def test_removed_aliases_absent_from_schema(self) -> None:
        """Test that the removed aliases no longer appear in the OpenAPI schema."""
        score_props = ScoreView.model_json_schema(mode="serialization")["properties"]
        piece_props = MessagePieceView.model_json_schema(mode="serialization")["properties"]
        message_props = MessageView.model_json_schema(mode="serialization")["properties"]

        assert "score_id" not in score_props
        assert "scored_at" not in score_props
        assert "piece_id" not in piece_props
        assert "pieces" not in message_props
