from __future__ import annotations

import copy
import json
from pathlib import Path

import pytest

from recoil.core.paths import ProjectPaths
from recoil.pipeline._lib import breakdown_propose
from recoil.pipeline._lib.breakdown_coverage_validator import (
    CoverageFinding,
    mention_key,
    verify_coverage,
)
from recoil.pipeline._lib.breakdown_propose import (
    BreakdownProposalError,
    approve_proposal,
    propose_bible_diff,
    reject_proposal,
)
from recoil.pipeline._lib.prose_validator import Severity


SCRIPT_HASH = "script-hash-1"


def test_propose_drafts_l9_adds_for_block_kinds_and_never_touches_bible(
    monkeypatch,
    tmp_path,
):
    paths = _make_project(tmp_path)
    _patch_paths(monkeypatch, paths)
    original_bible = _read_json(paths.global_bible_path)
    blocks = _all_block_findings()

    def fake_call(model: str, system_prompt: str, user_prompt: str) -> str:
        assert model == "test-model"
        assert "S2 bible-diff proposer" in system_prompt
        payload = json.loads(user_prompt)
        assert [block["check"] for block in payload["blocks"]] == [
            finding.check for finding in blocks
        ]
        return json.dumps(_proposal_payload())

    monkeypatch.setattr(breakdown_propose, "_call_proposal_model", fake_call)

    proposal, path = propose_bible_diff(
        "tartarus",
        1,
        _ledger(),
        original_bible,
        blocks,
        model="test-model",
    )

    assert path is not None
    assert path.name.startswith("bible_proposal_")
    assert proposal["ledger_script_content_hash"] == SCRIPT_HASH
    assert proposal["status"] == "pending"
    assert len(proposal["diff"]) == 6
    assert proposal["tombstone_suggestions"][0]["mention_key"] == mention_key(
        _mention(
            "identity_observation",
            character_id="jade",
            attribute="eye_color",
            observed_value="blue",
        )
    )
    assert _read_json(paths.global_bible_path) == original_bible

    char_add = _op(proposal, "characters.wren")
    assert "identity_invariants" in char_add["value"]
    assert "phases" in char_add["value"]
    assert "trigger" in char_add["value"]["phases"][0]

    sublocation_add = _op(
        proposal,
        "locations.lower_decks.sublocations.junction_node",
    )
    assert set(sublocation_add["value"]) == {"description"}

    prop_add = _op(proposal, "props.cryo_pod")
    assert {"states", "initial_state", "transitions", "carriable"} <= set(prop_add["value"])

    wardrobe_add = _op(proposal, "characters.jade.phases")
    assert "trigger" in wardrobe_add["value"]
    assert "appearance" in wardrobe_add["value"]


def test_approve_applies_adds_survives_reload_and_revalidates_clean(monkeypatch, tmp_path):
    paths = _make_project(tmp_path)
    _patch_paths(monkeypatch, paths)
    proposal_path = paths.episode_breakdown_dir(1) / "bible_proposal_test.json"
    _write_json(
        proposal_path,
        {
            "schema_version": 1,
            "project": "tartarus",
            "episode": 1,
            "ledger_script_content_hash": SCRIPT_HASH,
            "diff": _proposal_payload()["diff"],
            "tombstone_suggestions": [],
            "status": "pending",
        },
    )

    outcome = approve_proposal(proposal_path)

    reloaded = _read_json(paths.global_bible_path)
    assert "wren" in reloaded["characters"]
    assert "junction_node" in reloaded["locations"]["lower_decks"]["sublocations"]
    assert reloaded["props"]["cryo_pod"]["states"]["cracked"]["visual_delta"]
    assert len(reloaded["characters"]["jade"]["phases"]) == 1
    assert outcome["block_count"] == 0
    assert outcome["report_path"].is_file()
    assert _read_json(proposal_path)["status"] == "approved"
    assert not [
        finding
        for finding in verify_coverage(_ledger(), reloaded)
        if finding.severity is Severity.BLOCK
    ]


def test_approve_refuses_stale_hash_before_bible_write(monkeypatch, tmp_path):
    paths = _make_project(tmp_path)
    _patch_paths(monkeypatch, paths)
    original_bible = _read_json(paths.global_bible_path)
    proposal_path = paths.episode_breakdown_dir(1) / "bible_proposal_stale.json"
    _write_json(
        proposal_path,
        {
            "schema_version": 1,
            "project": "tartarus",
            "episode": 1,
            "ledger_script_content_hash": "old-hash",
            "diff": _proposal_payload()["diff"],
            "tombstone_suggestions": [],
            "status": "pending",
        },
    )

    with pytest.raises(BreakdownProposalError, match="stale proposal"):
        approve_proposal(proposal_path)

    assert _read_json(paths.global_bible_path) == original_bible


