#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

SOURCE_MARKER = "plan-tracking-issue:snapshot:v1 kind=source"
PLAN_MARKER = "plan-tracking-issue:snapshot:v1 kind=plan"
STATE_MARKER = "execute-from-tracking-issue:state:v1"
SESSION_MARKER = "execute-from-tracking-issue:session:v1"
VALIDATION_MARKER = "execute-from-tracking-issue:validation:v1"
CLOSEOUT_MARKER = "tracking-issue-closeout:v1"
EXPECTED_STATE_COLUMNS = ["ID", "Status", "Task", "Evidence", "Notes"]

COMPLETION_RE = re.compile(r"\b(complete|completed|all\s+\d+\s+sprints\s+complete|handoff|merged)\b", re.IGNORECASE)
PR_RE = re.compile(
    r"(?:\b(?:PR|pull\s+request)\s*:?\s*#(?P<text>\d+)\b|(?:https?://[^\s)]+)?/pull/(?P<link>\d+)\b)",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class IssueComment:
    body: str
    url: str = ""
    created_at: str = ""


@dataclass(frozen=True)
class IssueData:
    number: int | None
    title: str
    state: str
    body: str
    comments: list[IssueComment]
    url: str


@dataclass(frozen=True)
class Check:
    name: str
    status: str
    detail: str


@dataclass(frozen=True)
class CompletionGate:
    ready: bool
    blockers: list[str]
    incomplete_rows: list[dict[str, str]]


@dataclass(frozen=True)
class CloseoutModel:
    issue: IssueData
    checks: list[Check]
    source_comment: IssueComment | None
    plan_comment: IssueComment | None
    state_comment: IssueComment | None
    latest_session: IssueComment | None
    latest_validation: IssueComment | None
    latest_closeout: IssueComment | None
    current_state: dict[str, str]
    state_columns: list[str]
    state_rows: list[dict[str, str]]
    completion: CompletionGate
    pr_numbers: list[int]
    validation_summary: str
    approval_summary: str
    ready: bool
    repair_only: bool


def _comment_from_raw(raw: dict[str, Any]) -> IssueComment:
    return IssueComment(
        body=str(raw.get("body") or ""),
        url=str(raw.get("url") or raw.get("html_url") or ""),
        created_at=str(raw.get("createdAt") or raw.get("created_at") or ""),
    )


def issue_from_payload(payload: dict[str, Any]) -> IssueData:
    raw_comments = payload.get("comments") or []
    if not isinstance(raw_comments, list):
        raise ValueError("issue JSON comments must be a list")

    number: int | None
    try:
        number = int(payload["number"]) if payload.get("number") is not None else None
    except (TypeError, ValueError) as exc:
        raise ValueError("issue JSON number must be an integer when present") from exc

    return IssueData(
        number=number,
        title=str(payload.get("title") or ""),
        state=str(payload.get("state") or ""),
        body=str(payload.get("body") or ""),
        comments=[_comment_from_raw(c) for c in raw_comments if isinstance(c, dict)],
        url=str(payload.get("url") or ""),
    )


def load_issue_json(path: Path) -> IssueData:
    return issue_from_payload(json.loads(path.read_text("utf-8")))


def run_gh(args: list[str]) -> str:
    proc = subprocess.run(["gh", *args], text=True, capture_output=True)
    if proc.returncode != 0:
        command = " ".join(["gh", *args])
        detail = proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}"
        raise RuntimeError(f"{command} failed: {detail}")
    return proc.stdout.strip()


def gh_repo_args(repo: str | None) -> list[str]:
    return ["--repo", repo] if repo else []


def fetch_issue(issue: str, repo: str | None) -> IssueData:
    fields = "number,title,state,body,comments,labels,url"
    raw = run_gh(["issue", "view", issue, *gh_repo_args(repo), "--json", fields])
    return issue_from_payload(json.loads(raw))


def _has_marker_line(body: str, marker: str) -> bool:
    target = f"<!-- {marker} -->"
    return any(line.strip() == target for line in body.splitlines())


