"""ADR coverage analysis functions for audit-check and covers-check commands."""

import ast
import re
from pathlib import Path
from typing import Any, cast

from gzkit.commands.common import console
from gzkit.quality import check_product_proof
from gzkit.traceability import (
    CoverageEntry,
    compute_coverage,
    find_covers_in_source,
    scan_feature_tree,
    scan_test_tree,
)
from gzkit.triangle import (
    DiscoveredReq,
    EdgeType,
    LinkageRecord,
    ReqId,
    ReqKind,
    VertexRef,
    VertexType,
    scan_briefs,
)


def _extract_adr_semver(adr_id: str) -> str | None:
    """Extract X.Y.Z semantic version from an ADR identifier."""
    match = re.match(r"^ADR-(\d+\.\d+\.\d+)", adr_id)
    return match.group(1) if match else None


def _synthesize_doc_proof_linkage(
    discovered: list[DiscoveredReq],
    project_root: Path,
) -> list[LinkageRecord]:
    """Synthesize COVERS records for DOC-kind REQs whose parent OBPI has non-test proof.

    GHI #165 — ``@covers`` is a code annotation; it does not belong on
    decision-documentation or integration-artifact REQs. A ``[doc]``-tagged REQ
    is considered covered when its parent OBPI carries any non-test proof
    channel recognized by :func:`gzkit.quality.check_product_proof` (decision
    text, command doc, governance artifact, runbook entry, release manifest,
    etc.). The synthetic records carry ``source.identifier`` like
    ``brief:<OBPI>:<proof_type>`` so the coverage report attributes the
    evidence to its real channel rather than silently skipping it.
    """
    obpi_brief_paths: dict[str, Path] = {}
    obpi_doc_reqs: dict[str, list[DiscoveredReq]] = {}
    for dreq in discovered:
        if dreq.entity.kind != ReqKind.DOC:
            continue
        obpi_id = dreq.entity.parent_obpi
        obpi_brief_paths[obpi_id] = Path(dreq.source_path)
        obpi_doc_reqs.setdefault(obpi_id, []).append(dreq)

    if not obpi_brief_paths:
        return []

    proof = check_product_proof("ADR-synthetic", obpi_brief_paths, project_root)
    records: list[LinkageRecord] = []
    for status in proof.obpi_proofs:
        if not status.has_proof:
            continue
        brief_path = obpi_brief_paths.get(status.obpi_id)
        if brief_path is None:
            continue
        source_id = f"brief:{status.obpi_id}:{status.proof_type}"
        for dreq in obpi_doc_reqs.get(status.obpi_id, []):
            records.append(
                LinkageRecord(
                    source=VertexRef(
                        vertex_type=VertexType.TEST,
                        identifier=source_id,
                        location=str(brief_path),
                    ),
                    target=VertexRef(
                        vertex_type=VertexType.SPEC,
                        identifier=str(dreq.entity.id),
                    ),
                    edge_type=EdgeType.COVERS,
                    evidence_path=str(brief_path),
                )
            )
    return records


