"""Round-trip test: POST rejection with tags + notes → stored in gate_results → retrievable via GET.

Exercises the exact same logic as _api_dailies_reject in editors/review_server.py,
but directly against ExecutionStore (no HTTP server required).
"""

import json
import time

import pytest

from recoil.pipeline._lib.rejection import compute_rejection_overrides

from recoil.execution.execution_store import ExecutionStore


def _make_shot(shot_id="EP001_SH001", episode_id="EP001", **overrides):
    """Helper to build a shot dict with sensible defaults."""
    shot = {
        "shot_id": shot_id,
        "episode_id": episode_id,
        "pipeline": "still",
        "model": "gemini-3-pro-image-preview",
        "status": "previs_pending",
        "cost_incurred": 0.0,
        "attempts": 0,
        "max_attempts": 3,
    }
    shot.update(overrides)
    return shot


# Status rollback map — mirrors review_server._api_dailies_reject
_REJECT_STATUS_MAP = {
    "previs_generated": "previs_rejected",
    "keyframe_generated": "keyframe_rejected",
    "video_complete": "video_rejected",
}


def _compute_overrides(tags, body=None):
    """Thin wrapper around compute_rejection_overrides for test helpers."""
    body = body or {}
    return compute_rejection_overrides(tags, spoiler_word=body.get("spoiler_word", ""))


def _simulate_reject(store, shot_id, tags=None, notes="", body=None):
    """Reproduce the rejection logic from _api_dailies_reject.

    This mirrors editors/review_server.py _api_dailies_reject exactly:
    read shot → compute rollback status → compute overrides → build rejection_data → update_shot.
    """
    shot = store.get_shot(shot_id)
    assert shot is not None, f"Shot {shot_id} must exist before rejection"

    new_status = _REJECT_STATUS_MAP.get(shot["status"], "previs_pending")
    tags = tags or []

    rejection_data = {}
    if tags or notes:
        rejection_overrides = _compute_overrides(tags, body=body)
        rejection_data = {
            "last_rejection": {
                "tags": tags,
                "notes": notes,
                "at": time.time(),
                "from_status": shot["status"],
                "overrides": rejection_overrides,
            }
        }

    store.update_shot(shot_id, status=new_status, gate_results=rejection_data)
    return new_status


def _simulate_reject_with_jsonl(store, shot_id, tags=None, notes="", body=None, log_path=None):
    """Like _simulate_reject but also writes JSONL analytics log."""
    shot = store.get_shot(shot_id)
    assert shot is not None, f"Shot {shot_id} must exist before rejection"

    new_status = _simulate_reject(store, shot_id, tags=tags, notes=notes, body=body)

    tags = tags or []
    if (tags or notes) and log_path is not None:
        log_path.parent.mkdir(parents=True, exist_ok=True)
        # Count previous rejections for this shot
        attempt_count = 0
        if log_path.exists():
            with open(log_path, "r", encoding="utf-8") as f:
                for line in f:
                    try:
                        entry = json.loads(line)
                        if entry.get("shot_id") == shot_id:
                            attempt_count += 1
                    except json.JSONDecodeError:
                        pass
        log_entry = {
            "timestamp": time.time(),
            "episode_id": shot.get("episode_id", ""),
            "shot_id": shot_id,
            "tags": tags,
            "attempt_count": attempt_count + 1,
            "from_status": shot["status"],
        }
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(log_entry) + "\n")

    return new_status


