"""S2 bible-diff proposal and Gate A approval helpers.

The existing Stage-1 "Breakdown Pass" in ``ingest_pipeline`` writes
``global_bible.json``. This module is for the newer breakdown layer's S2
proposal flow: it drafts add-only bible diffs from coverage BLOCKs and never
writes the bible until an operator approves a proposal. This deliberately does
not integrate with ``~/.recoil/proposals`` / ProposalTray; Gate A v1 is a
chat/Finder-reviewed JSON proposal lifecycle.
"""

from __future__ import annotations

import copy
import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from recoil.core.atomic_write import atomic_write_json
from recoil.core.model_profiles import get_model
from recoil.core.paths import ProjectPaths
from recoil.pipeline._lib.breakdown_coverage_validator import (
    CoverageFinding,
    mention_key,
    verify_coverage,
    write_coverage_report,
)
from recoil.pipeline._lib.prose_validator import Severity
from recoil.pipeline._lib.render_schema import (
    validate_appearance,
    validate_identity_invariants,
    validate_phase_trigger,
    validate_prop_state_machine,
)


PROPOSAL_SCHEMA_VERSION = 1

BREAKDOWN_PROPOSE_SYSTEM = (
    "You are the S2 bible-diff proposer for Gate A coverage repair.\n"
    "Given a mention ledger, bible, and structured coverage BLOCKs, return "
    "strict JSON only. Draft additive bible diff ops that resolve the BLOCKs. "
    "Never remove, replace, or rewrite existing bible entries.\n"
    "\n"
    "Allowed diff op shape: {op:'add', path, value, resolves, evidence}. "
    "Use L9 shapes: character stubs include identity_invariants and a phase "
    "skeleton; sublocations are {description}; props use states, "
    "initial_state, transitions, carriable; wardrobe phase additions include "
    "trigger and appearance, AND the legacy CharacterPhase fields phase_id, "
    "(a NEW character add likewise carries char_id, display_name, and "
    "visual_description — the full existing schema is enforced on approve), "
    "start_ep, end_ep, wardrobe_description (existing keys are retained — a "
    "phase without them fails schema validation). Add tombstone_suggestions only when an additive "
    "bible entry cannot honestly resolve a BLOCK."
)


class BreakdownProposalError(RuntimeError):
    """Raised when S2 proposal or Gate A approval cannot proceed."""


def propose_bible_diff(
    project: str,
    episode: int,
    ledger: dict,
    bible: dict,
    block_findings: list[CoverageFinding],
    *,
    model: str | None = None,
    write: bool = True,
) -> tuple[dict, Path | None]:
    """Draft and optionally write an add-only bible diff proposal.

    ``block_findings`` must come from ``verify_coverage``. The proposal is
    bound to ``ledger["script_content_hash"]`` so approval can refuse stale
    operator-reviewed JSON.
    """

    blocks = [finding for finding in block_findings if finding.severity is Severity.BLOCK]
    prompt = _build_proposal_prompt(ledger, bible, blocks)
    model_id = model or get_model("prose_author", "text")
    try:
        raw = _call_proposal_model(model_id, BREAKDOWN_PROPOSE_SYSTEM, prompt)
        drafted = _parse_proposal_response(raw)
    except BreakdownProposalError:
        raise
    except Exception as exc:  # noqa: BLE001 - fail-loud operator pass
        raise BreakdownProposalError(f"proposal generation failed: {type(exc).__name__}") from exc

    proposal = {
        "schema_version": PROPOSAL_SCHEMA_VERSION,
        "project": project,
        "episode": episode,
        "generated_at": datetime.now(UTC).isoformat(),
        "ledger_script_content_hash": str(ledger.get("script_content_hash", "")),
        "diff": drafted["diff"],
        "tombstone_suggestions": drafted["tombstone_suggestions"],
        "status": "pending",
    }
    _validate_proposal_shape(proposal)

    if not write:
        return proposal, None

    dest_dir = ProjectPaths.for_project(project).episode_breakdown_dir(episode)
    dest_dir.mkdir(parents=True, exist_ok=True)
    path = _proposal_path(dest_dir)
    atomic_write_json(path, proposal, indent=2)
    return proposal, path