def _compute_adr_coverage(
    project_root: Path, adr_id: str, adr_dir: Path | None = None
) -> dict[str, Any]:
    """Compute requirement coverage for an ADR's REQs (advisory, non-blocking).

    Consults every proof channel appropriate to the REQ kind (GHI #165):

    - CODE REQs: ``@covers`` decorators/comments in ``tests/**/*.py`` and
      ``@REQ-...`` scenario tags in ``features/**/*.feature``.
    - DOC REQs: decision text, command doc, governance artifact, runbook
      entry, release manifest, or other non-test proof on the parent OBPI
      (see :func:`_synthesize_doc_proof_linkage`).
    """
    empty: dict[str, Any] = {
        "total_reqs": 0,
        "covered_reqs": 0,
        "uncovered_reqs": 0,
        "coverage_percent": 0.0,
        "by_obpi": [],
        "uncovered": [],
    }
    semver = _extract_adr_semver(adr_id)
    if not semver:
        return empty

    if adr_dir is None:
        adr_dir = project_root / "docs" / "design" / "adr"
    tests_dir = project_root / "tests"
    features_dir = project_root / "features"
    if not adr_dir.is_dir():
        return empty

    discovered = scan_briefs(adr_dir)
    linkage_records: list[LinkageRecord] = []
    if tests_dir.is_dir():
        linkage_records.extend(scan_test_tree(tests_dir))
    if features_dir.is_dir():
        linkage_records.extend(scan_feature_tree(features_dir))
    linkage_records.extend(_synthesize_doc_proof_linkage(discovered, project_root))
    report = compute_coverage(discovered, linkage_records, include_doc=True)

    prefix = f"REQ-{semver}-"
    adr_entries = [e for e in report.entries if e.req_id.startswith(prefix)]
    if not adr_entries:
        return empty

    total = len(adr_entries)
    covered = sum(1 for e in adr_entries if e.covered)

    obpi_groups: dict[str, list[CoverageEntry]] = {}
    for entry in adr_entries:
        parsed = ReqId.parse(entry.req_id)
        obpi_key = f"OBPI-{parsed.semver}-{parsed.obpi_item}"
        obpi_groups.setdefault(obpi_key, []).append(entry)

    by_obpi = []
    for obpi_key in sorted(obpi_groups):
        group = obpi_groups[obpi_key]
        g_total = len(group)
        g_covered = sum(1 for e in group if e.covered)
        by_obpi.append(
            {
                "obpi": obpi_key,
                "total_reqs": g_total,
                "covered_reqs": g_covered,
                "uncovered_reqs": g_total - g_covered,
                "coverage_percent": round(g_covered / g_total * 100, 1) if g_total > 0 else 0.0,
            }
        )

    return {
        "total_reqs": total,
        "covered_reqs": covered,
        "uncovered_reqs": total - covered,
        "coverage_percent": round(covered / total * 100, 1),
        "by_obpi": by_obpi,
        "uncovered": [
            {"req_id": e.req_id, "severity": "advisory"} for e in adr_entries if not e.covered
        ],
    }


def _print_coverage_section(
    coverage: dict[str, Any],
    advisory_findings: list[dict[str, Any]] | None = None,
) -> None:
    """Render human-readable coverage section for audit-check output."""
    total = coverage["total_reqs"]
    if total == 0:
        console.print("\n[bold]Coverage:[/bold] No REQs found for this ADR.")
        return

    covered = coverage["covered_reqs"]
    pct = coverage["coverage_percent"]
    console.print(f"\n[bold]Coverage:[/bold] {covered}/{total} REQs covered ({pct}%)")
    for row in coverage["by_obpi"]:
        obpi = row["obpi"]
        cov = row["covered_reqs"]
        tot = row["total_reqs"]
        pct_obpi = row["coverage_percent"]
        console.print(f"  {obpi}: {cov}/{tot} ({pct_obpi}%)")

    uncovered = [r for r in coverage.get("uncovered", []) if isinstance(r, dict)]
    if uncovered:
        console.print(f"[red]Uncovered REQs ({len(uncovered)}):[/red]")
        for u in uncovered:
            console.print(f"  - {u['req_id']}")


