#!/usr/bin/env python3
"""Append a completed sanity run to the repository-local ignored JSONL ledger and print its path."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "atm-beads" / "scripts"))
import sc_render  # noqa: E402


def fail(message: str) -> None:
    raise SystemExit(f"sanity-run-history: {message}")


def read_vars(path: Path) -> dict[str, object]:
    try:
        value = json.loads(path.read_text())
    except (OSError, json.JSONDecodeError) as exc:
        fail(f"cannot read completion vars {path}: {exc}")
    if not isinstance(value, dict):
        fail("completion vars must be a JSON object")
    for field in ("sprint", "run_id", "reviewer", "commit", "started_at", "completed_at"):
        if not isinstance(value.get(field), str) or not value[field]:
            fail(f"completion vars.{field} must be a nonempty string")
    if value["reviewer"] not in {"sanity-llm", "sanity-jev"}:
        fail("invalid reviewer")
    if value.get("verdict") not in {"PASS", "FAIL", "CANNOT_RUN"}:
        fail("invalid verdict")
    count = value.get("findings_count")
    if value["verdict"] == "CANNOT_RUN":
        if count is not None or not value.get("error"):
            fail("CANNOT_RUN requires null findings_count and an error")
    elif isinstance(count, bool) or not isinstance(count, int) or count < 0:
        fail("findings_count must be a nonnegative integer")
    for field in ("started_at", "completed_at"):
        utc_timestamp(value[field])
    return value


SLOT_ERROR_KEYS = ("code", "message", "recoverable", "deliverable")
LATER_FIELDS = ("errors", "jev_receipts")  # record fields added after rows were first logged


def valid_slot_error(error: object) -> bool:
    """`code`, `message`, `recoverable` well typed; `deliverable` a number >= 1, or None when the envelope lacks it."""
    return (isinstance(error, dict) and set(error) == set(SLOT_ERROR_KEYS)
            and type(error["code"]) is str and bool(error["code"]) and type(error["message"]) is str
            and type(error["recoverable"]) is bool
            and (error["deliverable"] is None or (type(error["deliverable"]) is int and error["deliverable"] >= 1)))


def slot_errors(value: dict[str, object]) -> list[dict[str, object]]:
    """Each failed slot's error, copied verbatim from its failure envelope; vars without reviewer_results have none.
    A malformed envelope (one whose error is not a well-typed code/message/recoverable) is not a slot error: its
    reviewer merge is CANNOT_RUN, whose `error` the row carries, so it never keeps the row from being logged."""
    results = value.get("reviewer_results") or []
    errors = [{key: result["error"].get(key) for key in SLOT_ERROR_KEYS}
              for result in results
              if isinstance(result, dict) and result.get("success") is False and isinstance(result.get("error"), dict)]
    return [error for error in errors if valid_slot_error(error)]


def jev_receipts(value: dict[str, object]) -> list[object]:
    """The receipt (`data.jev`) of every sanity-jev success reply, or each part's receipt of a split reply, copied
    verbatim; sanity-llm rows carry none."""
    if value.get("reviewer") != "sanity-jev":
        return []
    found = [result["data"].get("jev") for result in value.get("reviewer_results") or []
             if isinstance(result, dict) and result.get("success") is True and isinstance(result.get("data"), dict)]
    return [receipt for jev in found for receipt in (
        [part.get("receipt") if isinstance(part, dict) else part for part in jev] if isinstance(jev, list) else [jev])]


def phase_from_sprint(sprint: str) -> str:
    match = re.fullmatch(r"(?:phase-)?([a-z]+)(?:[-_]?\d+)?", sprint.lower())
    if not match:
        fail(f"cannot derive a phase from sprint {sprint!r}")
    return match.group(1)


def phase_from_bead(bead: str, sprint: str) -> str:
    try:
        result = subprocess.run(
            ["bd", "show", bead, "--json"], check=True, capture_output=True, text=True
        )
        issues = json.loads(result.stdout)
        metadata = issues[0].get("metadata") if isinstance(issues, list) and issues else None
        value = metadata.get("phase") if isinstance(metadata, dict) else None
        match = re.fullmatch(r"(?:phase-)?([a-z][a-z0-9_-]*)", value.lower()) if isinstance(value, str) else None
        if match:
            return match.group(1)
    except (OSError, subprocess.CalledProcessError, json.JSONDecodeError):
        pass
    return phase_from_sprint(sprint)


def primary_root() -> Path:
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
            check=True, capture_output=True, text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        fail(f"cannot locate repository common directory: {exc}")
    return Path(result.stdout.strip()).parent


def load_log(path: Path) -> list[dict[str, object]]:
    if not path.exists():
        return []
    records: list[dict[str, object]] = []
    try:
        lines = path.read_text().splitlines()
        for number, line in enumerate(lines, start=1):
            if not line.strip():
                continue
            try:
                record = json.loads(line)
            except json.JSONDecodeError as exc:
                # Never append behind malformed/truncated bytes or rewrite history.
                raise
            if not isinstance(record, dict):
                fail(f"log {path}:{number} is not a JSON object")
            records.append(record)
    except (OSError, json.JSONDecodeError) as exc:
        fail(f"cannot read log {path}: {exc}")
    return records


def utc_timestamp(value):
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
        if parsed.utcoffset() != timezone.utc.utcoffset(parsed):
            raise ValueError("timestamp must be UTC")
        return parsed
    except (ValueError, AttributeError) as exc:
        fail(f"invalid UTC timestamp {value!r}: {exc}")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--vars", required=True, type=Path)
    parser.add_argument("--task", required=True)
    parser.add_argument("--bead", required=True)
    parser.add_argument("--pr-number", required=True, type=int)
    parser.add_argument("--iteration", required=True, type=int)
    parser.add_argument("--final-verdict", required=True, choices=("PASS", "FAIL", "CANNOT_RUN"))
    args = parser.parse_args()
    if args.pr_number < 1 or args.iteration < 1:
        fail("--pr-number and --iteration must be positive")

    vars = read_vars(args.vars)
    completed = utc_timestamp(vars["completed_at"])
    elapsed = max(0, round(completed.timestamp() - utc_timestamp(vars["started_at"]).timestamp()))
    phase = phase_from_bead(args.bead, vars["sprint"])
    record = {
        "run_id": vars["run_id"], "reviewer": vars["reviewer"], "commit": vars["commit"],
        "started_at": vars["started_at"], "duration_seconds": elapsed,
        "error": vars.get("error"),
        "errors": slot_errors(vars),
        "jev_receipts": jev_receipts(vars),
        "task": args.task, "sprint": vars["sprint"], "phase": phase,
        "pr_number": args.pr_number, "findings": vars["findings_count"],
        "verdict": vars["verdict"], "final_verdict": args.final_verdict,
        "iteration": args.iteration,
        "completed_at": completed.isoformat().replace("+00:00", "Z"),
        "completed_local": completed.astimezone().strftime("%m-%d %H:%M"),
        "duration": f"{elapsed // 60}m{elapsed % 60:02d}s",
    }
    record = render_record(record)
    log_path = primary_root() / ".sc" / "sanity-log" / f"phase-{phase}.jsonl"
    append_record(log_path, record)
    print(log_path)


RECORD_TEMPLATE = Path(__file__).resolve().parent.parent / "templates/sanity-run-record.json.j2"
RECORD_TYPES = {
    "run_id": str, "reviewer": str, "commit": str, "task": str, "sprint": str,
    "phase": str, "started_at": str, "completed_at": str, "completed_local": str, "duration": str,
    "duration_seconds": int, "pr_number": int, "iteration": int, "verdict": str,
    "final_verdict": str,
}


def validate_record(record):
    if not isinstance(record, dict) or set(record) != set(RECORD_TYPES) | {"findings", "error", "errors", "jev_receipts"}:
        fail("rendered record has incorrect fields")
    for field, expected in RECORD_TYPES.items():
        exact_types = expected if isinstance(expected, tuple) else (expected,)
        if type(record[field]) not in exact_types or (expected is str and not record[field]):
            fail(f"rendered record.{field} must be " + " or ".join(item.__name__ for item in exact_types))
    if record["reviewer"] not in {"sanity-llm", "sanity-jev"}:
        fail("rendered record has invalid reviewer")
    if not isinstance(record["errors"], list) or not all(valid_slot_error(error) for error in record["errors"]):
        fail("rendered record.errors must be a list of {code, message, recoverable, deliverable}")
    receipts = record["jev_receipts"]
    if not isinstance(receipts, list) or not all(isinstance(receipt, dict) for receipt in receipts):
        fail("rendered record.jev_receipts must be a list of receipt objects")
    if record["reviewer"] == "sanity-llm" and receipts:
        fail("a sanity-llm row carries no Jev receipts")
    if record["reviewer"] == "sanity-jev" and record["verdict"] in {"PASS", "FAIL"} and not receipts:
        fail("a sanity-jev PASS/FAIL row requires the Jev receipt of every deliverable")
    if record["final_verdict"] not in {"PASS", "FAIL", "CANNOT_RUN"}:
        fail("rendered record has invalid final_verdict")
    if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", record["commit"]):
        fail("rendered record.commit must be a full commit SHA")
    if record["pr_number"] < 1 or record["iteration"] < 1 or record["duration_seconds"] < 0:
        fail("rendered record has invalid numeric range")
    started, completed = (utc_timestamp(record[key]) for key in ("started_at", "completed_at"))
    if completed < started:
        fail("completion precedes reviewer start")
    if record["verdict"] == "CANNOT_RUN":
        error = record["error"]
        if record["findings"] is not None or not isinstance(error, dict) or not all(
                isinstance(error.get(key), str) and error[key] for key in ("code", "message")):
            fail("CANNOT_RUN requires null findings and an error code/message")
    elif record["verdict"] in {"PASS", "FAIL"}:
        if type(record["findings"]) is not int or record["findings"] < 0 or record["error"] is not None:
            fail("completed verdict requires integer findings and null error")
    else:
        fail("rendered record has invalid verdict")
    return record


def render_record(variables):
    """Strict render, validate typed JSON, then hand to the locked append.

    No shell pipeline: failed render/validation cannot append any output.
    """
    try:
        record = json.loads(sc_render.render(RECORD_TEMPLATE, variables))
    except sc_render.RenderError as exc:
        fail(f"record render failed: {sc_render.error_text(exc)}")
    except json.JSONDecodeError as exc:
        fail(f"record render failed: {exc}")
    return validate_record(record)


def append_record(log_path, record):
    """Append one row; an identical retry of a logged run/reviewer is a no-op, a differing one fails.
    `sc-compose render --strict --append` renders the row again from the same values and writes it as one
    compact line under its lock, so concurrent writers never interleave."""
    validate_record(record)
    log_path.parent.mkdir(parents=True, exist_ok=True)
    records = load_log(log_path)
    same_run = [r for r in records if r.get("run_id") == record["run_id"]]
    if any(any(r.get(k) != record[k] for k in ("commit", "task", "sprint", "pr_number", "iteration", "final_verdict")) for r in same_run):
        fail("shared run identity disagrees with existing record")
    prior = [r for r in same_run if r.get("reviewer") == record["reviewer"]]
    if prior:
        # completed_local follows the process TZ; a retry under another TZ is the same row.
        # A row logged before a field existed (`errors`, `jev_receipts`) is compared without it.
        later = [key for key in LATER_FIELDS if key not in prior[-1]]
        if {**prior[-1], "completed_local": None} != {**{k: v for k, v in record.items() if k not in later}, "completed_local": None}:
            fail("run/reviewer already logged with different results")
        return
    with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as var_file:
        json.dump(record, var_file)
    try:
        proc = subprocess.run(["sc-compose", "render", "--strict", "--root", str(RECORD_TEMPLATE.parent),
                               "--file", str(RECORD_TEMPLATE), "--var-file", var_file.name, "--append", str(log_path)],
                              text=True, capture_output=True)
    finally:
        os.unlink(var_file.name)
    if proc.returncode:
        detail = (proc.stderr or proc.stdout).strip()
        if "ERR_RENDER_APPEND_NO_FINAL_NEWLINE" in detail:
            fail(f"{log_path} does not end with a newline; append the newline (printf '\\n' >> {log_path}), then rerun: {detail}")
        fail(f"append to {log_path} failed: {detail}")


if __name__ == "__main__":
    main()
