from __future__ import annotations

import ast
import dataclasses
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path

import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient

from recoil.api import notes_ledger, notes_snapshot, proposals_routes
from recoil.api.eventbus import BUS
from recoil.api.executors import script_edit
from recoil.api.main import app
from recoil.api.stub_routes import _reset_acted_for_tests


PROJECT_ID = "notes_project"
EPISODE_ID = "ep_notes"
ORIGINAL_SCRIPT = b"Title: Original\n\nFADE IN:\nINT. ROOM - DAY\n"
NEW_SCRIPT = "Title: Rewritten\n\nFADE IN:\nEXT. STREET - NIGHT\n"


@pytest.fixture
def episode_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    projects_root = tmp_path / "projects"
    projects_root.mkdir()
    (projects_root / ".recoil-data-root").write_text("recoil-data-root\n", encoding="utf-8")
    root = projects_root / PROJECT_ID / "episodes" / EPISODE_ID
    root.mkdir(parents=True)
    (root / "script.fountain").write_bytes(ORIGINAL_SCRIPT)
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))
    monkeypatch.setattr(script_edit, "projects_root", lambda: projects_root)
    monkeypatch.setattr(proposals_routes, "_PROPOSALS_ROOT", tmp_path / "proposals")
    return root


@pytest.fixture
def client() -> TestClient:
    _reset_acted_for_tests()
    BUS._reset_for_tests()
    with TestClient(app) as test_client:
        yield test_client


def _now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _make_record(
    *,
    note_id: str = "note-001",
    project_id: str = PROJECT_ID,
    episode_id: str = EPISODE_ID,
    status: str = "proposed",
    raw_text: str = "Fix the script.",
    target: dict | None = None,
    links: dict | None = None,
    artifacts_edited: list[dict] | None = None,
    mechanism: str = "patch_script",
) -> notes_ledger.NoteRecord:
    return notes_ledger.NoteRecord(
        note_id=note_id,
        schema_version=notes_ledger.NOTE_SCHEMA_VERSION,
        created_at=_now(),
        author="director",
        project_id=project_id,
        episode_id=episode_id,
        raw_text=raw_text,
        action_type="fix",
        domain="script",
        mechanism=mechanism,
        blast_tier="T1_ONE_ARTIFACT",
        scope="episode",
        target=target or {"script_sha256": "target", "pre_edit_sha256": "pre"},
        reference_target=None,
        classification={},
        artifacts_edited=artifacts_edited or [],
        derive_triggered=None,
        approvals={},
        spend={},
        links=links or {},
        status=status,
        rolled_back_by=None,
        error=None,
    )


def _notes_files(root: Path) -> list[Path]:
    return sorted((root / "_history" / "notes").glob("*.json"))


def _script_path(root: Path) -> Path:
    return root / "script.fountain"


def _single_note(root: Path) -> notes_ledger.NoteRecord:
    files = _notes_files(root)
    assert len(files) == 1
    return notes_ledger.read_note(root, files[0].stem)


def _approve_script_edit(client: TestClient, new_script: str = NEW_SCRIPT) -> dict:
    create_body = {
        "target": f"episode:{EPISODE_ID}",
        "title": "Protocol script edit",
        "est_cost_usd": 0.0,
        "est_time": "instant",
        "kind": "ScriptEditProposal",
        "project": PROJECT_ID,
        "diff": [{"kind": "rewrite", "after": new_script}],
    }
    created = client.post("/api/chat/proposals", json=create_body)
    assert created.status_code == 200, created.text
    proposal_id = created.json()["id"]

    approved = client.post(
        f"/api/chat/proposals/{proposal_id}/approve",
        json={"project": PROJECT_ID},
    )
    return {"proposal_id": proposal_id, "response": approved}