def approve_proposal(
    proposal_file: Path,
    *,
    tombstone_indices: list[int] | None = None,
) -> dict[str, Any]:
    """Apply an approved add-only proposal, then re-run coverage validation."""

    proposal_path = Path(proposal_file)
    proposal = _read_json_object(proposal_path)
    if proposal.get("status") == "rejected":
        raise BreakdownProposalError("cannot approve a rejected proposal")
    _validate_proposal_shape(proposal)

    project = str(proposal.get("project", ""))
    episode = int(proposal.get("episode", 0))
    paths = ProjectPaths.for_project(project)
    ledger_path = paths.episode_breakdown_dir(episode) / "mention_ledger.json"
    ledger = _read_json_object(ledger_path)
    current_hash = str(ledger.get("script_content_hash", ""))
    proposal_hash = str(proposal.get("ledger_script_content_hash", ""))
    if not current_hash or proposal_hash != current_hash:
        raise BreakdownProposalError(
            "stale proposal: ledger_script_content_hash does not match current ledger"
        )

    bible_path = paths.global_bible_path
    bible = _read_json_object(bible_path)
    updated_bible = copy.deepcopy(bible)
    for op in proposal.get("diff", []):
        _apply_add_op(updated_bible, op)

    schema_errors = validate_l9_bible(updated_bible)
    schema_errors.extend(_validate_legacy_phase_contract(updated_bible))
    if schema_errors:
        raise BreakdownProposalError("post-apply bible schema errors: " + "; ".join(schema_errors))

    # FAIL-CLOSED, full-contract: the candidate must satisfy the COMPLETE
    # existing GlobalBible schema (not just the additive L9 blocks) — this is
    # exactly the validation ingest/live readers run later, so anything that
    # would break them refuses here, before any write.
    try:
        from recoil.pipeline._lib.render_schema import GlobalBible

        GlobalBible.model_validate(updated_bible)
    except Exception as exc:
        raise BreakdownProposalError(
            f"post-apply bible fails GlobalBible schema validation: {exc}"
        ) from exc

    selected_tombstones = _selected_tombstones(
        proposal.get("tombstone_suggestions", []),
        tombstone_indices or [],
    )

    # FAIL-CLOSED: validate the candidate bible IN MEMORY before any write.
    # Residual BLOCKs mean the proposal is incomplete — the bible must not
    # mutate and the proposal must not flip to approved.
    candidate_tombstones = (
        _load_tombstones(paths.episode_breakdown_dir(episode) / "tombstones.json")
        + list(selected_tombstones)
    )
    results = verify_coverage(ledger, updated_bible, tombstones=candidate_tombstones)
    report_path = write_coverage_report(results, ledger, paths.episode_breakdown_dir(episode))
    if _count(results, Severity.BLOCK):
        return {
            "proposal_path": proposal_path,
            "bible_path": bible_path,
            "report_path": report_path,
            "results": results,
            "accepted_tombstones": [],
            "block_count": _count(results, Severity.BLOCK),
            "warn_count": _count(results, Severity.WARN),
            "applied": False,
        }

    atomic_write_json(bible_path, updated_bible, indent=2)

    accepted_tombstones = _write_tombstones(
        paths.episode_breakdown_dir(episode) / "tombstones.json",
        selected_tombstones,
    )

    proposal["status"] = "approved"
    proposal["approved_at"] = datetime.now(UTC).isoformat()
    proposal["approved_tombstone_indices"] = sorted(tombstone_indices or [])
    atomic_write_json(proposal_path, proposal, indent=2)

    return {
        "proposal_path": proposal_path,
        "bible_path": bible_path,
        "report_path": report_path,
        "results": results,
        "accepted_tombstones": accepted_tombstones,
        "block_count": _count(results, Severity.BLOCK),
        "warn_count": _count(results, Severity.WARN),
        "applied": True,
    }


def reject_proposal(proposal_file: Path) -> dict:
    """Mark a proposal rejected without applying anything to the bible."""

    proposal_path = Path(proposal_file)
    proposal = _read_json_object(proposal_path)
    proposal["status"] = "rejected"
    proposal["rejected_at"] = datetime.now(UTC).isoformat()
    atomic_write_json(proposal_path, proposal, indent=2)
    return proposal


_LEGACY_PHASE_REQUIRED = ("phase_id", "start_ep", "end_ep", "wardrobe_description")


def _validate_legacy_phase_contract(bible: dict) -> list[str]:
    """Existing keys are RETAINED: every phase must still satisfy the legacy
    CharacterPhase requirements (render_schema.py) or the next
    GlobalBible.model_validate in ingest fails on the bible we wrote."""
    errors: list[str] = []
    characters = bible.get("characters") if isinstance(bible.get("characters"), dict) else {}
    for char_id, character in characters.items():
        phases = character.get("phases") if isinstance(character, dict) else None
        for index, phase in enumerate(phases if isinstance(phases, list) else []):
            if not isinstance(phase, dict):
                continue
            for field in _LEGACY_PHASE_REQUIRED:
                if field not in phase:
                    errors.append(
                        f"characters.{char_id}.phases[{index}] missing legacy "
                        f"required field {field!r} (CharacterPhase contract)"
                    )
    return errors


