"""Find unprotected entry points — symbols with no path to a required gate.

Output formats: text (default), ``--json``. SARIF is deliberately NOT
emitted because cmd_coverage_gaps is a REPORT command, not a detector —
its ``uncovered[]`` and ``covered[]`` lists stay in the JSON envelope
(via ``wrap_findings()``) rather than persisting to the central
findings registry. Coverage-gaps findings are invocation-scoped to a
specific gate + entry-point query; SARIF is reserved for registry-
persisting detectors with structural file:line coordinates. See
W1206-audit-unclear verdict + W1199 superseded plan.
"""

from __future__ import annotations

import os
import re
from collections import deque

import click

from roam.capability import roam_capability
from roam.commands.gate_presets import (
    ALL_PRESETS,
    detect_preset,
    get_preset,
    load_gates_config,
)
from roam.commands.graph_helpers import build_forward_adj
from roam.commands.resolve import ensure_index
from roam.coverage_reports import ingest_coverage_reports
from roam.db.connection import batched_in, find_project_root, open_db
from roam.output.confidence import (
    confidence_distribution,
    verdict_with_high_count,
    wrap_findings,
)
from roam.output.formatter import abbrev_kind, format_table, json_envelope, loc, to_json
from roam.output.metric_definitions import (
    COVERAGE_PCT_DEFINITION,
    GATE_VIOLATION_DEFINITION,
)

# R22 — confidence classifier for coverage-gaps findings.
#
# Coverage-gap findings are uncovered entry points: top-level exported
# functions / methods that don't reach a gate symbol in the call graph.
# Confidence reflects how much real-world traffic the entry point is
# under (proxied by in-degree, since uncovered entries by definition
# have no gate path):
#   high   — ≥10 callers and no gate; load-bearing surface, real risk.
#   medium — 3-9 callers and no gate; non-trivial surface.
#   low    — <3 callers; low-impact even if technically uncovered.
#
# We annotate each uncovered entry with `caller_count` at envelope
# build time so the classifier can read it locally. A missing
# caller_count falls back to "low" (we have no signal to escalate).
_COVERAGE_HIGH_CALLERS = 10
_COVERAGE_MEDIUM_CALLERS = 3


def _coverage_gaps_classify(uncovered: dict) -> tuple[str, str]:
    """Map an uncovered-entry finding to a (confidence, reason) tuple."""
    callers = uncovered.get("caller_count", 0) or 0
    try:
        callers_n = int(callers)
    except (TypeError, ValueError):
        callers_n = 0
    if callers_n >= _COVERAGE_HIGH_CALLERS:
        return "high", f"{callers_n} callers indexed; load-bearing uncovered surface"
    if callers_n >= _COVERAGE_MEDIUM_CALLERS:
        return "medium", f"{callers_n} callers indexed; non-trivial surface"
    return "low", f"{callers_n} caller(s) indexed; low-impact even if uncovered"


def _find_gates(conn, gate_names, gate_pattern):
    """Find gate symbol IDs by exact name or regex pattern."""
    gates = set()
    gate_info = {}

    if gate_names:
        names = [n.strip() for n in gate_names.split(",") if n.strip()]
        for name in names:
            rows = conn.execute(
                "SELECT s.id, s.name, f.path as file_path, s.line_start "
                "FROM symbols s JOIN files f ON s.file_id = f.id "
                "WHERE s.name = ?",
                (name,),
            ).fetchall()
            for r in rows:
                gates.add(r["id"])
                gate_info[r["id"]] = r["name"]

    if gate_pattern:
        regex = re.compile(gate_pattern, re.IGNORECASE)
        rows = conn.execute(
            "SELECT s.id, s.name, f.path as file_path, s.line_start FROM symbols s JOIN files f ON s.file_id = f.id "
        ).fetchall()
        for r in rows:
            if regex.search(r["name"]):
                gates.add(r["id"])
                gate_info[r["id"]] = r["name"]

    return gates, gate_info