def test_ledger_roundtrip(episode_root: Path) -> None:
    rec = _make_record()
    notes_ledger.write_note(rec, episode_root)

    assert notes_ledger.read_note(episode_root, rec.note_id) == rec
    assert rec in notes_ledger.list_notes(episode_root)

    with pytest.raises(ValueError, match="action_type"):
        notes_ledger.validate_record(dataclasses.replace(rec, action_type="bad"))
    with pytest.raises(ValueError, match="mechanism"):
        notes_ledger.validate_record(dataclasses.replace(rec, mechanism="bad"))
    with pytest.raises(ValueError, match="schema_version"):
        notes_ledger.validate_record(dataclasses.replace(rec, schema_version="9.9"))


def test_ledger_immutability(episode_root: Path) -> None:
    fields = {field.name for field in dataclasses.fields(notes_ledger.NoteRecord)}
    assert notes_ledger.MUTABLE_FIELDS | notes_ledger.IMMUTABLE_FIELDS == fields
    assert notes_ledger.MUTABLE_FIELDS & notes_ledger.IMMUTABLE_FIELDS == frozenset()

    rec = _make_record(links={"proposal_id": "p1"})
    notes_ledger.write_note(rec, episode_root)

    notes_ledger.write_note(dataclasses.replace(rec, status="applied"), episode_root)
    notes_ledger.write_note(
        dataclasses.replace(rec, status="applied", links={"proposal_id": "p1", "review": "r1"}),
        episode_root,
    )

    with pytest.raises(ValueError, match="raw_text"):
        notes_ledger.write_note(dataclasses.replace(rec, raw_text="changed"), episode_root)
    with pytest.raises(ValueError, match="target"):
        notes_ledger.write_note(dataclasses.replace(rec, target={"script_sha256": "changed"}), episode_root)
    with pytest.raises(ValueError, match="mechanism"):
        notes_ledger.write_note(dataclasses.replace(rec, mechanism="noop"), episode_root)
    with pytest.raises(ValueError, match="links"):
        notes_ledger.write_note(
            dataclasses.replace(rec, status="applied", links={"proposal_id": "p2", "review": "r1"}),
            episode_root,
        )


def test_new_note_id_contract() -> None:
    ids = [notes_ledger.new_note_id() for _ in range(200)]

    assert len(set(ids)) == len(ids)
    assert sorted(ids) == ids
    assert all(re.fullmatch(r"^[A-Za-z0-9._-]+$", note_id) for note_id in ids)


