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

from __future__ import annotations

import argparse
from collections.abc import Sequence
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
    bead_metadata = bead.get("metadata")
    metadata = bead_metadata if isinstance(bead_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: Sequence[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: Sequence[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: dict[str, int | None] = dict(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 ""
        selected = selected_qa.get(dev_id)
        qa = live_by_id.get(str(selected.get("id"))) if isinstance(selected, dict) 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())