def test_tombstone_acceptance_is_selective(monkeypatch, tmp_path):
    paths = _make_project(tmp_path)
    _patch_paths(monkeypatch, paths)
    proposal_path = paths.episode_breakdown_dir(1) / "bible_proposal_tombs.json"
    suggestions = [
        {
            "mention_key": "character|ghost",
            "scene_id": "EP001_SC002",
            "reason": "Dream image only.",
        },
        {
            "mention_key": "prop|rumor",
            "scene_id": "*",
            "reason": "Not a physical prop.",
        },
    ]
    _write_json(
        proposal_path,
        {
            "schema_version": 1,
            "project": "tartarus",
            "episode": 1,
            "ledger_script_content_hash": SCRIPT_HASH,
            "diff": _proposal_payload()["diff"],
            "tombstone_suggestions": suggestions,
            "status": "pending",
        },
    )

    outcome = approve_proposal(proposal_path, tombstone_indices=[1])

    tombstones = _read_json(paths.episode_breakdown_dir(1) / "tombstones.json")
    assert tombstones == [
        {
            "mention_key": "prop|rumor",
            "scene_id": "*",
            "reason": "Not a physical prop.",
            "approved_by": "JT",
        }
    ]
    assert outcome["accepted_tombstones"] == tombstones


def test_reject_is_noop_on_bible(monkeypatch, tmp_path):
    paths = _make_project(tmp_path)
    _patch_paths(monkeypatch, paths)
    original_bible = copy.deepcopy(_read_json(paths.global_bible_path))
    proposal_path = paths.episode_breakdown_dir(1) / "bible_proposal_reject.json"
    _write_json(
        proposal_path,
        {
            "schema_version": 1,
            "project": "tartarus",
            "episode": 1,
            "ledger_script_content_hash": SCRIPT_HASH,
            "diff": _proposal_payload()["diff"],
            "tombstone_suggestions": [],
            "status": "pending",
        },
    )

    proposal = reject_proposal(proposal_path)

    assert proposal["status"] == "rejected"
    assert _read_json(proposal_path)["status"] == "rejected"
    assert _read_json(paths.global_bible_path) == original_bible


def _make_project(tmp_path: Path) -> ProjectPaths:
    root = tmp_path / "tartarus"
    paths = ProjectPaths.from_root(root)
    paths.episode_breakdown_dir(1).mkdir(parents=True)
    paths.global_bible_path.parent.mkdir(parents=True)
    _write_json(paths.global_bible_path, _base_bible())
    _write_json(paths.episode_breakdown_dir(1) / "mention_ledger.json", _ledger())
    return paths


def _patch_paths(monkeypatch: pytest.MonkeyPatch, paths: ProjectPaths) -> None:
    monkeypatch.setattr(
        breakdown_propose.ProjectPaths,
        "for_project",
        classmethod(lambda cls, project: paths),
    )


def _base_bible() -> dict:
    # Schema-complete (approve runs full GlobalBible.model_validate).
    return {
        "project": "fixture",
        "total_episodes": 1,
        "characters": {
            "jade": {
                "char_id": "jade",
                "display_name": "Jade",
                "visual_description": "Lean salvager with scarred knuckles.",
                "identity_invariants": {
                    "hair_color": "black",
                    "eye_color": "brown",
                    "skin_tone": "warm brown",
                    "build": "lean",
                    "distinguishing": [],
                },
                "phases": [],
            }
        },
        "locations": {
            "lower_decks": {
                "location_id": "lower_decks",
                "description": "metal grating, sweating pipes",
                "sublocations": {},
            }
        },
        "props": {},
    }


def _ledger() -> dict:
    return {
        "schema_version": 1,
        "project": "tartarus",
        "episode": 1,
        "script_content_hash": SCRIPT_HASH,
        "scenes": [
            {
                "scene_id": "EP001_SC002",
                "scene_hash": "scene-hash",
                "slugline": "INT. LOWER DECKS - NIGHT",
                "mentions": [
                    _mention("character", character_id="wren"),
                    _mention("location", location_id="engine_room"),
                    _mention(
                        "sublocation",
                        location_id="lower_decks",
                        sublocation="junction_node",
                    ),
                    _mention("prop", prop_id="cryo_pod"),
                    _mention("prop_state", prop_id="cryo_pod", state_id="cracked"),
                    _mention(
                        "wardrobe_change",
                        character_id="jade",
                        piece="jacket",
                        change="removed",
                    ),
                ],
                "carried_forward": False,
            }
        ],
    }


def _all_block_findings() -> list[CoverageFinding]:
    return [
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r1",
            "missing character",
            _mention("character", character_id="wren"),
            "EP001_SC002",
            "character evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r2",
            "missing location",
            _mention("location", location_id="engine_room"),
            "EP001_SC002",
            "location evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r3",
            "missing sublocation",
            _mention("sublocation", location_id="lower_decks", sublocation="junction_node"),
            "EP001_SC002",
            "sublocation evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r4",
            "missing prop",
            _mention("prop", prop_id="cryo_pod"),
            "EP001_SC002",
            "prop evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r5",
            "missing prop state",
            _mention("prop_state", prop_id="cryo_pod", state_id="cracked"),
            "EP001_SC002",
            "prop_state evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r6",
            "missing wardrobe phase",
            _mention("wardrobe_change", character_id="jade", piece="jacket", change="removed"),
            "EP001_SC002",
            "wardrobe_change evidence.",
        ),
        CoverageFinding(
            Severity.BLOCK,
            "coverage_r8",
            "identity contradiction",
            _mention(
                "identity_observation",
                character_id="jade",
                attribute="eye_color",
                observed_value="blue",
            ),
            "EP001_SC002",
            "identity evidence.",
        ),
    ]