def test_snapshot_binary_atomic(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    src = tmp_path / "script.fountain"
    src_bytes = b"\x00script\nbytes\xff"
    src.write_bytes(src_bytes)
    episode = tmp_path / "episode"
    calls: list[tuple[Path, bytes]] = []

    def spy(path: Path, data: bytes) -> None:
        calls.append((Path(path), data))
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(data)

    monkeypatch.setattr("recoil.api.notes_snapshot.atomic_write_bytes", spy)
    snapshot = notes_snapshot.snapshot_artifact(src, kind="script_edits", note_id="note-abc", episode_root=episode)

    assert snapshot.read_bytes() == src_bytes
    assert snapshot.parent == episode / "_history" / "script_edits"
    assert re.fullmatch(r"note-abc__\d{8}T\d{6}Z__[a-f0-9]{8}\.fountain", snapshot.name)
    assert snapshot.name.endswith(f"{hashlib.sha256(src_bytes).hexdigest()[:8]}.fountain")
    assert calls == [(snapshot, src_bytes)]

    with pytest.raises(FileNotFoundError):
        notes_snapshot.snapshot_artifact(tmp_path / "missing.bin", kind="script_edits", note_id="n2", episode_root=episode)
    with pytest.raises(ValueError, match="boards"):
        notes_snapshot.snapshot_artifact(src, kind="boards", note_id="n3", episode_root=episode)

    tree = ast.parse(Path(notes_snapshot.__file__).read_text(encoding="utf-8"))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            assert all(alias.name not in {"os", "tempfile"} for alias in node.names)
        if isinstance(node, ast.ImportFrom):
            assert node.module not in {"os", "tempfile"}
        if isinstance(node, ast.Attribute):
            assert not (
                isinstance(node.value, ast.Name)
                and node.value.id == "os"
                and node.attr == "replace"
            )
        if isinstance(node, ast.Name):
            assert node.id != "tempfile"


def test_execute_mints_proposed_note_before_mutation(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    real_write = script_edit.atomic_write_text
    observed: list[bool] = []

    def spy(path: Path, content: str, *, encoding: str = "utf-8") -> None:
        proposed_present = False
        for note_path in _notes_files(episode_root):
            data = json.loads(note_path.read_text(encoding="utf-8"))
            if data.get("status") == "proposed":
                proposed_present = True
        observed.append(proposed_present)
        real_write(path, content, encoding=encoding)

    monkeypatch.setattr("recoil.api.executors.script_edit.atomic_write_text", spy)
    result = script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID, proposal_id="proposal-123")

    assert observed == [True]
    note = notes_ledger.read_note(episode_root, result["note_id"])
    assert note.target["script_sha256"] == hashlib.sha256(NEW_SCRIPT.encode("utf-8")).hexdigest()
    assert note.target["pre_edit_sha256"] == hashlib.sha256(ORIGINAL_SCRIPT).hexdigest()
    assert note.links["proposal_id"] == "proposal-123"


def test_proposed_write_failure_no_mutation(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    err = OSError("proposed write failed")

    def raise_on_first_call(*_args, **_kwargs):
        raise err

    monkeypatch.setattr("recoil.api.executors.script_edit.notes_ledger.write_note", raise_on_first_call)

    with pytest.raises(OSError) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert exc.value is err
    assert _script_path(episode_root).read_bytes() == ORIGINAL_SCRIPT
    assert not list((episode_root / "_history" / "script_edits").glob("*"))
    assert not _notes_files(episode_root)


def test_execute_happy_path_flips_applied(episode_root: Path) -> None:
    result = script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert _script_path(episode_root).read_text(encoding="utf-8") == NEW_SCRIPT
    assert list((episode_root / "_history" / "script_edits").glob("*"))
    note = notes_ledger.read_note(episode_root, result["note_id"])
    assert note.status == "applied"
    assert note.artifacts_edited


def test_execute_snapshots_original_before_write(episode_root: Path) -> None:
    result = script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    snapshot = Path(result["pre_snapshot_ref"])
    assert snapshot.read_bytes() == ORIGINAL_SCRIPT
    note = notes_ledger.read_note(episode_root, result["note_id"])
    assert note.target["pre_edit_sha256"] == hashlib.sha256(ORIGINAL_SCRIPT).hexdigest()


def test_execute_requires_project_id(episode_root: Path) -> None:
    with pytest.raises(HTTPException) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=None)

    assert exc.value.status_code == 422
    assert exc.value.detail["error"] == "project_id_required"
    assert not _notes_files(episode_root)
    assert _script_path(episode_root).read_bytes() == ORIGINAL_SCRIPT


def test_execute_unresolvable_path_404_no_note(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, client: TestClient
) -> None:
    projects_root = tmp_path / "projects"
    projects_root.mkdir()
    (projects_root / ".recoil-data-root").write_text("recoil-data-root\n", encoding="utf-8")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(projects_root))
    monkeypatch.setattr(script_edit, "projects_root", lambda: projects_root)
    monkeypatch.setattr(proposals_routes, "_PROPOSALS_ROOT", tmp_path / "proposals")

    create_body = {
        "target": f"episode:{EPISODE_ID}",
        "title": "Missing script",
        "est_cost_usd": 0.0,
        "est_time": "instant",
        "kind": "ScriptEditProposal",
        "project": PROJECT_ID,
        "diff": [{"kind": "rewrite", "after": NEW_SCRIPT}],
    }
    created = client.post("/api/chat/proposals", json=create_body)
    assert created.status_code == 200, created.text
    proposal_id = created.json()["id"]

    approved = client.post(
        f"/api/chat/proposals/{proposal_id}/approve",
        json={"project": PROJECT_ID},
    )

    assert approved.status_code == 404
    assert approved.json()["detail"]["error"] == "script_not_found"
    assert not list(
        (projects_root / PROJECT_ID / "episodes" / EPISODE_ID / "_history" / "notes").glob("*.json")
    )
    proposal_doc = json.loads(
        (tmp_path / "proposals" / PROJECT_ID / f"{proposal_id}.json").read_text(encoding="utf-8")
    )
    assert proposal_doc["status"] == "approved"