class TestRejectionRoundTrip:
    """POST rejection → store in gate_results → GET and verify."""

    def test_reject_previs_with_tags_and_notes(self, tmp_path):
        """Full round-trip: reject a previs_generated shot with tags + notes."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            tags = ["wrong_prop", "too_clean"]
            notes = "Character is facing the wrong direction"
            new_status = _simulate_reject(
                store, "EP001_SH001", tags=tags, notes=notes
            )

            assert new_status == "previs_rejected"

            shot = store.get_shot("EP001_SH001")
            assert shot is not None
            assert shot["status"] == "previs_rejected"

            gate = shot["gate_results"]
            assert "last_rejection" in gate
            rej = gate["last_rejection"]
            assert rej["tags"] == ["wrong_prop", "too_clean"]
            assert rej["notes"] == "Character is facing the wrong direction"
            assert rej["from_status"] == "previs_generated"
            assert isinstance(rej["at"], float)
            assert rej["at"] > 0
            assert "overrides" in rej
        finally:
            store.close()

    def test_reject_keyframe_with_tags_and_notes(self, tmp_path):
        """Rejection of keyframe_generated rolls back to keyframe_rejected."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="keyframe_generated"))

            new_status = _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_expression"],
                notes="Wrong character ref used",
            )

            assert new_status == "keyframe_rejected"

            shot = store.get_shot("EP001_SH001")
            assert shot["status"] == "keyframe_rejected"
            rej = shot["gate_results"]["last_rejection"]
            assert rej["from_status"] == "keyframe_generated"
            assert rej["tags"] == ["wrong_expression"]
        finally:
            store.close()

    def test_reject_video_complete(self, tmp_path):
        """Rejection of video_complete rolls back to video_rejected."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="video_complete"))

            new_status = _simulate_reject(
                store, "EP001_SH001",
                tags=["continuity_drift"],
                notes="Character morphs mid-take",
            )

            assert new_status == "video_rejected"

            shot = store.get_shot("EP001_SH001")
            assert shot["status"] == "video_rejected"
            rej = shot["gate_results"]["last_rejection"]
            assert rej["from_status"] == "video_complete"
        finally:
            store.close()

    def test_reject_unknown_status_falls_back_to_previs_rejected(self, tmp_path):
        """Unknown status defaults to previs_rejected rollback (via fallback)."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="some_custom_status"))

            new_status = _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_camera"],
                notes="Edge case",
            )

            # Fallback in _REJECT_STATUS_MAP.get() returns "previs_pending" for unknown states
            # Unknown old state → any new state is allowed (legacy compat)
            assert new_status == "previs_pending"
            shot = store.get_shot("EP001_SH001")
            assert shot["status"] == "previs_pending"
        finally:
            store.close()

    def test_reject_without_tags_or_notes_stores_no_rejection_data(self, tmp_path):
        """Empty tags + empty notes = no rejection metadata in gate_results."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=[], notes="")

            shot = store.get_shot("EP001_SH001")
            assert shot["status"] == "previs_rejected"
            assert shot["gate_results"] == {}
        finally:
            store.close()

    def test_second_rejection_overwrites_last_rejection(self, tmp_path):
        """Second rejection merges into gate_results, overwriting last_rejection."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            # First rejection
            _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_prop"],
                notes="First rejection",
            )

            # Re-generate (simulate pipeline advancing back to previs_generated)
            # previs_rejected → previs_generated is a valid transition
            store.update_shot("EP001_SH001", status="previs_generated")

            # Second rejection
            _simulate_reject(
                store, "EP001_SH001",
                tags=["too_clean", "wrong_camera"],
                notes="Second rejection — still not right",
            )

            shot = store.get_shot("EP001_SH001")
            rej = shot["gate_results"]["last_rejection"]
            assert rej["tags"] == ["too_clean", "wrong_camera"]
            assert rej["notes"] == "Second rejection — still not right"
            assert rej["from_status"] == "previs_generated"
        finally:
            store.close()

    def test_rejection_preserves_existing_gate_results(self, tmp_path):
        """Rejection data merges with (not replaces) existing gate_results."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            store.update_shot(
                "EP001_SH001",
                gate_results={"anchor_role": "hero", "semantic_score": 0.92},
            )

            _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_camera"],
                notes="Too tight",
            )

            shot = store.get_shot("EP001_SH001")
            gate = shot["gate_results"]

            assert gate["anchor_role"] == "hero"
            assert gate["semantic_score"] == 0.92

            assert gate["last_rejection"]["tags"] == ["wrong_camera"]
            assert gate["last_rejection"]["notes"] == "Too tight"
        finally:
            store.close()

    def test_rejection_tags_only_no_notes(self, tmp_path):
        """Tags without notes still stores rejection metadata."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_expression"],
                notes="",
            )

            shot = store.get_shot("EP001_SH001")
            rej = shot["gate_results"]["last_rejection"]
            assert rej["tags"] == ["wrong_expression"]
            assert rej["notes"] == ""
        finally:
            store.close()

    def test_rejection_notes_only_no_tags(self, tmp_path):
        """Notes without tags still stores rejection metadata."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(
                store, "EP001_SH001",
                tags=[],
                notes="Just doesn't feel right",
            )

            shot = store.get_shot("EP001_SH001")
            rej = shot["gate_results"]["last_rejection"]
            assert rej["tags"] == []
            assert rej["notes"] == "Just doesn't feel right"
        finally:
            store.close()


class TestRejectionOverrides:
    """Verify structured tags compute correct override actions."""

    def test_wrong_prop_stores_inject_prop_ref(self, tmp_path):
        """wrong_prop tag stores inject_prop_ref override."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=["wrong_prop"])

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["inject_prop_ref"] is True
            assert "prop_recency_text" in overrides
            assert "HEAVY WEIGHT" in overrides["prop_recency_text"]
        finally:
            store.close()

    def test_narrative_spoiler_stores_negative_prompt(self, tmp_path):
        """narrative_spoiler with spoiler_word stores negative prompt override."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(
                store, "EP001_SH001",
                tags=["narrative_spoiler"],
                body={"spoiler_word": "blood"},
            )

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert "negative_prompt" in overrides
            assert "blood" in overrides["negative_prompt"]
            assert "must NOT be visible" in overrides["negative_prompt"]
        finally:
            store.close()

    def test_narrative_spoiler_without_word_no_negative_prompt(self, tmp_path):
        """narrative_spoiler without spoiler_word does not add negative prompt."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(
                store, "EP001_SH001",
                tags=["narrative_spoiler"],
            )

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert "negative_prompt" not in overrides
        finally:
            store.close()

    def test_continuity_drift_stores_n1_frame(self, tmp_path):
        """continuity_drift tag stores inject_n1_frame override."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=["continuity_drift"])

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["inject_n1_frame"] is True
            assert "SPATIAL LAYOUT REFERENCE ONLY" in overrides["n1_frame_text"]
        finally:
            store.close()

    def test_too_clean_stores_temp_delta_and_texture(self, tmp_path):
        """too_clean tag stores temp_delta and texture_modifiers."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=["too_clean"])

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["temp_delta"] == -0.15
            assert "grime" in overrides["texture_modifiers"]
        finally:
            store.close()

    def test_wrong_camera_stores_shot_type_override(self, tmp_path):
        """wrong_camera tag stores shot_type_override."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=["wrong_camera"])

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["shot_type_override"] is True
        finally:
            store.close()

    def test_wrong_expression_stores_expression_ref(self, tmp_path):
        """wrong_expression tag stores inject_expression_ref override."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(store, "EP001_SH001", tags=["wrong_expression"])

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["inject_expression_ref"] is True
            assert "EXPRESSION OVERRIDE" in overrides["expression_override_text"]
        finally:
            store.close()

    def test_multiple_tags_combine_overrides(self, tmp_path):
        """Multiple tags produce combined overrides."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject(
                store, "EP001_SH001",
                tags=["wrong_prop", "too_clean", "wrong_camera"],
            )

            shot = store.get_shot("EP001_SH001")
            overrides = shot["gate_results"]["last_rejection"]["overrides"]
            assert overrides["inject_prop_ref"] is True
            assert overrides["temp_delta"] == -0.15
            assert overrides["shot_type_override"] is True
        finally:
            store.close()


class TestRejectionJSONL:
    """Verify JSONL analytics logging."""

    def test_rejection_appends_to_jsonl(self, tmp_path):
        """Rejection creates a JSONL log entry."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        log_path = tmp_path / "state" / "visual" / "rejection_log.jsonl"
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject_with_jsonl(
                store, "EP001_SH001",
                tags=["wrong_prop"],
                notes="Bad prop",
                log_path=log_path,
            )

            assert log_path.exists()
            lines = log_path.read_text().strip().split("\n")
            assert len(lines) == 1
            entry = json.loads(lines[0])
            assert entry["shot_id"] == "EP001_SH001"
            assert entry["episode_id"] == "EP001"
            assert entry["tags"] == ["wrong_prop"]
            assert entry["attempt_count"] == 1
            assert entry["from_status"] == "previs_generated"
            assert "timestamp" in entry
        finally:
            store.close()

    def test_jsonl_increments_attempt_count(self, tmp_path):
        """Second rejection for same shot increments attempt_count."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        log_path = tmp_path / "state" / "visual" / "rejection_log.jsonl"
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            # First rejection
            _simulate_reject_with_jsonl(
                store, "EP001_SH001",
                tags=["wrong_prop"],
                log_path=log_path,
            )

            # Re-generate: previs_rejected → previs_generated (valid transition)
            store.update_shot("EP001_SH001", status="previs_generated")

            # Second rejection
            _simulate_reject_with_jsonl(
                store, "EP001_SH001",
                tags=["too_clean"],
                log_path=log_path,
            )

            lines = log_path.read_text().strip().split("\n")
            assert len(lines) == 2
            entry1 = json.loads(lines[0])
            entry2 = json.loads(lines[1])
            assert entry1["attempt_count"] == 1
            assert entry2["attempt_count"] == 2
        finally:
            store.close()

    def test_jsonl_not_written_for_empty_rejection(self, tmp_path):
        """No JSONL entry when tags and notes are both empty."""
        store = ExecutionStore(project="test", db_path=tmp_path / "test.db")
        log_path = tmp_path / "state" / "visual" / "rejection_log.jsonl"
        try:
            store.insert_shot(_make_shot(status="previs_generated"))

            _simulate_reject_with_jsonl(
                store, "EP001_SH001",
                tags=[],
                notes="",
                log_path=log_path,
            )

            # File should not exist since no tags or notes
            assert not log_path.exists()
        finally:
            store.close()
