#!/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"

EXPECTED_STATE_COLUMNS = ["ID", "Status", "Task", "Evidence", "Notes"]
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 LifecycleModel:
    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
    current_state: dict[str, str]
    state_columns: list[str]
    state_rows: list[dict[str, str]]
    completion: CompletionGate
    stale_links: list[str]
    pr_numbers: list[int]
    ready: bool
    repair_body: bool

    @property
    def dashboard_drift(self) -> bool:
        return bool(self.stale_links)


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")

    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():
        stripped = line.strip()
        if stripped.startswith("## "):
            return stripped.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 _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 _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]],
    latest_validation: IssueComment | None,
    stale_links: list[str],
    pr_numbers: list[int],
) -> CompletionGate:
    blockers: list[str] = []
    incomplete_rows: list[dict[str, str]] = []

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

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

    allowed_row_statuses = {"done", "deferred"}
    for row in state_rows:
        row_status = _normalize_status(row.get("Status", ""))
        if row_status not in allowed_row_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)

    if not latest_validation:
        blockers.append("missing latest validation comment")
    if not pr_numbers:
        blockers.append("missing linked PR evidence")
    if stale_links:
        blockers.append("stale dashboard links: " + ", ".join(stale_links))

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


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


def _comment_summary(comment: IssueComment | None, label: str) -> str:
    if not comment:
        return "none yet"
    heading = _first_heading(comment.body)
    return _link_or_text(heading if heading != "No heading" else label, comment.url)


def _material_dashboard_links(
    *,
    issue_body: str,
    source: IssueComment | None,
    plan: IssueComment | None,
    state: IssueComment | None,
    session: IssueComment | None,
    validation: IssueComment | None,
) -> list[str]:
    expected = [
        ("source snapshot", source.url if source else ""),
        ("plan snapshot", plan.url if plan else ""),
        ("execution state", state.url if state else ""),
        ("latest session", session.url if session else ""),
        ("latest validation", validation.url if validation else ""),
    ]
    return [label for label, url in expected if url and url not in issue_body]


def _session_blockers(session: IssueComment | None) -> str:
    if not session:
        return "none recorded"
    fields = _extract_bullet_fields(session.body)
    return fields.get("Blockers") or fields.get("blockers") or "none recorded"


def analyze_issue(issue: IssueData, *, repair_body: bool = False) -> LifecycleModel:
    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)

    current_state = _extract_bullet_fields(state.body) if state else {}
    state_columns, state_rows = _parse_state_table(state.body) if state else ([], [])

    checks: list[Check] = []
    state_upper = issue.state.upper()
    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"))

    if state:
        if state_columns == EXPECTED_STATE_COLUMNS:
            checks.append(Check("state schema", "pass", "ID | Status | Task | Evidence | Notes"))
        elif state_columns:
            checks.append(Check("state schema", "fail", "state table columns drift: " + ", ".join(state_columns)))
        else:
            checks.append(Check("state schema", "fail", "missing state task ledger table"))

    checks.append(
        Check(
            "latest session",
            "pass" if latest_session else "warn",
            latest_session.url if latest_session else "no session comment yet",
        )
    )
    checks.append(
        Check(
            "latest validation",
            "pass" if latest_validation else "warn",
            latest_validation.url if latest_validation else "no validation comment yet",
        )
    )

    stale_links = _material_dashboard_links(
        issue_body=issue.body,
        source=source,
        plan=plan,
        state=state,
        session=latest_session,
        validation=latest_validation,
    )
    checks.append(
        Check(
            "dashboard links",
            "warn" if stale_links else "pass",
            "stale or missing: " + ", ".join(stale_links) if stale_links else "dashboard links current",
        )
    )

    pr_numbers = _extract_pr_numbers(
        state.body if state else "",
        latest_session.body if latest_session else "",
        latest_validation.body if latest_validation else "",
    )
    completion = _completion_gate(
        state=state,
        current_state=current_state,
        state_columns=state_columns,
        state_rows=state_rows,
        latest_validation=latest_validation,
        stale_links=stale_links,
        pr_numbers=pr_numbers,
    )
    checks.append(
        Check(
            "completion readiness",
            "pass" if completion.ready else "warn",
            "complete" if completion.ready else "; ".join(completion.blockers),
        )
    )
    ready = all(check.status != "fail" for check in checks)
    return LifecycleModel(
        issue=issue,
        checks=checks,
        source_comment=source,
        plan_comment=plan,
        state_comment=state,
        latest_session=latest_session,
        latest_validation=latest_validation,
        current_state=current_state,
        state_columns=state_columns,
        state_rows=state_rows,
        completion=completion,
        stale_links=stale_links,
        pr_numbers=pr_numbers,
        ready=ready,
        repair_body=repair_body,
    )