def validate_l9_bible(bible: dict) -> list[str]:
    """Run the additive L9 schema validators over a complete bible object."""

    errors: list[str] = []
    characters = bible.get("characters") if isinstance(bible.get("characters"), dict) else {}
    for char_id, character in characters.items():
        if not isinstance(character, dict):
            errors.append(f"characters.{char_id} must be an object")
            continue
        errors.extend(f"characters.{char_id}.{error}" for error in validate_identity_invariants(character))
        phases = character.get("phases") if isinstance(character.get("phases"), list) else []
        for index, phase in enumerate(phases):
            if not isinstance(phase, dict):
                errors.append(f"characters.{char_id}.phases[{index}] must be an object")
                continue
            prefix = f"characters.{char_id}.phases[{index}]"
            errors.extend(f"{prefix}.{error}" for error in validate_phase_trigger(phase))
            errors.extend(f"{prefix}.{error}" for error in validate_appearance(phase))

    locations = bible.get("locations") if isinstance(bible.get("locations"), dict) else {}
    for loc_id, location in locations.items():
        if not isinstance(location, dict):
            errors.append(f"locations.{loc_id} must be an object")
            continue
        sublocations = location.get("sublocations")
        if sublocations is None:
            continue
        if not isinstance(sublocations, dict):
            errors.append(f"locations.{loc_id}.sublocations must be an object")
            continue
        for sub_id, sublocation in sublocations.items():
            if not isinstance(sublocation, dict):
                errors.append(f"locations.{loc_id}.sublocations.{sub_id} must be an object")
            elif not isinstance(sublocation.get("description"), str):
                errors.append(
                    f"locations.{loc_id}.sublocations.{sub_id}.description must be a string"
                )

    props = bible.get("props") if isinstance(bible.get("props"), dict) else {}
    for prop_id, prop in props.items():
        if not isinstance(prop, dict):
            errors.append(f"props.{prop_id} must be an object")
            continue
        errors.extend(f"props.{prop_id}.{error}" for error in validate_prop_state_machine(prop))
    return errors


def _proposal_path(dest_dir: Path) -> Path:
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
    path = dest_dir / f"bible_proposal_{stamp}.json"
    suffix = 2
    while path.exists():
        path = dest_dir / f"bible_proposal_{stamp}-{suffix}.json"
        suffix += 1
    return path


def _build_proposal_prompt(ledger: dict, bible: dict, blocks: list[CoverageFinding]) -> str:
    payload = {
        "ledger_script_content_hash": ledger.get("script_content_hash", ""),
        "bible": bible,
        "blocks": [
            {
                "check": finding.check,
                "message": finding.message,
                "scene_id": finding.scene_id,
                "evidence": finding.evidence,
                "mention_key": mention_key(finding.mention),
                "mention": finding.mention,
            }
            for finding in blocks
        ],
        "response_shape": {
            "diff": [
                {
                    "op": "add",
                    "path": "characters.example_id",
                    "value": {},
                    "resolves": ["coverage_r1:character|example_id"],
                    "evidence": "verbatim span quote",
                }
            ],
            "tombstone_suggestions": [
                {
                    "mention_key": "kind|required|fields",
                    "scene_id": "EP001_SC001",
                    "reason": "why this should be operator-approved instead of bible-added",
                }
            ],
        },
    }
    return json.dumps(payload, ensure_ascii=True, indent=2)


def _parse_proposal_response(raw: str) -> dict[str, Any]:
    try:
        parsed = json.loads(_strip_json_fence(raw))
    except json.JSONDecodeError as exc:
        raise BreakdownProposalError("invalid JSON from proposal model") from exc
    if not isinstance(parsed, dict):
        raise BreakdownProposalError("proposal model response must be a JSON object")

    diff = parsed.get("diff", [])
    tombstones = parsed.get("tombstone_suggestions", [])
    if not isinstance(diff, list):
        raise BreakdownProposalError("proposal diff must be a list")
    if not isinstance(tombstones, list):
        raise BreakdownProposalError("tombstone_suggestions must be a list")
    return {"diff": diff, "tombstone_suggestions": tombstones}