def _latest_with_marker(issue: IssueData, marker: str) -> IssueComment | None:
    matches = [comment for comment in issue.comments if _has_marker_line(comment.body, marker)]
    return matches[-1] if matches else None


def _first_heading(body: str) -> str:
    for line in body.splitlines():
        line = line.strip()
        if line.startswith("## "):
            return line.removeprefix("## ").strip()
    return "No heading"


def _extract_bullet_fields(body: str) -> dict[str, str]:
    fields: dict[str, str] = {}
    for line in body.splitlines():
        match = re.match(r"^-\s*([^:]+):\s*(.*)$", line.strip())
        if match:
            fields[match.group(1).strip()] = match.group(2).strip()
    return fields


def _table_cells(line: str) -> list[str]:
    stripped = line.strip()
    if not stripped.startswith("|") or not stripped.endswith("|"):
        return []
    return [cell.strip().strip("`") for cell in stripped.strip("|").split("|")]


def _looks_like_state_header(cells: list[str]) -> bool:
    required = {"ID", "Status", "Task"}
    return required.issubset(set(cells))


def _parse_state_table(body: str) -> tuple[list[str], list[dict[str, str]]]:
    lines = body.splitlines()
    for index, line in enumerate(lines):
        columns = _table_cells(line)
        if not _looks_like_state_header(columns):
            continue

        rows: list[dict[str, str]] = []
        for row_line in lines[index + 2 :]:
            cells = _table_cells(row_line)
            if not cells:
                break
            if len(cells) != len(columns):
                continue
            rows.append(dict(zip(columns, cells, strict=True)))
        return columns, rows
    return [], []


def _normalize_status(value: str) -> str:
    return value.strip().strip("`").lower()


def _completion_gate(
    *,
    state: IssueComment | None,
    current_state: dict[str, str],
    state_columns: list[str],
    state_rows: list[dict[str, str]],
) -> CompletionGate:
    blockers: list[str] = []
    incomplete_rows: list[dict[str, str]] = []

    if not state:
        blockers.append("missing execution-state marker")
        return CompletionGate(ready=False, blockers=blockers, incomplete_rows=incomplete_rows)

    if state_columns != EXPECTED_STATE_COLUMNS:
        blockers.append(
            "state table columns drift: " + (", ".join(state_columns) if state_columns else "missing state task ledger table")
        )

    status = _normalize_status(current_state.get("Status", ""))
    if status != "complete":
        blockers.append(f"state status is {current_state.get('Status', 'missing')}; expected complete")

    allowed_statuses = {"done", "deferred"}
    for row in state_rows:
        row_status = _normalize_status(row.get("Status", ""))
        if row_status not in allowed_statuses:
            incomplete_rows.append(row)
    if incomplete_rows:
        row_details = ", ".join(f"{row.get('ID', 'unknown')}={row.get('Status', 'missing')}" for row in incomplete_rows)
        blockers.append("incomplete task rows: " + row_details)

    return CompletionGate(ready=not blockers, blockers=blockers, incomplete_rows=incomplete_rows)


def _extract_pr_numbers(*texts: str) -> list[int]:
    seen: set[int] = set()
    out: list[int] = []
    for text in texts:
        for match in PR_RE.finditer(text):
            number = int(match.group("text") or match.group("link"))
            if number not in seen:
                seen.add(number)
                out.append(number)
    return out


def _execution_pr_numbers(issue: IssueData) -> list[int]:
    texts: list[str] = []
    for comment in issue.comments:
        if any(_has_marker_line(comment.body, marker) for marker in (STATE_MARKER, SESSION_MARKER, VALIDATION_MARKER, CLOSEOUT_MARKER)):
            texts.append(comment.body)
    return _extract_pr_numbers(*texts)