def _collect_covers_annotations(project_root: Path) -> dict[str, list[str]]:
    """Collect ``@covers`` references from ``tests/**/*.py``.

    Uses the canonical scanner in :mod:`gzkit.traceability` so this matches
    what gz drift, gz covers, and gz adr audit-check see (see #120). Returns
    a mapping of target identifier (REQ/OBPI/ADR) to the list of relative
    test file paths that reference it. ``adr_covers_check`` also wants to
    see ADR/OBPI references, which only appear via the decorator-call form;
    those are picked up via a focused AST pass below.
    """
    tests_dir = project_root / "tests"
    if not tests_dir.exists():
        return {}

    covers: dict[str, list[str]] = {}

    for test_file in sorted(tests_dir.rglob("*.py")):
        content = test_file.read_text(encoding="utf-8")
        rel_path = test_file.relative_to(project_root).as_posix()

        # REQ-form references (decorator + docstring forms) via shared regex.
        for req_id, _line_num in find_covers_in_source(content):
            rows = covers.setdefault(req_id, [])
            if rel_path not in rows:
                rows.append(rel_path)

        # ADR/OBPI-form references only appear in decorator calls; the regex
        # above is REQ-shaped. Walk the AST once for those non-REQ targets.
        try:
            tree = ast.parse(content)
        except SyntaxError:
            continue

        for node in ast.walk(tree):
            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                continue
            for decorator in node.decorator_list:
                if not isinstance(decorator, ast.Call):
                    continue
                if isinstance(decorator.func, ast.Name):
                    decorator_name = decorator.func.id
                elif isinstance(decorator.func, ast.Attribute):
                    decorator_name = decorator.func.attr
                else:
                    continue
                if decorator_name != "covers" or not decorator.args:
                    continue
                target_arg = decorator.args[0]
                if not isinstance(target_arg, ast.Constant) or not isinstance(
                    target_arg.value, str
                ):
                    continue
                target = target_arg.value.strip()
                if not target or target.startswith("REQ-"):
                    continue
                rows = covers.setdefault(target, [])
                if rel_path not in rows:
                    rows.append(rel_path)

    return covers


OBPI_SEMVER_ITEM_RE = re.compile(r"^OBPI-([0-9]+\.[0-9]+\.[0-9]+)-([0-9]{2})(?:-[a-z0-9-]+)?$")
REQ_ID_RE = re.compile(r"\bREQ-[0-9]+\.[0-9]+\.[0-9]+-[0-9]{2}-[0-9]{2}\b")


def _extract_h2_section_lines(content: str, heading: str) -> list[tuple[int, str]]:
    """Return line-numbered content lines for a markdown H2 section."""
    lines = content.splitlines()
    heading_line = f"## {heading}"
    section_start: int | None = None
    for idx, line in enumerate(lines):
        if line.strip() == heading_line:
            section_start = idx + 1
            break
    if section_start is None:
        return []

    section_end = len(lines)
    for idx in range(section_start, len(lines)):
        if lines[idx].startswith("## "):
            section_end = idx
            break

    return [(line_no + 1, lines[line_no]) for line_no in range(section_start, section_end)]


def _req_prefix_for_obpi(obpi_id: str) -> str | None:
    """Return expected REQ prefix for an OBPI (REQ-<semver>-<item>-)."""
    match = OBPI_SEMVER_ITEM_RE.match(obpi_id)
    if not match:
        return None
    semver, item = match.groups()
    return f"REQ-{semver}-{item}-"


def _extract_obpi_requirement_targets(
    project_root: Path,
    obpi_file: Path,
    obpi_id: str,
) -> dict[str, Any]:
    """Extract REQ targets and acceptance-criteria gaps from an OBPI brief."""
    content = obpi_file.read_text(encoding="utf-8")
    section_lines = _extract_h2_section_lines(content, "Acceptance Criteria")
    req_prefix = _req_prefix_for_obpi(obpi_id)

    requirement_targets: set[str] = set()
    criteria_without_req_ids: list[dict[str, Any]] = []
    invalid_requirement_targets: list[dict[str, Any]] = []

    for line_no, line in section_lines:
        match = re.match(r"^\s*-\s*\[[ xX]\]\s*(.+?)\s*$", line)
        if not match:
            continue
        criterion_text = match.group(1).strip()
        if not criterion_text:
            continue

        req_ids = sorted(set(REQ_ID_RE.findall(criterion_text)))
        if not req_ids:
            criteria_without_req_ids.append(
                {
                    "obpi": obpi_id,
                    "file": obpi_file.relative_to(project_root).as_posix(),
                    "line": line_no,
                    "text": criterion_text,
                }
            )
            continue

        for req_id in req_ids:
            requirement_targets.add(req_id)
            if req_prefix and not req_id.startswith(req_prefix):
                invalid_requirement_targets.append(
                    {
                        "obpi": obpi_id,
                        "file": obpi_file.relative_to(project_root).as_posix(),
                        "line": line_no,
                        "req": req_id,
                        "expected_prefix": req_prefix,
                    }
                )

    return {
        "requirement_targets": sorted(requirement_targets),
        "criteria_without_req_ids": criteria_without_req_ids,
        "invalid_requirement_targets": invalid_requirement_targets,
    }