def _find_entries(conn, scope, entry_pattern):
    """Find entry point symbols — exported top-level functions, optionally scoped."""
    sql = (
        "SELECT s.id, s.name, s.kind, f.path as file_path, s.line_start "
        "FROM symbols s JOIN files f ON s.file_id = f.id "
        "WHERE s.is_exported = 1 AND s.kind IN ('function', 'method') "
        "AND s.parent_id IS NULL "
    )
    params = []

    if scope:
        # Convert glob to LIKE pattern
        like = scope.replace("*", "%").replace("?", "_")
        sql += "AND f.path LIKE ? "
        params.append(like)

    sql += "ORDER BY f.path, s.line_start"
    rows = conn.execute(sql, params).fetchall()

    if entry_pattern:
        regex = re.compile(entry_pattern, re.IGNORECASE)
        rows = [r for r in rows if regex.search(r["name"])]

    return rows


def _bfs_to_gate(adj, start_id, gates, max_depth):
    """BFS from start_id to find shortest path to any gate symbol.

    Returns (gate_name, depth, chain) or (None, None, None) if not found.
    """
    if start_id in gates:
        return start_id, 0, [start_id]

    visited = {start_id}
    # Queue entries: (node_id, depth, path)
    queue = deque([(start_id, 0, [start_id])])

    while queue:
        current, depth, path = queue.popleft()
        if depth >= max_depth:
            continue
        for neighbor in adj.get(current, set()):
            if neighbor in visited:
                continue
            visited.add(neighbor)
            new_path = path + [neighbor]
            if neighbor in gates:
                return neighbor, depth + 1, new_path
            queue.append((neighbor, depth + 1, new_path))

    return None, None, None


def _evaluate_gate_rules(conn, rules):
    """Evaluate gate rules against indexed files.

    Returns a list of violation dicts for files that fail a gate rule.
    """
    from roam.index.gitignore import matches_gitignore

    # Fetch all indexed file paths
    all_files = [r["path"] for r in conn.execute("SELECT path FROM files").fetchall()]

    # Build a set of test-like file paths for quick lookup
    test_patterns = ["test_*", "*_test.*", "*.test.*", "*.spec.*", "*_test.go"]
    test_files = set()
    for fp in all_files:
        basename = os.path.basename(fp)
        if any(matches_gitignore(basename, tp) for tp in test_patterns):
            test_files.add(fp)

    # D was N+1 (one query per test file). Single grouped
    # query is O(test_files) data on the wire and one round trip.
    test_fn_count: dict[str, int] = dict.fromkeys(test_files, 0)
    if test_files:
        from roam.db.connection import batched_in

        rows = batched_in(
            conn,
            "SELECT f.path, COUNT(*) as cnt FROM symbols s JOIN files f ON s.file_id = f.id "
            "WHERE f.path IN ({ph}) AND s.kind = 'function' AND s.name LIKE 'test%' "
            "GROUP BY f.path",
            list(test_files),
        )
        for r in rows:
            test_fn_count[r["path"]] = int(r["cnt"] or 0)

    violations = []
    for rule in rules:
        # Find files matching include patterns
        matched = set()
        for fp in all_files:
            for pat in rule.include_patterns:
                if matches_gitignore(fp, pat):
                    matched.add(fp)
                    break

        # Remove files matching exclude patterns
        for fp in list(matched):
            for pat in rule.exclude_patterns:
                if matches_gitignore(fp, pat):
                    matched.discard(fp)
                    break

        # For each matched file, check if a corresponding test file exists
        for fp in sorted(matched):
            basename = os.path.basename(fp)
            stem = os.path.splitext(basename)[0]
            # Look for test_<stem>.py, <stem>_test.py, <stem>.test.js, etc.
            has_test = False
            related_test_count = 0
            for tf in test_files:
                tf_base = os.path.basename(tf)
                if stem in tf_base:
                    has_test = True
                    related_test_count += test_fn_count.get(tf, 0)

            if not has_test or related_test_count < rule.min_test_count:
                violations.append(
                    {
                        "rule": rule.name,
                        "severity": rule.severity,
                        "file": fp,
                        "description": rule.description,
                        "test_found": has_test,
                        "test_count": related_test_count,
                        "min_required": rule.min_test_count,
                    }
                )

    return violations