def _validation_summary(issue: IssueData, latest_validation: IssueComment | None) -> str:
    if latest_validation:
        heading = _first_heading(latest_validation.body)
        return f"{heading} ({latest_validation.url})" if latest_validation.url else heading

    validation_lines: list[str] = []
    for comment in issue.comments:
        if not _has_marker_line(comment.body, SESSION_MARKER):
            continue
        for line in comment.body.splitlines():
            stripped = line.strip()
            if "Validation:" in stripped or "validation:" in stripped:
                validation_lines.append(stripped.lstrip("- ").strip())

    if validation_lines:
        return validation_lines[-1]
    return "No validation evidence found"


def _check_prs_merged(pr_numbers: list[int], repo: str | None, *, skip: bool, dry_run: bool) -> Check:
    if not pr_numbers:
        return Check("linked PRs", "warn", "no linked PR numbers detected")
    if skip:
        joined = ", ".join(f"#{n}" for n in pr_numbers)
        return Check("linked PRs", "warn", f"skipped live PR merge checks for {joined}")
    if dry_run:
        joined = ", ".join(f"#{n}" for n in pr_numbers)
        return Check("linked PRs", "warn", f"dry-run did not query live PR merge state for {joined}")

    unmerged: list[str] = []
    for number in pr_numbers:
        raw = run_gh(["pr", "view", str(number), *gh_repo_args(repo), "--json", "number,state,mergedAt,url"])
        payload = json.loads(raw)
        merged_at = str(payload.get("mergedAt") or "")
        state = str(payload.get("state") or "")
        if not merged_at and state.upper() != "MERGED":
            unmerged.append(f"#{number} state={state or 'unknown'}")

    if unmerged:
        return Check("linked PRs", "fail", "unmerged PRs: " + ", ".join(unmerged))
    joined = ", ".join(f"#{n}" for n in pr_numbers)
    return Check("linked PRs", "pass", f"all linked PRs are merged: {joined}")


def analyze_issue(
    issue: IssueData,
    *,
    review_approved: bool,
    review_comment_url: str | None,
    repo: str | None,
    skip_pr_checks: bool,
    dry_run: bool,
    repair_closed: bool = False,
) -> CloseoutModel:
    source = _latest_with_marker(issue, SOURCE_MARKER)
    plan = _latest_with_marker(issue, PLAN_MARKER)
    state = _latest_with_marker(issue, STATE_MARKER)
    latest_session = _latest_with_marker(issue, SESSION_MARKER)
    latest_validation = _latest_with_marker(issue, VALIDATION_MARKER)
    latest_closeout = _latest_with_marker(issue, CLOSEOUT_MARKER)
    current_state = _extract_bullet_fields(state.body) if state else {}
    state_columns, state_rows = _parse_state_table(state.body) if state else ([], [])
    completion = _completion_gate(
        state=state,
        current_state=current_state,
        state_columns=state_columns,
        state_rows=state_rows,
    )

    checks: list[Check] = []
    state_upper = issue.state.upper()
    if repair_closed:
        if state_upper == "CLOSED":
            checks.append(Check("issue state", "pass", "state=CLOSED; repair mode selected"))
        else:
            checks.append(Check("issue state", "fail", f"repair mode requires state=CLOSED; state={issue.state or 'unknown'}"))
    else:
        checks.append(Check("issue state", "pass" if state_upper != "CLOSED" else "fail", f"state={issue.state or 'unknown'}"))
    checks.append(Check("source snapshot", "pass" if source else "fail", source.url if source else "missing source snapshot marker"))
    checks.append(Check("plan snapshot", "pass" if plan else "fail", plan.url if plan else "missing plan snapshot marker"))
    checks.append(Check("execution state", "pass" if state else "fail", state.url if state else "missing execution-state marker"))
    checks.append(
        Check(
            "execution completion",
            "pass" if completion.ready else "fail",
            "complete"
            if completion.ready
            else "; ".join(completion.blockers)
            + "; next action: post a complete execute-from-tracking-issue:state:v1 comment first",
        )
    )

    if latest_session and COMPLETION_RE.search(latest_session.body):
        session_status = "pass"
        session_detail = _first_heading(latest_session.body)
    elif latest_session:
        session_status = "fail"
        session_detail = "latest session does not contain a completion signal"
    else:
        session_status = "fail"
        session_detail = "missing execution session marker"
    checks.append(Check("completed session", session_status, session_detail))

    validation_summary = _validation_summary(issue, latest_validation)
    checks.append(
        Check(
            "validation evidence",
            "pass" if validation_summary != "No validation evidence found" else "fail",
            validation_summary,
        )
    )

    approval_summary = review_comment_url or ("explicit close approval flag" if review_approved else "missing close approval")
    checks.append(Check("close approval", "pass" if (review_approved or review_comment_url) else "fail", approval_summary))

    pr_numbers = _execution_pr_numbers(issue)
    checks.append(_check_prs_merged(pr_numbers, repo, skip=skip_pr_checks, dry_run=dry_run))

    ready = all(check.status != "fail" for check in checks)
    return CloseoutModel(
        issue=issue,
        checks=checks,
        source_comment=source,
        plan_comment=plan,
        state_comment=state,
        latest_session=latest_session,
        latest_validation=latest_validation,
        latest_closeout=latest_closeout,
        current_state=current_state,
        state_columns=state_columns,
        state_rows=state_rows,
        completion=completion,
        pr_numbers=pr_numbers,
        validation_summary=validation_summary,
        approval_summary=approval_summary,
        ready=ready,
        repair_only=repair_closed,
    )


