#!/usr/bin/env python3
"""Render a sprint report from the committed plan file and live beads/PRs."""

from __future__ import annotations

import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "atm-beads/scripts"))
from sprint_index_common import load_phase_plan, run_json, index_bead_pairs, phase_id, sprint_beads
from sprint_qa import metadata, choose_round, qa_verdict, qa_icon, findings_summary
from plan_contract import DIFFICULTY_MODELS, difficulty_tiers, model_matches
import repo_config
import sc_render


def edge_id(edge: dict[str, Any]) -> str:
    return str(edge.get("depends_on_id") or edge.get("id") or "")


def edge_type(edge: dict[str, Any]) -> str:
    return str(edge.get("type") or edge.get("dependency_type") or "")


def sanity_iterations(log_path: Path, phase: str | None = None) -> dict[str, int]:
    if not log_path.exists():
        return {}
    iterations: dict[str, int] = {}
    lines = log_path.read_text(encoding="utf-8").splitlines()
    for line_number, line in enumerate(lines, 1):
        if not line.strip():
            continue
        try:
            run = json.loads(line)
        except json.JSONDecodeError as exc:
            if line_number == len(lines):
                print(f"sprint-report: skipping truncated final line in {log_path}", file=sys.stderr)
                continue
            raise RuntimeError(f"invalid sanity history {log_path}:{line_number}: {exc}") from exc
        if not isinstance(run, dict):
            raise RuntimeError(f"invalid sanity history record {log_path}:{line_number}")
        if phase is not None:
            record_phase = str(run.get("phase") or "").removeprefix("phase-")
            if not record_phase:
                match = re.fullmatch(r"(?:phase-)?([a-z]+)(?:[-_]?\d+)?", str(run.get("sprint", "")).lower())
                record_phase = match.group(1) if match else ""
            if record_phase != phase.removeprefix("phase-"):
                continue
        if run.get("verdict") not in {"PASS", "FAIL"} or not run.get("completed_at"):
            continue
        task, iteration = run.get("task"), run.get("iteration")
        if not isinstance(task, str) or type(iteration) is not int or iteration < 1:
            raise RuntimeError(f"invalid sanity iteration {log_path}:{line_number}")
        iterations[task] = max(iterations.get(task, 0), iteration)
    return iterations


def phase_sanity_iterations(log_dir: Path, phase: str) -> dict[str, int]:
    """Merge the renamed LLM history and current phase log without counting rows.

    The historical filename carries no phase, so filter its records explicitly.
    Both reviewer rows share an iteration; the maximum counts that run once.
    """
    phase = phase.removeprefix("phase-")
    iterations = sanity_iterations(log_dir / "sanity-llm.jsonl", phase)
    for task, iteration in sanity_iterations(log_dir / f"phase-{phase}.jsonl").items():
        iterations[task] = max(iterations.get(task, 0), iteration)
    return iterations


def completed_sanity_runs(events: dict[str, Any]) -> int:
    return sum(event.get("event") == "completed" for event in events["events"])


def historical_sanity_iterations(repo: Path, task: str) -> tuple[str, int | None]:
    try:
        events = run_json(repo, "atm", "task", "events", task, "--all", "--json")
        return task, completed_sanity_runs(events)
    except (OSError, RuntimeError, subprocess.CalledProcessError, KeyError, TypeError) as exc:
        print(f"sprint-report: sanity history unavailable for {task}: {exc}", file=sys.stderr)
        return task, None


def related_bead(bead: dict[str, Any], target: str, relation: str) -> bool:
    return any(edge_id(edge) == target and edge_type(edge) == relation
               for edge in bead.get("dependencies") or [])


def is_qa_of(bead: dict[str, Any], dev_id: str) -> bool:
    """A QA round of dev_id: a stage:qa child of it (phase contract: QA lives under the bead it checks, and bd
    keeps one edge type per pair) or, for rounds created before the contract, a bead that `validates` it."""
    if related_bead(bead, dev_id, "validates"):
        return True
    if "stage:qa" not in (bead.get("labels") or []):
        return False
    metadata = bead.get("metadata") if isinstance(bead.get("metadata"), dict) else {}
    return related_bead(bead, dev_id, "parent-child") or bead.get("parent") == dev_id \
        or metadata.get("checked_bead") == dev_id