def _proposal_payload() -> dict:
    return {
        "diff": [
            {
                "op": "add",
                "path": "characters.wren",
                "value": {
                    "char_id": "wren",
                    "display_name": "Wren",
                    "visual_description": "Towering armored figure, exposed human face.",
                    "identity_invariants": {
                        "hair_color": "unknown",
                        "eye_color": "unknown",
                        "skin_tone": "unknown",
                        "build": "unknown",
                        "distinguishing": [],
                    },
                    "phases": [
                        {
                            "phase_id": "wren_phase_1_intro",
                            "start_ep": 1,
                            "end_ep": 61,
                            "wardrobe_description": "Gunmetal blue armored chassis.",
                            "trigger": {
                                "type": "script_event",
                                "scene_ref": "EP001_SC002",
                                "description": "Wren appears in the lower decks.",
                                "evidence_hash": "a" * 64,
                            },
                            "appearance": {
                                "wardrobe": [],
                                "hair_state": "loose",
                                "visible_gear": [],
                                "notable_marks": [],
                            },
                        }
                    ],
                },
                "resolves": ["coverage_r1:character|wren"],
                "evidence": "character evidence.",
            },
            {
                "op": "add",
                "path": "locations.engine_room",
                "value": {"location_id": "engine_room", "description": "A machinery bay.", "sublocations": {}},
                "resolves": ["coverage_r2:location|engine_room"],
                "evidence": "location evidence.",
            },
            {
                "op": "add",
                "path": "locations.lower_decks.sublocations.junction_node",
                "value": {"description": "A junction node in the lower decks."},
                "resolves": ["coverage_r3:sublocation|lower_decks|junction_node"],
                "evidence": "sublocation evidence.",
            },
            {
                "op": "add",
                "path": "props.cryo_pod",
                "value": {
                    "prop_id": "cryo_pod",
                    "description": "Brushed-steel cryo pod, frost-covered viewport.",
                    "states": {
                        "sealed": {
                            "description": "Sealed cryo pod.",
                            "visual_delta": "Closed frosted lid.",
                        },
                        "cracked": {
                            "description": "Cracked cryo pod.",
                            "visual_delta": "A split across the viewport.",
                        },
                    },
                    "initial_state": "sealed",
                    "transitions": [
                        {
                            "from": "sealed",
                            "to": "cracked",
                            "reversible": False,
                            "trigger_scene": "EP001_SC002",
                        }
                    ],
                    "carriable": False,
                },
                "resolves": [
                    "coverage_r4:prop|cryo_pod",
                    "coverage_r5:prop_state|cryo_pod|cracked",
                ],
                "evidence": "prop evidence.",
            },
            {
                "op": "add",
                "path": "characters.jade.phases",
                "value": {
                    "phase_id": "jade_phase_2_jacket_off",
                    "start_ep": 1,
                    "end_ep": 61,
                    "wardrobe_description": "Tanktop, jacket tied around waist.",
                    "trigger": {
                        "type": "script_event",
                        "scene_ref": "EP001_SC002",
                        "description": "Jade removes her jacket.",
                        "evidence_hash": "b" * 64,
                    },
                    "appearance": {
                        "wardrobe": [
                            {
                                "piece": "jacket",
                                "descriptor": "oil-dark utility jacket",
                                "state": "removed",
                                "salient": True,
                            }
                        ],
                        "hair_state": "loose",
                        "visible_gear": [],
                        "notable_marks": [],
                    },
                },
                "resolves": ["coverage_r6:wardrobe_change|jade|jacket|removed"],
                "evidence": "wardrobe_change evidence.",
            },
            {
                "op": "add",
                "path": "characters.jade.transients",
                "value": ["steady"],
                "resolves": ["coverage_r7:transient_state|jade|steady"],
                "evidence": "transient evidence.",
            },
        ],
        "tombstone_suggestions": [
            {
                "mention_key": "identity_observation|jade|eye_color|blue",
                "scene_id": "EP001_SC002",
                "reason": "Contradictory lighting gag; JT must approve if intentional.",
            }
        ],
    }


def _mention(kind: str, **kwargs) -> dict:
    mention = {
        "kind": kind,
        "surface_text": kind,
        "scene_id": "EP001_SC002",
        "scene_hash": "scene-hash",
        "span_quote": f"{kind} evidence.",
    }
    mention.update(kwargs)
    return mention


def _op(proposal: dict, path: str) -> dict:
    for op in proposal["diff"]:
        if op["path"] == path:
            return op
    raise AssertionError(f"missing op {path}")


def _read_json(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def _write_json(path: Path, data: object) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2), encoding="utf-8")