def _link_or_text(label: str, url: str | None) -> str:
    return f"[{label}]({url})" if url else label


def _status_line(model: CloseoutModel) -> str:
    failed = [check for check in model.checks if check.status == "fail"]
    if failed:
        return "blocked"
    warnings = [check for check in model.checks if check.status == "warn"]
    return "complete with warnings" if warnings else "complete"


def render_dashboard(
    model: CloseoutModel,
    *,
    closeout_comment_url: str | None = None,
    closed: bool = False,
    finalized_closed_issue: bool = False,
) -> str:
    issue = model.issue
    pr_text = ", ".join(f"#{n}" for n in model.pr_numbers) if model.pr_numbers else "none detected"
    if model.repair_only:
        next_action = "closed issue finalized; dashboard repaired" if finalized_closed_issue else "dashboard repaired; issue was already closed"
        if model.latest_closeout:
            closeout_link = _link_or_text("existing closeout comment", model.latest_closeout.url)
        elif closeout_comment_url:
            closeout_link = _link_or_text("closed-issue finalization comment", closeout_comment_url)
        elif finalized_closed_issue:
            closeout_link = "pending closed-issue finalization comment"
        else:
            closeout_link = "not posted in repair-only mode"
    else:
        next_action = "closed" if closed else "ready to close after live mutation"
        closeout_link = closeout_comment_url or "pending closeout comment"
    session_link = _link_or_text(_first_heading(model.latest_session.body), model.latest_session.url) if model.latest_session else "missing"

    lines = [
        "## Final Dashboard",
        "",
        "This issue is the durable tracking surface for an issue-backed plan execution. The full source, plan, and execution logs remain in",
        "append-only issue comments.",
        "",
        f"- Status: {_status_line(model)}",
        "- Target scope: issue-backed plan execution",
        "- Current task: final closeout",
        f"- Next action: {next_action}",
        f"- Validation: {model.validation_summary}",
        f"- Linked PRs: {pr_text}",
        "- Blockers: none recorded in the latest completed session",
        f"- Review approval: {model.approval_summary}",
        "",
        "## Durable Record",
        "",
        f"- Source snapshot: {_link_or_text('source snapshot', model.source_comment.url if model.source_comment else '')}",
        f"- Plan snapshot: {_link_or_text('plan snapshot', model.plan_comment.url if model.plan_comment else '')}",
        f"- Execution state: {_link_or_text('execution state', model.state_comment.url if model.state_comment else '')}",
        f"- Latest session: {session_link}",
        f"- Closeout comment: {closeout_link}",
        "",
        "## Closeout Checks",
        "",
        "| Check | Status | Detail |",
        "| --- | --- | --- |",
    ]
    for check in model.checks:
        detail = check.detail.replace("\n", " ").replace("|", r"\|")
        lines.append(f"| {check.name} | {check.status} | {detail} |")

    lines.extend(
        [
            "",
            "## Original Tracker",
            "",
            f"- Title: {issue.title or 'untitled'}",
            f"- Issue: {issue.url or issue.number or 'unknown'}",
        ]
    )
    return "\n".join(lines).rstrip() + "\n"