@roam_capability(
    name="coverage-gaps",
    category="reports",
    summary="Find entry points with no path to a required gate symbol",
    maturity="stable",
    mcp_expose=True,
    mcp_preset=("core",),
    side_effect=False,
    task_required=False,
    destructive=False,
    stale_sensitive=True,
    ai_safe=True,
    requires_index=True,
)
@click.command("coverage-gaps")
@click.option(
    "--gate",
    "gate_names",
    default=None,
    help="Comma-separated gate symbol names (e.g. 'requireAuth,validateToken')",
)
@click.option(
    "--gate-pattern",
    "gate_pattern",
    default=None,
    help="Regex to match gate symbols by name (e.g. 'auth|permission|guard')",
)
@click.option("--scope", default=None, help="File scope glob (e.g. 'app/routes/**')")
@click.option(
    "--entry-pattern",
    "entry_pattern",
    default=None,
    help="Regex to filter entry points by name (e.g. 'handler|controller')",
)
@click.option("--max-depth", default=8, show_default=True, help="Max BFS depth")
@click.option(
    "--preset",
    "preset_name",
    default=None,
    help="Use a built-in gate preset (python, javascript, go, java-maven, rust)",
)
@click.option(
    "--auto-detect",
    "auto_detect",
    is_flag=True,
    default=False,
    help="Auto-detect framework preset from project files",
)
@click.option("--config", "config_path", default=None, help="Path to .roam-gates.yml config file")
@click.option(
    "--import-report",
    "import_reports",
    multiple=True,
    help="Import coverage report (.info/.xml/.json). Repeat for multiple reports.",
)
@click.option(
    "--merge-imported",
    is_flag=True,
    default=False,
    help="Merge imported coverage into existing data (default replaces old imports).",
)
@click.pass_context
def coverage_gaps(
    ctx,
    gate_names,
    gate_pattern,
    scope,
    entry_pattern,
    max_depth,
    preset_name,
    auto_detect,
    config_path,
    import_reports,
    merge_imported,
):
    """Find entry points with no path to a required gate symbol.

    Use --gate for exact names or --gate-pattern for regex matching.
    Searches the call graph to find which entry points can reach a gate
    and which are unprotected.

    Use --preset or --auto-detect to apply framework-specific gate rules
    that check file-level test coverage requirements.

    Unlike ``auth-gaps`` (which performs PHP/Laravel-specific source analysis
    for unprotected endpoints) and ``test-gaps`` (which finds untested symbols
    in changed files), this command walks the call graph to verify that all
    entry points reach a required gate symbol -- configurable via
    ``--gate-pattern`` or framework presets.
    """
    json_mode = ctx.obj.get("json") if ctx.obj else False
    ensure_index()

    import_summary = None
    if import_reports:
        try:
            with open_db(readonly=False) as conn:
                import_summary = ingest_coverage_reports(
                    conn,
                    list(import_reports),
                    replace_existing=not merge_imported,
                    project_root=find_project_root(),
                )
        except Exception as exc:
            msg = f"Coverage import failed: {exc}"
            if json_mode:
                click.echo(
                    to_json(
                        json_envelope(
                            "coverage-gaps",
                            summary={"verdict": f"ERROR: {msg}", "error": msg},
                        )
                    )
                )
            else:
                click.echo(msg)
            raise SystemExit(1) from None

    # --- Resolve preset / config gate rules ---
    preset_used = None
    gate_rules = []

    if config_path:
        gate_rules = load_gates_config(config_path)
        if not gate_rules:
            msg = f"No rules loaded from {config_path}"
            if json_mode:
                click.echo(
                    to_json(
                        json_envelope(
                            "coverage-gaps",
                            summary={"verdict": f"ERROR: {msg}", "error": msg},
                        )
                    )
                )
            else:
                click.echo(msg)
            return

    if preset_name:
        preset_used = get_preset(preset_name)
        if not preset_used:
            available = ", ".join(p.name for p in ALL_PRESETS)
            msg = f"Unknown preset '{preset_name}'. Available: {available}"
            if json_mode:
                click.echo(
                    to_json(
                        json_envelope(
                            "coverage-gaps",
                            summary={"verdict": f"ERROR: {msg}", "error": msg},
                        )
                    )
                )
            else:
                click.echo(msg)
            return
        gate_rules = preset_used.rules

    if auto_detect and not preset_used:
        with open_db(readonly=True) as conn:
            file_paths = [r["path"] for r in conn.execute("SELECT path FROM files").fetchall()]
        preset_used = detect_preset(file_paths)
        if preset_used:
            gate_rules = preset_used.rules

    # If preset/config rules are active, evaluate them
    gate_violations = []
    if gate_rules:
        with open_db(readonly=True) as conn:
            gate_violations = _evaluate_gate_rules(conn, gate_rules)

    # Import-only mode: no gate traversal requested.
    if import_summary and not gate_names and not gate_pattern and not gate_rules:
        coverage_pct = import_summary.get("coverage_pct")
        coverage_str = f"{coverage_pct}%" if coverage_pct is not None else "n/a"
        verdict = (
            f"Imported {import_summary['reports']} report(s): "
            f"{import_summary['matched_files']}/{import_summary['parsed_files']} files matched; "
            f"coverage={coverage_str}"
        )
        if json_mode:
            click.echo(
                to_json(
                    json_envelope(
                        "coverage-gaps",
                        summary={
                            "verdict": verdict,
                            "imported_reports": import_summary["reports"],
                            "matched_files": import_summary["matched_files"],
                            "parsed_files": import_summary["parsed_files"],
                            "unmatched_files": import_summary["unmatched_count"],
                            "coverage_pct": import_summary["coverage_pct"],
                            # W331b: name the coverage_pct computation so
                            # consumers do not confuse it with the gate-
                            # reachability metric in the main branch.
                            # In this import-only branch coverage_pct
                            # comes from the parsed .info / .xml report.
                            "coverage_pct_definition": (
                                "imported coverage_pct from parsed report (.info/.xml/.json);"
                                " NOT the gate-reachability metric."
                            ),
                        },
                        import_summary=import_summary,
                    )
                )
            )
        else:
            click.echo(f"VERDICT: {verdict}")
            click.echo(
                f"  Updated: {import_summary['file_rows_updated']} files, "
                f"{import_summary['symbol_rows_updated']} symbols"
            )
            if import_summary["unmatched_count"]:
                click.echo(f"  Unmatched files: {import_summary['unmatched_count']}")
        return

    # If only preset/config mode (no --gate/--gate-pattern), output just the violations
    if not gate_names and not gate_pattern and gate_rules:
        errors = [v for v in gate_violations if v["severity"] == "error"]
        warnings = [v for v in gate_violations if v["severity"] == "warning"]
        preset_info = preset_used.name if preset_used else "custom"

        # LAW 6 + LAW 4: self-contained verdict that names the count and
        # anchors on a concrete-noun terminal ("violations" / "warnings" /
        # "files"). A bare "fail" / "pass" violates both laws.
        if errors:
            preset_verdict = (
                f"{len(errors)} blocking and {len(warnings)} advisory "
                f"gate violations across {len(gate_violations)} findings"
            )
        elif warnings:
            preset_verdict = f"0 blocking gate violations with {len(warnings)} advisory warnings"
        else:
            preset_verdict = f"0 gate violations across {preset_info} preset rules"

        if json_mode:
            click.echo(
                to_json(
                    json_envelope(
                        "coverage-gaps",
                        summary={
                            "verdict": preset_verdict,
                            "preset": preset_info,
                            "total_violations": len(gate_violations),
                            "errors": len(errors),
                            "warnings": len(warnings),
                            "imported_coverage_pct": import_summary["coverage_pct"] if import_summary else None,
                            # W331b (Pattern 3a): name the violation
                            # computation so the preset-only branch is
                            # unambiguous to downstream consumers.
                            "gate_violation_definition": GATE_VIOLATION_DEFINITION,
                        },
                        preset=preset_info,
                        gate_violations=gate_violations,
                        import_summary=import_summary,
                    )
                )
            )
        else:
            click.echo(f"VERDICT: {preset_verdict}")
            click.echo()
            click.echo(f"=== Coverage Gaps (preset: {preset_info}) ===\n")
            if import_summary:
                pct = import_summary.get("coverage_pct")
                pct_str = f"{pct}%" if pct is not None else "n/a"
                click.echo(
                    f"Imported coverage: {import_summary['matched_files']}/"
                    f"{import_summary['parsed_files']} files, {pct_str}"
                )
                click.echo()
            click.echo(f"Violations: {len(gate_violations)}  Errors: {len(errors)}  Warnings: {len(warnings)}")
            click.echo()
            if gate_violations:
                rows = []
                for v in gate_violations[:40]:
                    rows.append(
                        [
                            v["severity"].upper(),
                            v["rule"],
                            v["file"],
                            f"{v['test_count']}/{v['min_required']}",
                            v["description"],
                        ]
                    )
                click.echo(
                    format_table(
                        ["Severity", "Rule", "File", "Tests", "Description"],
                        rows,
                        budget=40,
                    )
                )
            else:
                click.echo("All gate rules pass.")
        return

    if not gate_names and not gate_pattern:
        verdict = "missing required filter — pass --gate or --gate-pattern"
        if json_mode:
            click.echo(
                to_json(
                    json_envelope(
                        "coverage-gaps",
                        summary={"verdict": verdict, "error": "missing_filter"},
                        status="usage_error",
                        isError=True,
                        error_code="USAGE_ERROR",
                        error=verdict,
                        usage={
                            "--gate": "single gate symbol (e.g. handle_login)",
                            "--gate-pattern": "regex over symbol names (e.g. '^auth_.*')",
                        },
                    )
                )
            )
        else:
            click.echo(f"VERDICT: {verdict}")
            click.echo()
            click.echo("  --gate NAME         single gate symbol (e.g. handle_login)")
            click.echo("  --gate-pattern RE   regex over symbol names (e.g. '^auth_.*')")
            click.echo()
            click.echo("Examples:")
            click.echo("  roam coverage-gaps --gate handle_payment")
            click.echo("  roam coverage-gaps --gate-pattern '^validate_.*'")
        raise SystemExit(2)

    with open_db(readonly=True) as conn:
        gates, gate_info = _find_gates(conn, gate_names, gate_pattern)

        if not gates:
            # W837 (Pattern 2 empty-state disclosure): the pre-W837 envelope
            # carried only ``summary.error`` — no ``verdict`` (LAW 6: the
            # verdict must stand alone) and no ``partial_success`` / ``state``
            # flag. A consumer reading ``summary.verdict`` saw ``None``,
            # indistinguishable from a malformed envelope. On an empty corpus
            # (or a gate filter that matches nothing) there is genuinely no
            # gate to compute coverage against — disclose that explicitly
            # rather than emitting a verdict-less envelope.
            verdict = "no gate symbols matched — coverage cannot be computed (broaden --gate / --gate-pattern or index a populated repo)"
            if json_mode:
                click.echo(
                    to_json(
                        json_envelope(
                            "coverage-gaps",
                            summary={
                                "verdict": verdict,
                                "error": "No gate symbols found",
                                "partial_success": True,
                                "state": "no_gates",
                            },
                            agent_contract={"facts": [verdict]},
                        )
                    )
                )
            else:
                click.echo(f"VERDICT: {verdict}")
                click.echo("No gate symbols found matching the criteria.")
            return

        entries = _find_entries(conn, scope, entry_pattern)
        if not entries:
            # W837 (Pattern 2): mirror the no-gates disclosure for the
            # no-entry-points branch. A real gate may exist but an empty /
            # entry-point-less corpus has nothing reaching it, so coverage is
            # vacuous — name the absent entry points explicitly.
            verdict = "no entry points in scope — coverage cannot be computed (widen --scope / --entry-pattern or index a populated repo)"
            if json_mode:
                click.echo(
                    to_json(
                        json_envelope(
                            "coverage-gaps",
                            summary={
                                "verdict": verdict,
                                "error": "No entry points found",
                                "partial_success": True,
                                "state": "no_entries",
                            },
                            agent_contract={"facts": [verdict]},
                        )
                    )
                )
            else:
                click.echo(f"VERDICT: {verdict}")
                click.echo("No entry points found in scope.")
            return

        adj = build_forward_adj(conn)

        # Resolve symbol names for chain display
        id_to_name = {}
        all_ids = set()
        for e in entries:
            all_ids.add(e["id"])
        for g in gates:
            all_ids.add(g)
        # Batch fetch names
        if all_ids:
            for r in batched_in(
                conn,
                "SELECT id, name FROM symbols WHERE id IN ({ph})",
                list(all_ids),
            ):
                id_to_name[r["id"]] = r["name"]

        covered = []
        uncovered = []

        for entry in entries:
            gate_id, depth, chain = _bfs_to_gate(adj, entry["id"], gates, max_depth)
            if gate_id is not None:
                # Resolve chain names (lazy — fetch as needed)
                chain_names = []
                for sid in chain:
                    if sid not in id_to_name:
                        r = conn.execute("SELECT name FROM symbols WHERE id = ?", (sid,)).fetchone()
                        id_to_name[sid] = r["name"] if r else "?"
                    chain_names.append(id_to_name[sid])

                covered.append(
                    {
                        "name": entry["name"],
                        "kind": entry["kind"],
                        "file": entry["file_path"],
                        "line": entry["line_start"],
                        "gate": gate_info.get(gate_id, "?"),
                        "depth": depth,
                        "chain": chain_names,
                    }
                )
            else:
                uncovered.append(
                    {
                        "name": entry["name"],
                        "kind": entry["kind"],
                        "file": entry["file_path"],
                        "line": entry["line_start"],
                        "reason": f"no gate in call chain (searched {max_depth} hops)",
                    }
                )

        total = len(entries)
        coverage_pct = round(len(covered) * 100 / total, 1) if total else 0

        if json_mode:
            # R22: annotate each uncovered entry with its caller count
            # so the classifier can derive confidence. Single batched
            # graph_metrics lookup keeps this constant in the count
            # of uncovered entries.
            if uncovered:
                # Look up symbol ids for uncovered entries by name+file+line
                # — entries[] earlier carried the id, so reuse the entry
                # row map keyed by (name, file, line).
                entry_id_by_key = {(e["name"], e["file_path"], e["line_start"]): e["id"] for e in entries}
                uncovered_ids: list[int] = []
                for u in uncovered:
                    key = (u["name"], u["file"], u["line"])
                    sid = entry_id_by_key.get(key)
                    if sid is not None:
                        u["_symbol_id"] = sid
                        uncovered_ids.append(sid)
                in_degree_by_id: dict[int, int] = {}
                if uncovered_ids:
                    for r in batched_in(
                        conn,
                        "SELECT symbol_id, in_degree FROM graph_metrics WHERE symbol_id IN ({ph})",
                        uncovered_ids,
                    ):
                        in_degree_by_id[r["symbol_id"]] = int(r["in_degree"] or 0)
                for u in uncovered:
                    sid = u.pop("_symbol_id", None)
                    u["caller_count"] = in_degree_by_id.get(sid, 0) if sid is not None else 0

            uncovered_triples = wrap_findings(uncovered, classifier=_coverage_gaps_classify)
            distribution = confidence_distribution(uncovered_triples)
            base_verdict = (
                f"all {total} entry points reach a gate ({coverage_pct}% coverage)"
                if len(uncovered) == 0
                else f"{len(uncovered)} unprotected entry point"
                + ("s" if len(uncovered) != 1 else "")
                + f" ({coverage_pct}% coverage)"
            )
            wrapped_verdict = verdict_with_high_count(base_verdict, distribution)
            summary = {
                "verdict": wrapped_verdict,
                "total_entries": total,
                "covered": len(covered),
                "uncovered": len(uncovered),
                "coverage_pct": coverage_pct,
                "gates_found": sorted(set(gate_info.values())),
                "imported_coverage_pct": import_summary["coverage_pct"] if import_summary else None,
                "findings_confidence_distribution": distribution,
                # W331b (Pattern 3a): stamp the precise definition of the
                # coverage_pct metric. In this main branch coverage_pct is
                # the gate-reachability ratio, NOT imported line/branch
                # coverage — those two are different signals despite the
                # shared field name and must not be conflated.
                "coverage_pct_definition": COVERAGE_PCT_DEFINITION,
            }
            extra = dict(
                gates_found=sorted(set(gate_info.values())),
                uncovered=uncovered_triples,
                covered=covered,
            )
            if preset_used:
                summary["preset"] = preset_used.name
                extra["preset"] = preset_used.name
            if gate_violations:
                summary["gate_violation_count"] = len(gate_violations)
                # W331b: when gate_violations are also reported here,
                # carry the violation definition too.
                summary["gate_violation_definition"] = GATE_VIOLATION_DEFINITION
                extra["gate_violations"] = gate_violations
            if import_summary:
                extra["import_summary"] = import_summary
            click.echo(
                to_json(
                    json_envelope(
                        "coverage-gaps",
                        summary=summary,
                        **extra,
                    )
                )
            )
            return

        # --- Text output ---
        if len(uncovered) == 0:
            verdict = f"all {total} entry points reach a gate ({coverage_pct}% coverage)"
        else:
            verdict = f"{len(uncovered)} unprotected entry point{'s' if len(uncovered) != 1 else ''} ({coverage_pct}% coverage)"
        click.echo(f"VERDICT: {verdict}")
        click.echo()
        header = "=== Coverage Gaps ==="
        if preset_used:
            header = f"=== Coverage Gaps (preset: {preset_used.name}) ==="
        click.echo(f"{header}\n")
        if import_summary:
            pct = import_summary.get("coverage_pct")
            pct_str = f"{pct}%" if pct is not None else "n/a"
            click.echo(
                f"Imported coverage: {import_summary['matched_files']}/"
                f"{import_summary['parsed_files']} files, {pct_str}"
            )
            click.echo()
        click.echo(f"Gates: {', '.join(sorted(set(gate_info.values())))}")
        click.echo(
            f"Entry points: {total}  Covered: {len(covered)}  Uncovered: {len(uncovered)}  Coverage: {coverage_pct}%"
        )
        click.echo()

        if uncovered:
            click.echo(f"-- Uncovered ({len(uncovered)}) --")
            rows = []
            for u in uncovered[:30]:
                rows.append(
                    [
                        u["name"],
                        abbrev_kind(u["kind"]),
                        loc(u["file"], u["line"]),
                        u["reason"],
                    ]
                )
            click.echo(
                format_table(
                    ["Name", "Kind", "Location", "Reason"],
                    rows,
                    budget=30,
                )
            )
            click.echo()

        if covered:
            click.echo(f"-- Covered ({len(covered)}) --")
            rows = []
            for c in covered[:20]:
                chain_str = " -> ".join(c["chain"][:5])
                if len(c["chain"]) > 5:
                    chain_str += f" (+{len(c['chain']) - 5})"
                rows.append(
                    [
                        c["name"],
                        abbrev_kind(c["kind"]),
                        loc(c["file"], c["line"]),
                        c["gate"],
                        str(c["depth"]),
                        chain_str,
                    ]
                )
            click.echo(
                format_table(
                    ["Name", "Kind", "Location", "Gate", "Depth", "Chain"],
                    rows,
                    budget=20,
                )
            )

        if gate_violations:
            click.echo()
            click.echo(f"-- Gate Violations ({len(gate_violations)}) --")
            rows = []
            for v in gate_violations[:30]:
                rows.append(
                    [
                        v["severity"].upper(),
                        v["rule"],
                        v["file"],
                        f"{v['test_count']}/{v['min_required']}",
                    ]
                )
            click.echo(
                format_table(
                    ["Severity", "Rule", "File", "Tests"],
                    rows,
                    budget=30,
                )
            )