def status_icon(dev: dict[str, Any], sanity: dict[str, Any] | None,
                findings: list[dict[str, Any]] = (), blocked: bool = False) -> str:
    if blocked or dev.get("status") == "blocked":
        return "🚧"
    if sanity is None:
        return "🚩"
    open_findings = [finding for finding in findings if finding.get("status") != "closed"]
    if open_findings:
        if dev.get("status") == "in_progress" or any(
            finding.get("status") == "in_progress" for finding in open_findings
        ):
            return "🔨"
        return "🚩"
    statuses = {str(dev.get("status", "")), str(sanity.get("status", ""))}
    if "blocked" in statuses:
        return "🚧"
    if "in_progress" in statuses:
        return "🌀"
    if statuses == {"closed"}:
        verdict = str(metadata(sanity).get("verdict") or "").upper()
        if not verdict:
            match = re.match(r"^(PASS|FAIL)\b", str(sanity.get("close_reason") or ""), re.IGNORECASE)
            verdict = match.group(1).upper() if match else ""
        return "✅" if verdict == "PASS" else "🚩"
    return "📥"


def qa_summary(qa: dict[str, Any] | None, findings: list[dict[str, Any]]) -> str:
    if not qa:
        return ""
    round_number = metadata(qa).get("round") or "?"
    verdict = qa_verdict(qa)
    open_findings = sum(1 for finding in findings if finding.get("status") != "closed")
    return f"R{round_number} {verdict} ({open_findings} open)"


def review_ready(dev_cell: str, qa: dict[str, Any] | None,
                 findings: list[dict[str, Any]]) -> bool:
    return (dev_cell == "✅" and qa is not None and qa.get("status") == "closed"
            and qa_verdict(qa) == "PASS"
            and all(finding.get("status") == "closed" for finding in findings))


def check_icon(pr: dict[str, Any] | None, merge_ready: bool = False) -> str:
    if not pr:
        return "—"
    if pr.get("mergedAt") or str(pr.get("state", "")).upper() == "MERGED":
        return "🏁"
    checks = pr.get("statusCheckRollup") or []
    conclusions = {
        str(check.get("conclusion") or check.get("state") or check.get("status") or "PENDING").upper()
        for check in checks
    }
    if conclusions & {"FAILURE", "CANCELLED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"}:
        return "❌"
    if pr.get("mergeable") == "CONFLICTING" or pr.get("mergeStateStatus") in {"BLOCKED", "DIRTY"}:
        return "🚧"
    if not checks or conclusions & {
        "EXPECTED", "PENDING", "QUEUED", "IN_PROGRESS", "REQUESTED", "WAITING"
    }:
        return "🌀"
    if (merge_ready and pr.get("state") == "OPEN" and not pr.get("isDraft")
            and pr.get("mergeStateStatus") == "CLEAN" and pr.get("mergeable") == "MERGEABLE"):
        return "🚀"
    return "✅"


def select_pr(prs: list[dict[str, Any]], head: str | None) -> dict[str, Any] | None:
    if not head:
        return None
    matches = [pr for pr in prs if pr.get("headRefName") == head]
    return max(
        matches,
        key=lambda pr: (
            str(pr.get("state", "")).upper() == "OPEN",
            int(pr.get("number", 0)),
        ),
    ) if matches else None


def integration_detail(branch: str | None, pr: dict[str, Any] | None) -> str:
    """The detailed report's integration block; the target is the integration PR's own base, never assumed."""
    target = f" → {pr['baseRefName']}" if pr and pr.get("baseRefName") else ""
    cell = f"#{pr['number']}" if pr else "—"
    return f"Integration: {branch or 'integration'}{target}\nCI: {check_icon(pr)}\nPR: {cell}"


def index_path(repo: Path, root: str) -> Path:
    helper = repo / ".claude/skills/atm-beads/scripts/phase-index-path"
    return Path(subprocess.check_output([str(helper), "--root", root], text=True).strip())


def load_index(repo: Path, explicit: Path | None, root: str | None) -> tuple[Path, dict[str, Any]]:
    if explicit:
        path = explicit if explicit.is_absolute() else repo / explicit
    elif root:
        path = index_path(repo, root)
    else:
        candidates = sorted((repo / repo_config.load(repo)["plans_dir"]).glob("phase-*.jsonl"))
        if len(candidates) != 1:
            raise RuntimeError("pass --root or --index; no unique canonical phase plan found")
        path = candidates[0]
    if not path.exists():
        raise RuntimeError(f"canonical phase plan not found: {path}")
    try:
        return path, load_phase_plan(path, root)
    except (OSError, RuntimeError) as exc:
        raise RuntimeError(f"invalid canonical phase plan {path}: {exc}") from exc