def render_closeout_comment(model: CloseoutModel, *, dry_run: bool, finalized_closed_issue: bool = False) -> str:
    issue = model.issue
    pr_text = ", ".join(f"#{n}" for n in model.pr_numbers) if model.pr_numbers else "none detected"
    if model.repair_only and finalized_closed_issue:
        action = "Closed-issue finalization; posted the missing closeout marker and repaired the dashboard without reopening or closing again."
    elif model.repair_only:
        action = "Repair-only dashboard update; no closeout comment or second close operation should be performed."
    else:
        action = "Dry-run only; no GitHub mutation performed." if dry_run else "Posted final dashboard and closed the issue as completed."
    lines = [
        f"<!-- {CLOSEOUT_MARKER} -->",
        "## Tracking Issue Closeout",
        "",
        "### Checked",
    ]
    for check in model.checks:
        lines.append(f"- {check.name}: {check.status} - {check.detail}")
    lines.extend(
        [
            "",
            "### Result",
            f"- Issue: {issue.url or issue.number or 'unknown'}",
            f"- Status: {_status_line(model)}",
            f"- Linked PRs: {pr_text}",
            f"- Validation: {model.validation_summary}",
            f"- Approval: {model.approval_summary}",
            f"- Action: {action}",
            "",
            "### Next",
            "- None; reopen or open a follow-up issue only if review finds a new gap.",
        ]
    )
    return "\n".join(lines).rstrip() + "\n"


def report_payload(
    model: CloseoutModel,
    *,
    dry_run: bool,
    closed: bool,
    closeout_comment_url: str | None,
    finalized_closed_issue: bool = False,
) -> dict[str, Any]:
    if finalized_closed_issue:
        mode = "repair-closed-finalize"
    else:
        mode = "repair-closed" if model.repair_only else "normal-closeout"
    return {
        "schema": "tracking-issue-closeout.report.v1",
        "mode": mode,
        "dry_run": dry_run,
        "ready": model.ready,
        "completion_ready": model.completion.ready,
        "completion_blockers": model.completion.blockers,
        "incomplete_state_rows": model.completion.incomplete_rows,
        "repair_only": model.repair_only and not finalized_closed_issue,
        "finalized_closed_issue": finalized_closed_issue,
        "closed": closed,
        "issue": {
            "number": model.issue.number,
            "title": model.issue.title,
            "state": model.issue.state,
            "url": model.issue.url,
        },
        "linked_prs": model.pr_numbers,
        "validation_summary": model.validation_summary,
        "approval": model.approval_summary,
        "closeout_comment_url": closeout_comment_url,
        "checks": [check.__dict__ for check in model.checks],
    }


def write_text(path: str | None, text: str) -> None:
    if not path:
        return
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(text, "utf-8")


def write_json(path: str | None, payload: dict[str, Any]) -> None:
    if not path:
        return
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", "utf-8")


def _issue_arg(issue: IssueData, fallback: str | None) -> str:
    if fallback:
        return fallback
    if issue.number is not None:
        return str(issue.number)
    if issue.url:
        return issue.url
    raise ValueError("cannot resolve issue argument")