def render_dashboard(model: LifecycleModel) -> str:
    state = model.current_state
    pr_text = ", ".join(f"#{n}" for n in model.pr_numbers) if model.pr_numbers else "none yet"
    lines = [
        "## Current Dashboard",
        "",
        "This issue is the durable tracking surface for an issue-backed plan execution. Source, plan, and execution progress remain in",
        "append-only issue comments.",
        "",
        f"- Status: {state.get('Status', 'unknown')}",
        f"- Target scope: {state.get('Target scope', 'issue-backed plan execution')}",
        f"- Execution window: {state.get('Execution window', 'unknown')}",
        f"- Current task: {state.get('Current task', 'unknown')}",
        f"- Next action: {state.get('Next task', 'unknown')}",
        f"- Branch/commit/PR: {state.get('Branch/commit/PR', 'none yet')}",
        f"- Latest validation: {_comment_summary(model.latest_validation, 'latest validation')}",
        f"- Linked PRs: {pr_text}",
        f"- Blockers: {_session_blockers(model.latest_session)}",
        "",
        "## 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: {_comment_summary(model.latest_session, 'latest session')}",
        f"- Latest validation: {_comment_summary(model.latest_validation, 'latest validation')}",
        "",
        "## Lifecycle Audit",
        "",
        "- Rendered dashboard links: current",
        "- Pre-render drift details: available in the lifecycle JSON report",
        f"- State ledger columns: {' | '.join(model.state_columns) if model.state_columns else 'missing'}",
        "",
        "## Original Tracker",
        "",
        f"- Title: {model.issue.title or 'untitled'}",
        f"- Issue: {model.issue.url or model.issue.number or 'unknown'}",
    ]
    return "\n".join(lines).rstrip() + "\n"


def report_payload(model: LifecycleModel, *, dry_run: bool) -> dict[str, Any]:
    return {
        "schema": "execute-from-tracking-issue.lifecycle-report.v1",
        "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_body": model.repair_body,
        "dashboard_drift": model.dashboard_drift,
        "stale_links": model.stale_links,
        "issue": {
            "number": model.issue.number,
            "title": model.issue.title,
            "state": model.issue.state,
            "url": model.issue.url,
        },
        "latest_comments": {
            "source": model.source_comment.url if model.source_comment else None,
            "plan": model.plan_comment.url if model.plan_comment else None,
            "state": model.state_comment.url if model.state_comment else None,
            "session": model.latest_session.url if model.latest_session else None,
            "validation": model.latest_validation.url if model.latest_validation else None,
        },
        "current_state": model.current_state,
        "state_columns": model.state_columns,
        "state_rows": model.state_rows,
        "linked_prs": model.pr_numbers,
        "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 repair_body_live(model: LifecycleModel, *, issue_arg: str, repo: str | None, dashboard: str) -> None:
    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)


def parse_args(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        prog="tracking-issue-lifecycle.sh",
        description="Audit and render an execute-from-tracking-issue lifecycle dashboard.",
    )
    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("--dry-run", action="store_true", help="Render and validate without mutating GitHub")
    parser.add_argument(
        "--repair-body",
        action="store_true",
        help="Update only the mutable issue body dashboard from latest append-only comments",
    )
    parser.add_argument("--body-out", help="Write rendered current dashboard body to this path")
    parser.add_argument("--report-out", help="Write JSON lifecycle audit report to this path")
    parser.add_argument(
        "--require-complete",
        action="store_true",
        help="Exit non-zero unless the latest execution state, validation, PR evidence, and dashboard links are complete",
    )
    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")
    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, repair_body=bool(args.repair_body))
    dashboard = render_dashboard(model)
    write_text(args.body_out, dashboard)

    payload = report_payload(model, dry_run=bool(args.dry_run))
    write_json(args.report_out, payload)

    if not model.ready or (args.require_complete and not model.completion.ready):
        failed = ", ".join(f"{c.name}: {c.detail}" for c in model.checks if c.status == "fail")
        if args.require_complete and not model.completion.ready:
            completion_failed = "completion readiness: " + "; ".join(model.completion.blockers)
            failed = f"{failed}, {completion_failed}" if failed else completion_failed
        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

    if args.repair_body and not args.dry_run:
        repair_body_live(model, issue_arg=_issue_arg(issue, args.issue), repo=args.repo, dashboard=dashboard)

    if args.repair_body:
        status = "repair-dry-run" if args.dry_run else "repaired"
    else:
        status = "audit-dry-run" if args.dry_run else "audit"
    print(f"status={status}")
    print(f"issue={issue.url or issue.number or 'unknown'}")
    print(f"ready={str(model.ready).lower()}")
    print(f"completion_ready={str(model.completion.ready).lower()}")
    print(f"repair_body={str(model.repair_body).lower()}")
    print(f"dashboard_drift={str(model.dashboard_drift).lower()}")
    print(f"stale_links={','.join(model.stale_links) if model.stale_links else 'none'}")
    if args.body_out:
        print(f"body_out={args.body_out}")
    if args.report_out:
        print(f"report_out={args.report_out}")
    return 0


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