def _validate_proposal_shape(proposal: dict) -> None:
    if proposal.get("schema_version") != PROPOSAL_SCHEMA_VERSION:
        raise BreakdownProposalError("unsupported proposal schema_version")
    if not isinstance(proposal.get("ledger_script_content_hash"), str):
        raise BreakdownProposalError("proposal missing ledger_script_content_hash")
    if not isinstance(proposal.get("diff"), list):
        raise BreakdownProposalError("proposal diff must be a list")
    for index, op in enumerate(proposal["diff"]):
        if not isinstance(op, dict):
            raise BreakdownProposalError(f"diff[{index}] must be an object")
        if op.get("op") != "add":
            raise BreakdownProposalError(f"diff[{index}] uses unsupported op {op.get('op')!r}")
        if not isinstance(op.get("path"), str) or not op["path"]:
            raise BreakdownProposalError(f"diff[{index}].path must be a non-empty string")
        if "value" not in op:
            raise BreakdownProposalError(f"diff[{index}].value is required")
        if not isinstance(op.get("resolves"), list):
            raise BreakdownProposalError(f"diff[{index}].resolves must be a list")
        if not isinstance(op.get("evidence"), str):
            raise BreakdownProposalError(f"diff[{index}].evidence must be a string")

    tombstones = proposal.get("tombstone_suggestions")
    if not isinstance(tombstones, list):
        raise BreakdownProposalError("tombstone_suggestions must be a list")
    for index, suggestion in enumerate(tombstones):
        if not isinstance(suggestion, dict):
            raise BreakdownProposalError(f"tombstone_suggestions[{index}] must be an object")
        for key in ("mention_key", "scene_id", "reason"):
            if not isinstance(suggestion.get(key), str) or not suggestion[key]:
                raise BreakdownProposalError(f"tombstone_suggestions[{index}].{key} is required")


def _apply_add_op(target: dict, op: dict) -> None:
    if op.get("op") != "add":
        raise BreakdownProposalError(f"unsupported diff op {op.get('op')!r}; v1 accepts add only")
    parts = str(op.get("path", "")).split(".")
    if any(not part for part in parts):
        raise BreakdownProposalError(f"invalid add path {op.get('path')!r}")
    cursor: Any = target
    for part in parts[:-1]:
        if not isinstance(cursor, dict):
            raise BreakdownProposalError(f"cannot traverse non-object at {part!r}")
        if part not in cursor:
            cursor[part] = {}
        cursor = cursor[part]

    leaf = parts[-1]
    if isinstance(cursor, dict):
        existing = cursor.get(leaf)
        if existing is None and leaf not in cursor:
            cursor[leaf] = copy.deepcopy(op["value"])
            return
        if isinstance(existing, list):
            existing.append(copy.deepcopy(op["value"]))
            return
        raise BreakdownProposalError(f"add path already exists: {op['path']}")
    raise BreakdownProposalError(f"cannot apply add at non-object path {op['path']!r}")


def _selected_tombstones(suggestions: list[dict], indices: list[int]) -> list[dict]:
    if not indices:
        return []
    accepted: list[dict] = []
    for index in sorted(set(indices)):
        if index < 0 or index >= len(suggestions):
            raise BreakdownProposalError(f"tombstone index out of range: {index}")
        suggestion = suggestions[index]
        accepted.append(
            {
                "mention_key": suggestion["mention_key"],
                "scene_id": suggestion["scene_id"],
                "reason": suggestion["reason"],
                "approved_by": "JT",
            }
        )
    return accepted


def _write_tombstones(path: Path, accepted: list[dict]) -> list[dict]:
    if not accepted:
        return []
    existing = _load_tombstones(path)
    atomic_write_json(path, [*existing, *accepted], indent=2)
    return accepted


def _load_tombstones(path: Path) -> list[dict]:
    if not path.is_file():
        return []
    data = _read_json(path)
    if not isinstance(data, list):
        raise BreakdownProposalError(f"tombstones file must contain a list: {path}")
    return [item for item in data if isinstance(item, dict)]


def _read_json_object(path: Path) -> dict:
    data = _read_json(path)
    if not isinstance(data, dict):
        raise BreakdownProposalError(f"expected JSON object: {path}")
    return data


def _read_json(path: Path) -> Any:
    try:
        return json.loads(Path(path).read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise BreakdownProposalError(f"file not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise BreakdownProposalError(f"invalid JSON: {path}") from exc


def _strip_json_fence(raw: str) -> str:
    text = (raw or "").strip()
    if text.startswith("```"):
        lines = text.splitlines()
        if lines and lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()
    return text


def _count(results: list[CoverageFinding], severity: Severity) -> int:
    return sum(1 for result in results if result.severity is severity)


def _call_proposal_model(model: str, system_prompt: str, user_prompt: str) -> str:
    """LLM call seam - monkeypatched in tests."""

    from recoil.core.claude_cli import claude_transport

    if claude_transport() == "cli":
        from recoil.core.claude_cli import claude_cli_call

        return claude_cli_call(user_prompt, system_prompt=system_prompt, model=model)

    from recoil.core.anthropic_client import anthropic_client

    client = anthropic_client()
    response = client.messages.create(
        model=model,
        max_tokens=4096,
        system=system_prompt,
        messages=[{"role": "user", "content": user_prompt}],
    )
    return response.content[0].text


__all__ = [
    "BREAKDOWN_PROPOSE_SYSTEM",
    "BreakdownProposalError",
    "approve_proposal",
    "propose_bible_diff",
    "reject_proposal",
    "validate_l9_bible",
]