def close_issue_live(
    model: CloseoutModel,
    *,
    issue_arg: str,
    repo: str | None,
    close_reason: str,
    dashboard_path: str | None,
    comment_path: str | None,
) -> tuple[bool, str | None, str, str]:
    closeout_comment = render_closeout_comment(model, dry_run=False)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as comment_file:
        comment_file.write(closeout_comment)
        temp_comment_path = comment_file.name

    try:
        comment_url = run_gh(["issue", "comment", issue_arg, *gh_repo_args(repo), "--body-file", temp_comment_path]) or None
    finally:
        Path(temp_comment_path).unlink(missing_ok=True)

    dashboard = render_dashboard(model, closeout_comment_url=comment_url, closed=True)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as body_file:
        body_file.write(dashboard)
        temp_body_path = body_file.name

    try:
        run_gh(["issue", "edit", issue_arg, *gh_repo_args(repo), "--body-file", temp_body_path])
        run_gh(["issue", "close", issue_arg, *gh_repo_args(repo), "--reason", close_reason])
    finally:
        Path(temp_body_path).unlink(missing_ok=True)

    write_text(dashboard_path, dashboard)
    write_text(comment_path, closeout_comment)
    return True, comment_url, dashboard, closeout_comment


def post_closeout_comment_live(
    model: CloseoutModel,
    *,
    issue_arg: str,
    repo: str | None,
    comment_path: str | None,
    finalized_closed_issue: bool,
) -> str | None:
    closeout_comment = render_closeout_comment(model, dry_run=False, finalized_closed_issue=finalized_closed_issue)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as comment_file:
        comment_file.write(closeout_comment)
        temp_comment_path = comment_file.name

    try:
        comment_url = run_gh(["issue", "comment", issue_arg, *gh_repo_args(repo), "--body-file", temp_comment_path]) or None
    finally:
        Path(temp_comment_path).unlink(missing_ok=True)

    write_text(comment_path, closeout_comment)
    return comment_url


def repair_closed_issue_body_live(
    model: CloseoutModel,
    *,
    issue_arg: str,
    repo: str | None,
    dashboard_path: str | None,
    closeout_comment_url: str | None = None,
    finalized_closed_issue: bool = False,
) -> str:
    dashboard = render_dashboard(
        model,
        closeout_comment_url=closeout_comment_url,
        closed=False,
        finalized_closed_issue=finalized_closed_issue,
    )
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as body_file:
        body_file.write(dashboard)
        temp_body_path = body_file.name

    try:
        run_gh(["issue", "edit", issue_arg, *gh_repo_args(repo), "--body-file", temp_body_path])
    finally:
        Path(temp_body_path).unlink(missing_ok=True)

    write_text(dashboard_path, dashboard)
    return dashboard


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="tracking-issue-closeout.sh",
        description="Finalize a plan-tracking GitHub issue after execute-from-tracking-issue completes.",
    )
    parser.add_argument("--issue", help="GitHub issue number or URL")
    parser.add_argument("--repo", help="GitHub repository as owner/repo")
    parser.add_argument("--issue-json", help="Load issue JSON from a fixture file; requires --dry-run")
    parser.add_argument("--review-approved", action="store_true", help="Assert the user approved closing this issue")
    parser.add_argument("--review-comment-url", help="URL for the user/project approval comment")
    parser.add_argument("--skip-pr-checks", action="store_true", help="Skip live linked PR merge checks")
    parser.add_argument("--dry-run", action="store_true", help="Render and validate without mutating GitHub")
    parser.add_argument(
        "--repair-closed",
        action="store_true",
        help="Repair the final dashboard for an already closed issue without posting a closeout comment or closing again",
    )
    parser.add_argument(
        "--finalize-closed",
        action="store_true",
        help="With --repair-closed, append one missing tracking-issue-closeout marker before repairing the dashboard",
    )
    parser.add_argument("--body-out", help="Write rendered final dashboard body to this path")
    parser.add_argument("--comment-out", help="Write rendered closeout comment to this path")
    parser.add_argument("--report-out", help="Write JSON closeout report to this path")
    parser.add_argument(
        "--close-reason",
        choices=["completed", "not planned"],
        default="completed",
        help="GitHub issue close reason for live mode",
    )
    args = parser.parse_args(argv)

    if not args.issue and not args.issue_json:
        parser.error("--issue or --issue-json is required")
    if args.issue_json and not args.dry_run:
        parser.error("--issue-json requires --dry-run")
    if not args.review_approved and not args.review_comment_url:
        parser.error("--review-approved or --review-comment-url is required")
    if args.finalize_closed and not args.repair_closed:
        parser.error("--finalize-closed requires --repair-closed")
    return args