def _collect_adr_requirement_targets(
    project_root: Path,
    obpi_files: dict[str, Path],
) -> tuple[list[str], list[dict[str, Any]], list[dict[str, Any]]]:
    """Collect requirement targets and REQ-shape issues for an ADR OBPI set."""
    requirement_targets: set[str] = set()
    criteria_without_req_ids: list[dict[str, Any]] = []
    invalid_requirement_targets: list[dict[str, Any]] = []

    for obpi_id, obpi_file in sorted(obpi_files.items()):
        parsed = _extract_obpi_requirement_targets(project_root, obpi_file, obpi_id)
        requirement_targets.update(parsed["requirement_targets"])
        criteria_without_req_ids.extend(parsed["criteria_without_req_ids"])
        invalid_requirement_targets.extend(parsed["invalid_requirement_targets"])

    return (
        sorted(requirement_targets),
        criteria_without_req_ids,
        invalid_requirement_targets,
    )


def _build_covers_rows(
    adr_id: str,
    expected_targets: list[str],
    covers: dict[str, list[str]],
) -> tuple[list[dict[str, Any]], list[str]]:
    """Build per-target coverage rows and return missing targets."""
    rows: list[dict[str, Any]] = []
    missing: list[str] = []
    for target in expected_targets:
        tests = covers.get(target, [])
        rows.append(
            {
                "target": target,
                "target_type": (
                    "adr" if target == adr_id else "obpi" if target.startswith("OBPI-") else "req"
                ),
                "covered": bool(tests),
                "tests": tests,
            }
        )
        if not tests:
            missing.append(target)
    return rows, missing


def _print_adr_covers_check_result(result: dict[str, Any]) -> None:
    """Render human-readable output for adr covers-check."""
    adr_id = str(result["adr"])
    passed = bool(result["passed"])
    missing = cast(list[str], result["missing_targets"])
    criteria_without_req_ids = cast(list[dict[str, Any]], result["criteria_without_req_ids"])
    invalid_requirement_targets = cast(list[dict[str, Any]], result["invalid_requirement_targets"])
    unmatched_targets = cast(list[str], result["unmatched_targets"])

    console.print(f"[bold]ADR covers-check:[/bold] {adr_id}")
    if passed:
        console.print("[green]PASS[/green] All ADR/OBPI/REQ targets have @covers annotations.")
    if missing:
        console.print("[red]FAIL[/red] Missing @covers annotations:")
        for target in missing:
            console.print(f"  - {target}")
    if criteria_without_req_ids:
        console.print("[red]FAIL[/red] Acceptance criteria missing REQ IDs:")
        for row in criteria_without_req_ids:
            console.print(f"  - {row['obpi']}:{row['line']} -> {row['text']}")
    if invalid_requirement_targets:
        console.print("[red]FAIL[/red] REQ IDs with wrong OBPI scope:")
        for row in invalid_requirement_targets:
            console.print(
                f"  - {row['obpi']}:{row['line']} -> {row['req']} "
                f"(expected {row['expected_prefix']}*)"
            )
    if unmatched_targets:
        console.print("[yellow]Unmatched @covers targets (not linked to this ADR):[/yellow]")
        for target in unmatched_targets:
            console.print(f"  - {target}")