def test_write_failure_flips_failed_and_keeps_script(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    err = OSError("mutation failed")

    def raise_oserror(*_args, **_kwargs):
        raise err

    monkeypatch.setattr("recoil.api.executors.script_edit.atomic_write_text", raise_oserror)

    with pytest.raises(OSError) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert exc.value is err
    assert _script_path(episode_root).read_bytes() == ORIGINAL_SCRIPT
    note = _single_note(episode_root)
    assert note.status == "failed"
    assert note.error


def test_flip_failure_after_mutation_keeps_proposed(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    original_write_note = notes_ledger.write_note
    err = OSError("applied flip failed")

    def raise_on_applied(rec: notes_ledger.NoteRecord, root: Path) -> Path:
        if rec.status == "applied":
            raise err
        return original_write_note(rec, root)

    monkeypatch.setattr("recoil.api.executors.script_edit.notes_ledger.write_note", raise_on_applied)

    with pytest.raises(OSError) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert exc.value is err
    assert _script_path(episode_root).read_text(encoding="utf-8") == NEW_SCRIPT
    note = _single_note(episode_root)
    assert note.status == "proposed"
    assert note.artifacts_edited == []


def test_approve_proposal_transaction_success(episode_root: Path, client: TestClient) -> None:
    outcome = _approve_script_edit(client)

    response = outcome["response"]
    assert response.status_code == 200, response.text
    data = response.json()
    assert data["status"] == "executed"
    assert _script_path(episode_root).read_text(encoding="utf-8") == NEW_SCRIPT
    note = notes_ledger.read_note(episode_root, data["note_id"])
    assert note.status == "applied"
    assert note.links["proposal_id"] == outcome["proposal_id"]
    assert note.artifacts_edited


def test_mutation_and_failed_note_write_both_fail(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    original_write_note = notes_ledger.write_note
    mutation_err = OSError("mutation failed")
    failed_note_err = OSError("failed note write failed")

    def raise_mutation(*_args, **_kwargs):
        raise mutation_err

    def raise_on_failed(rec: notes_ledger.NoteRecord, root: Path) -> Path:
        if rec.status == "failed":
            raise failed_note_err
        return original_write_note(rec, root)

    monkeypatch.setattr("recoil.api.executors.script_edit.atomic_write_text", raise_mutation)
    monkeypatch.setattr("recoil.api.executors.script_edit.notes_ledger.write_note", raise_on_failed)

    with pytest.raises(OSError) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert exc.value is mutation_err
    assert _script_path(episode_root).read_bytes() == ORIGINAL_SCRIPT
    note = _single_note(episode_root)
    assert note.status == "proposed"


def test_snapshot_failure_keeps_proposed_no_mutation(
    episode_root: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    err = OSError("snapshot failed")

    def raise_oserror(*_args, **_kwargs):
        raise err

    monkeypatch.setattr("recoil.api.executors.script_edit.snapshot_artifact", raise_oserror)

    with pytest.raises(OSError) as exc:
        script_edit.execute(EPISODE_ID, NEW_SCRIPT, project_id=PROJECT_ID)

    assert exc.value is err
    note = _single_note(episode_root)
    assert note.status == "proposed"
    assert note.artifacts_edited == []
    assert _script_path(episode_root).read_bytes() == ORIGINAL_SCRIPT