def main(argv: list[str] | None = None) -> int:
    args = parse_args(list(argv or sys.argv[1:]))
    issue = load_issue_json(Path(args.issue_json)) if args.issue_json else fetch_issue(args.issue, args.repo)
    model = analyze_issue(
        issue,
        review_approved=bool(args.review_approved),
        review_comment_url=args.review_comment_url,
        repo=args.repo,
        skip_pr_checks=bool(args.skip_pr_checks),
        dry_run=bool(args.dry_run),
        repair_closed=bool(args.repair_closed),
    )

    should_finalize_closed = bool(args.finalize_closed and model.repair_only and not model.latest_closeout)
    dashboard = render_dashboard(
        model,
        closeout_comment_url=None,
        closed=False,
        finalized_closed_issue=should_finalize_closed,
    )
    closeout_comment = (
        render_closeout_comment(model, dry_run=bool(args.dry_run), finalized_closed_issue=should_finalize_closed)
        if (not model.repair_only or should_finalize_closed)
        else ""
    )
    write_text(args.body_out, dashboard)
    if closeout_comment:
        write_text(args.comment_out, closeout_comment)

    if not model.ready:
        payload = report_payload(
            model,
            dry_run=bool(args.dry_run),
            closed=False,
            closeout_comment_url=None,
            finalized_closed_issue=should_finalize_closed,
        )
        write_json(args.report_out, payload)
        failed = ", ".join(f"{c.name}: {c.detail}" for c in model.checks if c.status == "fail")
        print(f"status=blocked issue={issue.url or issue.number or 'unknown'}", file=sys.stderr)
        print(f"failed_checks={failed}", file=sys.stderr)
        return 1

    closed = False
    closeout_comment_url: str | None = None
    if model.repair_only and not args.dry_run:
        if should_finalize_closed:
            closeout_comment_url = post_closeout_comment_live(
                model,
                issue_arg=_issue_arg(issue, args.issue),
                repo=args.repo,
                comment_path=args.comment_out,
                finalized_closed_issue=True,
            )
        dashboard = repair_closed_issue_body_live(
            model,
            issue_arg=_issue_arg(issue, args.issue),
            repo=args.repo,
            dashboard_path=args.body_out,
            closeout_comment_url=closeout_comment_url,
            finalized_closed_issue=should_finalize_closed,
        )
    elif not args.dry_run:
        closed, closeout_comment_url, dashboard, closeout_comment = close_issue_live(
            model,
            issue_arg=_issue_arg(issue, args.issue),
            repo=args.repo,
            close_reason=args.close_reason,
            dashboard_path=args.body_out,
            comment_path=args.comment_out,
        )

    payload = report_payload(
        model,
        dry_run=bool(args.dry_run),
        closed=closed,
        closeout_comment_url=closeout_comment_url,
        finalized_closed_issue=should_finalize_closed,
    )
    write_json(args.report_out, payload)

    if model.repair_only:
        mode = "repair-dry-run" if args.dry_run else "repaired"
    else:
        mode = "dry-run" if args.dry_run else "closed"
    print(f"status={mode}")
    if should_finalize_closed:
        mode_label = "repair-closed-finalize"
    else:
        mode_label = "repair-closed" if model.repair_only else "normal-closeout"
    print(f"mode={mode_label}")
    print(f"issue={issue.url or issue.number or 'unknown'}")
    print(f"ready={str(model.ready).lower()}")
    print(f"finalized_closed_issue={str(should_finalize_closed).lower()}")
    print(f"linked_prs={','.join(str(n) for n in model.pr_numbers) if model.pr_numbers else 'none'}")
    print(f"validation={model.validation_summary}")
    if closeout_comment_url:
        print(f"closeout_comment={closeout_comment_url}")
    if args.body_out:
        print(f"body_out={args.body_out}")
    if args.comment_out:
        print(f"comment_out={args.comment_out}")
    if args.report_out:
        print(f"report_out={args.report_out}")
    return 0


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