def live_sprint_rows(index: dict[str, Any], beads: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    """The plan supplies membership; labels and ordering come from live beads (the sprint container when
    it is live, else the dev bead)."""
    rows = []
    for dev_id, container_id in sprint_beads(index).items():
        if dev_id not in beads:
            raise RuntimeError(f"planned dev bead not found: {dev_id}")
        bead = beads.get(container_id) or beads[dev_id]
        meta = {**metadata(beads[dev_id]), **metadata(bead)}
        rows.append({"id": dev_id, "sprint": meta.get("sprint") or dev_id,
                     "title": bead.get("title", ""), "layer": meta.get("layer")})
    def order(row):
        try:
            layer = int(row["layer"])
        except (TypeError, ValueError):
            layer = 2**31 - 1
        number = re.search(r"(\d+)$", row["id"])
        return layer, int(number.group(1)) if number else 2**31 - 1, row["id"]
    return sorted(rows, key=order)


def member_name(member: dict[str, Any]) -> str:
    return str(member.get("name") or member.get("identity") or member.get("id") or "")


def dev_members(members: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Roster members of type `dev`: the agents a dispatch row recommends."""
    return [m for m in members if m.get("agent_type") == "dev"]


def dispatch_rows(ready: list[dict[str, Any]], members: list[dict[str, Any]], active: set[str]) -> list[dict[str, str]]:
    """Read-only dispatch rows recommending idle `dev` members for each difficulty, then one tier up; the lead decides."""
    rows = []
    for bead in ready:
        meta = metadata(bead)
        labels = set(bead.get("labels") or [])
        stage = next((label.removeprefix("stage:") for label in labels if label.startswith("stage:")), "unknown")
        difficulty = meta.get("difficulty")
        if difficulty not in DIFFICULTY_MODELS:
            fitting = "UNCLASSIFIED"
        else:
            idle = [m for m in dev_members(members) if member_name(m) not in active]
            names = next((found for tier in difficulty_tiers(difficulty)
                          if (found := [member_name(m) for m in idle if model_matches(str(m.get("model") or ""), tier)])), [])
            fitting = ",".join(filter(None, names)) or "WAIT"
        try:
            layer = int(meta.get("layer", 2**31 - 1))
        except (TypeError, ValueError):
            layer = 2**31 - 1
        rows.append({"id": str(bead.get("id", "")), "stage": stage,
                     "severity": str(meta.get("severity", "-")),
                     "sprint": str(meta.get("sprint", bead.get("id", ""))),
                     "difficulty": str(difficulty or "UNCLASSIFIED"), "agents": fitting,
                     "priority": str(bead.get("priority", 99)), "layer": str(layer)})
    return sorted(rows, key=lambda row: (int(row["priority"]), int(row["layer"]), row["id"]))


def render_dispatch(rows: list[dict[str, str]], members: list[dict[str, Any]] = ()) -> str:
    devs = dev_members(list(members))
    unknown = sorted(member_name(m) for m in devs if str(m.get("model") or "").lower() in ("", "unknown", "none"))
    head = ["prerequisite: dev agents have roster type `dev` and `model` set"
            + (f"; unknown: {', '.join(unknown)}" if unknown else "") + ("" if devs else "; no roster member has type dev")]
    return "\n".join(head + ["{priority} {layer} {id} {stage} {severity} {sprint} {difficulty} {agents}".format(**row)
                             for row in rows])


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--table", action="store_true", help="render the compact table (default)")
    mode.add_argument("--detailed", action="store_true", help="render detailed sprint blocks")
    mode.add_argument("--dag", action="store_true", help="render the plan dependency DAG as SVG and PNG")
    mode.add_argument("--view", action="store_true", help="render the SVG DAG in an HTML artifact and open it in Wyvern")
    mode.add_argument("--dispatch", action="store_true", help="print phase-scoped ready work and recommended idle dev agents")
    parser.add_argument("--output", type=Path, help="local DAG export prefix; skips writing the HTML next to the plan")
    parser.add_argument("--open", action="store_true", help="open DAG PNG in Preview on macOS, default viewer elsewhere")
    parser.add_argument("--root", help="phase root bead id")
    parser.add_argument("--index", type=Path, help="plan file path (<plans_dir>/phase-<p>.jsonl)")
    args = parser.parse_args()
    if args.output and not (args.dag or args.view):
        parser.error("--output requires --dag or --view")
    if args.open and not args.dag:
        parser.error("--open requires --dag; use --view to open HTML in Wyvern")

    try:
        repo = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip())
        _, index = load_index(repo, args.index, args.root)
        roots = run_json(repo, "bd", "show", index["root_bead_id"], "--json")
        if not roots:
            raise RuntimeError(f"phase root bead not found: {index['root_bead_id']}")
        root_bead = roots[0]
        phase = phase_id(index["root_bead_id"], root_bead)
        common_dir = Path(subprocess.check_output(
            ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
            cwd=repo, text=True,
        ).strip())
        sanity_runs = phase_sanity_iterations(common_dir.parent / ".sc" / "sanity-log", phase)
        rows = index.get("sprints")
        if not isinstance(rows, list) or not rows:
            raise RuntimeError("sprint index contains no sprints")
        if args.dag or args.view:
            from sprint_dag import generate
            plan_html = None if args.output else (
                repo / repo_config.load(repo)["plans_dir"] / f"phase-{phase}" / f"phase-{phase}-dag.html")
            return generate(repo, index, sanity_runs, phase, args.output, args.open, args.view,
                            plan_html=plan_html)
        row_ids = list(index_bead_pairs(index))
        all_beads = run_json(repo, "bd", "list", "--all", "-n", "0", "--json")
        if args.dispatch:
            ready = run_json(repo, "bd", "ready", "-n", "0", "--json")
            members = run_json(repo, "atm", "members", "--json")
            if not isinstance(members, list):
                members = members.get("members", [])
            tasks = run_json(repo, "atm", "task", "list", "--all", "--json")
            if not isinstance(tasks, list):
                tasks = tasks.get("tasks", [])
            active = {str(task.get("assignee") or task.get("owner") or "") for task in tasks
                      if str(task.get("state") or task.get("status") or "") not in {"completed", "cancelled", "refused", "closed", "failed"}}
            descendants = {index["root_bead_id"]}
            changed = True
            while changed:
                changed = False
                for bead in all_beads:
                    if any(edge_type(edge) == "parent-child" and edge_id(edge) in descendants
                           for edge in bead.get("dependencies") or []) and bead.get("id") not in descendants:
                        descendants.add(str(bead["id"])); changed = True
            print(render_dispatch(dispatch_rows([bead for bead in ready if str(bead.get("id")) in descendants], members, active), members))
            return 0
        blocked_ids = {bead["id"] for bead in run_json(repo, "bd", "blocked", "--json")}
        dev_ids = set(row_ids)
        sanity_by_dev: dict[str, dict[str, Any]] = {}
        qa_by_dev: dict[str, list[dict[str, Any]]] = {}
        for bead in all_beads:
            for dev_id in dev_ids:
                if "stage:dev-sanity" in (bead.get("labels") or []) and related_bead(bead, dev_id, "blocks"):
                    sanity_by_dev[dev_id] = bead
                if is_qa_of(bead, dev_id):
                    qa_by_dev.setdefault(dev_id, []).append(bead)
        selected_qa = {dev_id: choose_round(candidates) for dev_id, candidates in qa_by_dev.items()}
        for dev_id, gate_id in index_bead_pairs(index).items():
            live_gates = [bead["id"] for bead in all_beads
                          if "stage:dev-sanity" in (bead.get("labels") or [])
                          and related_bead(bead, dev_id, "blocks")]
            if live_gates != [gate_id]:
                raise RuntimeError(f"{dev_id}: indexed sanity bead {gate_id} does not match live gates {live_gates}")
        missing_history = {str(bead["id"]) for bead in sanity_by_dev.values()} - sanity_runs.keys()
        with ThreadPoolExecutor(max_workers=4) as pool:
            for task, count in pool.map(lambda task: historical_sanity_iterations(repo, task), missing_history):
                sanity_runs[task] = count
        containers = sprint_beads(index)
        related_ids = row_ids + list(containers.values()) + [str(bead["id"]) for bead in sanity_by_dev.values()] + [
            str(qa["id"]) for qa in selected_qa.values() if qa
        ]
        live = run_json(repo, "bd", "show", *dict.fromkeys(related_ids), "--json")
        live_by_id = {str(bead.get("id")): bead for bead in live}
        rows = live_sprint_rows(index, live_by_id)
        integration_branch = metadata(root_bead).get("integration_branch")
        pages = run_json(
            repo, "gh", "api", "--paginate", "--slurp", "--method", "GET",
            "repos/{owner}/{repo}/pulls", "-f", "state=all", "-f", "per_page=100",
        )
        prs = [
            {"number": pr["number"], "state": "MERGED" if pr.get("merged_at") else pr["state"].upper(),
             "mergedAt": pr.get("merged_at"), "headRefName": pr["head"]["ref"],
             "baseRefName": pr["base"]["ref"]}
            for page in pages for pr in page
        ]
        def sprint_meta(dev_id):
            return {**metadata(live_by_id.get(dev_id, {})), **metadata(live_by_id.get(containers[dev_id], {}))}
        heads = {sprint_meta(dev_id).get("branch") for dev_id in dev_ids}
        heads.add(integration_branch)
        selected = [pr for head in heads if (pr := select_pr(prs, head))]
        def refresh_pr(pr):
            return run_json(repo, "gh", "pr", "view", str(pr["number"]), "--json",
                            "number,state,isDraft,mergedAt,statusCheckRollup,headRefName,baseRefName,title,mergeStateStatus,mergeable")
        with ThreadPoolExecutor(max_workers=4) as pool:
            prs = list(pool.map(refresh_pr, selected))
    except (OSError, RuntimeError, subprocess.CalledProcessError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
        print(f"sprint-report: {exc}", file=sys.stderr)
        return 2

    table_rows: list[str] = []
    detailed_rows: list[str] = []
    for row in rows:
        dev_id = str(row["id"])
        dev = live_by_id.get(dev_id, {})
        sanity_id = sanity_by_dev.get(dev_id, {}).get("id")
        sanity = live_by_id.get(str(sanity_id)) if sanity_id else None
        sanity_count = sanity_runs.get(str(sanity_id))
        sanity_cell = "?" if sanity_count is None else str(sanity_count) if sanity_count else ""
        qa = live_by_id.get(str(selected_qa.get(dev_id, {}).get("id"))) if selected_qa.get(dev_id) else None
        qa_ids = {str(candidate["id"]) for candidate in qa_by_dev.get(dev_id, [])}
        findings = [
            bead for bead in all_beads
            if any(
                related_bead(bead, qa_id, "discovered-from")
                or related_bead(bead, qa_id, "parent-child")
                for qa_id in qa_ids
            )
        ]
        sanity_findings = [
            bead for bead in all_beads
            if sanity and (
                related_bead(bead, str(sanity["id"]), "discovered-from")
                or related_bead(bead, str(sanity["id"]), "parent-child")
            )
        ]
        dev_cell = status_icon(
            dev, sanity, sanity_findings, blocked=dev_id in blocked_ids
        )
        qa_cell = qa_icon(qa, findings)
        find_cell = "" if not qa else findings_summary(findings)
        pr = select_pr(prs, sprint_meta(dev_id).get("branch"))
        pr_cell = f"#{pr['number']}" if pr else "—"
        ci_cell = check_icon(
            pr, merge_ready=review_ready(dev_cell, qa, findings)
        )
        table_rows.append(f"| {row['sprint']} | {dev_cell} | {sanity_cell} | {qa_cell} | {find_cell} | {ci_cell} | {pr_cell} |")
        detailed_rows.append("\n".join([
            f"Sprint: {row['sprint']}  {row.get('title', '')}",
            f"DEV: {dev_cell} ({dev.get('status', 'unknown')})",
            f"S: {sanity_cell}",
            f"QA: {qa_summary(qa, findings)}",
            f"FIND: {find_cell}",
            f"CI: {ci_cell}",
            f"PR: {pr_cell}",
        ]))

    integration_pr = select_pr(prs, integration_branch)
    integration_pr_cell = f"#{integration_pr['number']}" if integration_pr else "—"
    integration_row = (
        f"| **{integration_branch or 'integration'}** | | | | | {check_icon(integration_pr)} | "
        f"{integration_pr_cell} |"
    )
    detailed_integration = integration_detail(integration_branch, integration_pr)
    variables = {
        "mode": "detailed" if args.detailed else "table",
        "sprint_rows": "\n\n".join(detailed_rows) if args.detailed else "\n".join(table_rows),
        "integration_row": detailed_integration if args.detailed else integration_row,
    }
    try:
        rendered = sc_render.render(repo / ".claude/skills/sprint-report/report.md.j2", variables)
    except sc_render.RenderError as exc:
        print(sc_render.error_text(exc), file=sys.stderr)
        return 1
    print(rendered, end="")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
