#!/usr/bin/env python3
"""spec-revocation-check — generic spec-amendment revocation gate.

Catches the two failure modes that have each felled a previous spec-amendment
attempt: a fabricated ``Revokes prior text (<TAG>): "..."`` quote whose text
was never in the file's pre-amendment state, and a normative line that was
removed with NO revocation marker at all.

Generic: walks any globs the caller provides, against any ``--base``
commit-ish, with any ``--tag`` label, in any repo. No project-specific
defaults.

Exit codes:
    0 — clean
    1 — at least one violation (any of the four categories)
    2 — usage / environment error (bad --base, not a git repo, bad regex,
        missing --base / --glob)

See ``specs/spec-revocation-check.spec.md`` for the full contract.
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

# --- Collapse helpers (blockquote, list-bullet, table outer pipes, then
# whitespace). Match the reference byte-for-byte so a quote extracted in this
# script matches the same quote at the same base sha.
_WS_RE = re.compile(r"\s+")
_BLOCKQUOTE_PREFIX_RE = re.compile(r"^>+\s*")
_LIST_BULLET_PREFIX_RE = re.compile(r"^(?:[-*]|\d+\.)\s+")
_TABLE_ROW_OUTER_PIPE_RE = re.compile(r"^\|\s*|\s*\|$")

# Markdown-only lines that are pure structure (Check B ignores them).
_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?[\s:|-]+\|?[\s:|-]*$")
_CODE_FENCE_RE = re.compile(r"^\s*(```|~~~)\s*$")
_HORIZONTAL_RULE_RE = re.compile(r"^\s*([-*_][\s]*){3,}\s*$")


def collapse(text: str) -> str:
    """Strip Markdown structural prefixes per line, then collapse all
    whitespace to single spaces (and trim)."""
    lines = text.splitlines()
    lines = [_BLOCKQUOTE_PREFIX_RE.sub("", line) for line in lines]
    lines = [_LIST_BULLET_PREFIX_RE.sub("", line) for line in lines]
    lines = [_TABLE_ROW_OUTER_PIPE_RE.sub("", line) for line in lines]
    return _WS_RE.sub(" ", " ".join(lines)).strip()


def is_markdown_only(collapsed: str) -> bool:
    """True if the collapsed text is pure Markdown structure (table
    separator, code fence, horizontal rule)."""
    if not collapsed:
        return True
    if "|" in collapsed and _TABLE_SEPARATOR_RE.match(collapsed):
        return True
    if _CODE_FENCE_RE.match(collapsed):
        return True
    if _HORIZONTAL_RULE_RE.match(collapsed):
        return True
    return False


def is_free_prose_unquoted(line: str) -> bool:
    """True if the line is a free-prose (no-quote) revocation marker.

    Recognised by the presence of ``Revokes prior text:`` (the colon form,
    no parens) AND the absence of any ``"`` on the same physical line.
    Examples::

        Revokes prior text: the pre-… wording
        **Revokes prior text:** the pre-… wording

    This is an older M23-era style that uses prose instead of a verbatim
    quote. Truly malformed lines (e.g. typos like ``Revokes prior text
    (M33): no quote`` or ``Revokes prior text typo``) lack the colon form
    or contain a ``"``, so they are not classified as free-prose.
    """
    if "Revokes prior text:" not in line:
        return False
    return '"' not in line


# --- Default marker regex builders ---------------------------------------

# The two-branch no-tag pattern. We use named groups to disambiguate which
# branch matched (the tagged branch's quote group is None on the untagged
# branch, and vice versa).
_NO_TAG_PATTERN = (
    r'Revokes prior text \((?P<tag>[^)]+)\):\s*"(?P<quote_tagged>.*)"'
    r'|Revokes prior text:\s*"(?P<quote_untagged>.*)"'
)


def default_marker_regex(tag: str | None) -> re.Pattern[str]:
    """Build the default per-line marker pattern.

    With ``--tag TAG``: ``Revokes prior text (TAG):\\s*"(.*)"``.
    Without ``--tag``: either the tagged form for any single parenthesised
    token (group ``quote_tagged``), OR the untagged form
    ``Revokes prior text:\\s*"(.*)"`` (group ``quote_untagged``).

    Greedy to the LAST ``"`` on the line so quotes containing literal ``"``
    still parse.
    """
    if tag is not None:
        body = rf'Revokes prior text \({re.escape(tag)}\):\s*"(.*)"'
    else:
        body = _NO_TAG_PATTERN
    return re.compile(body)


# --- Git helpers ----------------------------------------------------------

def _git(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        ["git", "-C", str(repo_root), *args],
        capture_output=True,
        text=True,
    )


def git_show(repo_root: Path, rev: str, rel_path: str) -> str | None:
    result = _git(repo_root, "show", f"{rev}:{rel_path}")
    if result.returncode != 0:
        return None
    return result.stdout


def git_resolve(repo_root: Path, rev: str) -> str | None:
    """Return the full sha for ``rev``, or None if it does not resolve."""
    result = _git(repo_root, "rev-parse", "--verify", f"{rev}^{{commit}}")
    if result.returncode != 0:
        return None
    return result.stdout.strip()


def git_diff_renames(repo_root: Path, base: str) -> dict[str, str]:
    """Return {old_path: new_path} for renames reported by ``git diff
    --find-renames --name-status -M30 <base>``.

    The ``-M30`` lowers the rename-detection similarity threshold from the
    default 50% to 30% — a renamed-then-quoted file has very low raw
    similarity (the original prose line is replaced with a revocation
    marker), so the default threshold misses it. 30% is conservative enough
    to avoid spurious matches on unrelated text but loose enough to catch
    the rename-and-amend case the brief describes.
    """
    result = _git(repo_root, "diff", "--find-renames", "-M30", "--name-status", "--diff-filter=R", base)
    out: dict[str, str] = {}
    if result.returncode != 0:
        return out
    for line in result.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) == 3 and parts[0].startswith("R"):
            _, old, new = parts
            out[old] = new
    return out


# --- File enumeration -----------------------------------------------------

def iter_files(repo_root: Path, globs: list[str]) -> list[Path]:
    """Return every file matched by any glob, sorted, deduped."""
    seen: dict[Path, None] = {}
    for pattern in globs:
        for p in sorted(repo_root.glob(pattern)):
            if p.is_file():
                seen[p] = None
    return list(seen)


def _glob_to_regex(pat: str) -> re.Pattern[str]:
    """Translate a pathlib-style glob (``*``, ``**``, ``?``) to a regex.

    ``**`` matches any number of path segments (including zero), consuming an
    optional trailing ``/``. ``*`` matches any chars except ``/``. ``?``
    matches one char except ``/``. Other chars are escaped.
    """
    out: list[str] = []
    i = 0
    while i < len(pat):
        c = pat[i]
        if c == "*":
            if i + 1 < len(pat) and pat[i + 1] == "*":
                out.append(".*")
                i += 2
                if i < len(pat) and pat[i] == "/":
                    i += 1
            else:
                out.append("[^/]*")
                i += 1
        elif c == "?":
            out.append("[^/]")
            i += 1
        else:
            out.append(re.escape(c))
            i += 1
    return re.compile("^" + "".join(out) + "$")


def iter_files_at_base(repo_root: Path, base: str, globs: list[str]) -> list[str]:
    """Return relative paths of every file that matched any glob at ``base``."""
    result = _git(repo_root, "ls-tree", "-r", "--name-only", base)
    if result.returncode != 0:
        return []
    base_paths = set(result.stdout.splitlines())
    compiled = [_glob_to_regex(g) for g in globs]
    out: list[str] = []
    seen: set[str] = set()
    for rel in sorted(base_paths):
        if rel in seen:
            continue
        if any(c.match(rel) for c in compiled):
            seen.add(rel)
            out.append(rel)
    return out


# --- Marker parsing -------------------------------------------------------

def find_quotes_and_malformed(
    path: Path,
    marker_re: re.Pattern[str],
    no_tag_mode: bool,
    tag: str | None,
    strict: bool,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]], int]:
    """Return (quotes, malformed, ignored).

    ``quotes``: ``[(line_no, quoted_text), ...]`` for every parseable marker.
    ``malformed``: ``[(line_no, raw_line), ...]`` for every candidate line
    that contained the marker phrase but did NOT parse.
    ``ignored``: count of free-prose unquoted lines seen in no-tag mode
    when ``strict`` is False. With ``--tag``, free-prose is impossible by
    construction (only literal-tagged lines are candidates), so this is
    always 0.

    In **tag mode** (``--tag TAG``, ``no_tag_mode == False``), only lines
    containing the literal ``Revokes prior text (TAG)`` are candidates;
    every other line is skipped before the regex runs, so a marker for a
    different tag, an untagged marker, or a free-prose marker cannot
    accidentally be classified as malformed or quoted.

    In **no-tag mode**, every line containing ``Revokes prior text`` is a
    candidate. A candidate that fails the multi-branch regex is split:
    ``is_free_prose_unquoted`` lines become ``ignored`` (or ``malformed``
    if ``strict``); everything else becomes ``malformed``.
    """
    quotes: list[tuple[int, str]] = []
    malformed: list[tuple[int, str]] = []
    ignored = 0
    tag_literal = f"Revokes prior text ({tag})" if not no_tag_mode else None
    for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if "Revokes prior text" not in line:
            continue
        if tag_literal is not None and tag_literal not in line:
            # With --tag, lines without the literal TAG marker are entirely
            # ignored — not malformed, not quoted, not counted. Their text
            # is never used to cover deletions in this run.
            continue
        m = marker_re.search(line)
        if m:
            if no_tag_mode:
                # Group 'quote_tagged' is None on the untagged branch.
                q = m.group("quote_tagged")
                if q is None:
                    q = m.group("quote_untagged")
            else:
                q = m.group(1)
            quotes.append((n, q or ""))
        else:
            if no_tag_mode and is_free_prose_unquoted(line):
                if strict:
                    malformed.append((n, line))
                else:
                    ignored += 1
            else:
                malformed.append((n, line))
    return quotes, malformed, ignored


# --- The check itself -----------------------------------------------------

def run_checks(
    repo_root: Path,
    base_sha: str,
    tag: str | None,
    marker_re: re.Pattern[str],
    globs: list[str],
    min_line_chars: int,
    strict: bool,
) -> dict:
    files_at_head = iter_files(repo_root, globs)
    renames = git_diff_renames(repo_root, base_sha)
    head_rel_set = {str(p.relative_to(repo_root)) for p in files_at_head}
    # Inverse map: new -> old, for renamed files.
    rename_targets = {new: old for old, new in renames.items()}

    no_tag_mode = (tag is None)

    unquoted: list[dict] = []
    fabricated: list[dict] = []
    malformed: list[dict] = []
    ignored_total = 0

    # Pass 1: every file at HEAD (Check A + Check B).
    for path in files_at_head:
        rel = str(path.relative_to(repo_root))
        base_text = git_show(repo_root, base_sha, rel)
        # If this is a renamed file, the base text lives at the OLD path.
        if base_text is None and rel in rename_targets:
            old_rel = rename_targets[rel]
            base_text = git_show(repo_root, base_sha, old_rel)

        quotes, mf, ignored = find_quotes_and_malformed(
            path, marker_re, no_tag_mode, tag, strict,
        )
        ignored_total += ignored

        if base_text is None:
            # File did not exist at base (and is not a rename). Any quote is
            # fabricated.
            for ln, quote in quotes:
                fabricated.append({
                    "path": rel, "line": ln, "base": base_sha,
                    "quote": quote,
                    "reason": "file did not exist at base",
                })
            for ln, raw in mf:
                malformed.append({
                    "path": rel, "line": ln, "base": base_sha,
                    "line_text": raw,
                })
            continue

        # Check A — verbatim quotes.
        collapsed_base = collapse(base_text)
        for ln, quote in quotes:
            cq = collapse(quote)
            if cq and cq not in collapsed_base:
                fabricated.append({
                    "path": rel, "line": ln, "base": base_sha,
                    "quote": quote,
                })

        # Malformed markers.
        for ln, raw in mf:
            malformed.append({
                "path": rel, "line": ln, "base": base_sha,
                "line_text": raw,
            })

        # Check B — completeness (only if file differs from base).
        current_text = path.read_text(encoding="utf-8")
        if collapse(base_text) == collapse(current_text):
            continue

        collapsed_current = collapse(current_text)
        collapsed_quotes = [collapse(q) for _, q in quotes]
        for n, line in enumerate(base_text.splitlines(), start=1):
            if not line.strip():
                continue
            collapsed_old = collapse(line)
            if not collapsed_old:
                continue
            if len(collapsed_old) < min_line_chars:
                continue
            if is_markdown_only(collapsed_old):
                continue
            if collapsed_old in collapsed_current:
                continue
            if any(collapsed_old in q for q in collapsed_quotes):
                continue
            unquoted.append({
                "path": rel, "line": n, "base": base_sha,
                "text": line.strip(),
            })

    # Pass 2: files that matched at base but not at HEAD (Check D).
    base_paths = iter_files_at_base(repo_root, base_sha, globs)
    deleted: list[dict] = []
    for rel in base_paths:
        if rel in head_rel_set:
            continue
        if rel in renames:
            new = renames[rel]
            if new in head_rel_set:
                continue  # rename handled by Check B
        deleted.append({"path": rel, "base": base_sha})

    return {
        "repo_root": str(repo_root.resolve()),
        "base": base_sha,
        "tag": tag,
        "strict": strict,
        "marker_regex": marker_re.pattern,
        "files_checked": sorted(head_rel_set),
        "violations": {
            "unquoted_deletions": unquoted,
            "fabricated_quotes": fabricated,
            "malformed_markers": malformed,
            "deleted_since_base": deleted,
        },
        "summary": {
            "unquoted": len(unquoted),
            "fabricated": len(fabricated),
            "malformed": len(malformed),
            "deleted": len(deleted),
            "ignored": ignored_total,
        },
    }


# --- Output ---------------------------------------------------------------

def format_human(result: dict) -> str:
    """Render the result dict as human-readable text."""
    lines: list[str] = []
    v = result["violations"]

    if v["unquoted_deletions"]:
        lines.append("unquoted deletions:")
        for x in v["unquoted_deletions"]:
            lines.append(f"  {x['path']}:{x['line']} ({x['base']}): {x['text']}")
    if v["fabricated_quotes"]:
        lines.append("fabricated quotes:")
        for x in v["fabricated_quotes"]:
            reason = x.get("reason", "")
            if reason:
                lines.append(f"  {x['path']}:{x['line']} ({x['base']}): {x['quote']!r}  [{reason}]")
            else:
                lines.append(f"  {x['path']}:{x['line']} ({x['base']}): {x['quote']!r}")
    if v["malformed_markers"]:
        lines.append("malformed markers:")
        for x in v["malformed_markers"]:
            lines.append(f"  {x['path']}:{x['line']} ({x['base']}): {x['line_text']}")
    if v["deleted_since_base"]:
        lines.append("deleted since base:")
        for x in v["deleted_since_base"]:
            lines.append(f"  {x['path']} ({x['base']})")

    if not any(v.values()) and result["summary"]["ignored"] == 0:
        return (
            f"spec-revocation-check: clean — every {result['base']} line "
            "survives or is quoted."
        )

    s = result["summary"]
    lines.append(
        f"Summary: {s['unquoted'] + s['fabricated'] + s['malformed'] + s['deleted']} "
        f"violation(s) — {s['unquoted']} unquoted, {s['fabricated']} fabricated, "
        f"{s['malformed']} malformed, {s['deleted']} deleted, "
        f"{s['ignored']} ignored"
    )
    return "\n".join(lines)


# --- CLI ------------------------------------------------------------------

def _validate_marker_regex(pattern: str) -> tuple[re.Pattern[str] | None, str | None]:
    """Compile the pattern and check it has exactly one capture group."""
    try:
        compiled = re.compile(pattern)
    except re.error as e:
        return None, f"--marker-regex does not compile: {e}"
    if compiled.groups != 1:
        return None, (
            f"--marker-regex must have exactly one capture group, got "
            f"{compiled.groups}. The single group is the quote text."
        )
    return compiled, None


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(
        description="Generic spec-amendment revocation gate.",
        add_help=True,
    )
    parser.add_argument("--base", required=True,
                        help="pre-amendment base commit-ish (required)")
    parser.add_argument("--tag", default=None,
                        help="marker tag, e.g. M33 (optional)")
    parser.add_argument("--glob", action="append", dest="globs", default=[],
                        help="glob (relative to --repo-root), repeatable, REQUIRED")
    parser.add_argument("--repo-root", default=None,
                        help="repo root (default: git rev-parse --show-toplevel of cwd)")
    parser.add_argument("--marker-regex", default=None,
                        help="override the marker regex; must have exactly one capture group")
    parser.add_argument("--min-line-chars", type=int, default=12,
                        help="ignore base lines shorter than this after collapsing (default: 12)")
    parser.add_argument("--strict", action="store_true",
                        help="without --tag, treat free-prose unquoted markers as malformed "
                             "(they are otherwise counted in the summary as 'ignored')")
    parser.add_argument("--json", action="store_true",
                        help="emit result as JSON on stdout")
    parser.add_argument("--dryrun", "-n", action="store_true",
                        help="print resolved config then run checks anyway")
    args = parser.parse_args(argv)

    if not args.globs:
        print("error: --glob is required (repeatable; no project default)", file=sys.stderr)
        return 2

    # Resolve repo root from cwd (NOT from the script's path).
    if args.repo_root is not None:
        repo_root = Path(args.repo_root).resolve()
    else:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True, text=True,
        )
        if result.returncode != 0:
            print(
                f"error: not inside a git working tree "
                f"({result.stderr.strip() or 'git rev-parse failed'})",
                file=sys.stderr,
            )
            return 2
        repo_root = Path(result.stdout.strip()).resolve()

    # Verify --base resolves.
    base_sha = git_resolve(repo_root, args.base)
    if base_sha is None:
        print(
            f"error: --base {args.base!r} does not resolve in {repo_root}",
            file=sys.stderr,
        )
        return 2

    # Build / validate marker regex.
    if args.marker_regex is not None:
        marker_re, err = _validate_marker_regex(args.marker_regex)
        if err is not None:
            print(f"error: {err}", file=sys.stderr)
            return 2
    else:
        marker_re = default_marker_regex(args.tag)

    if args.min_line_chars < 0:
        print("error: --min-line-chars must be >= 0", file=sys.stderr)
        return 2

    # Dryrun: print resolved config (still run checks).
    if args.dryrun:
        head_files = iter_files(repo_root, args.globs)
        print(f"[dryrun] repo_root: {repo_root}")
        print(f"[dryrun] base:      {args.base} -> {base_sha}")
        print(f"[dryrun] tag:       {args.tag if args.tag else 'None'}")
        print(f"[dryrun] strict:    {args.strict}")
        print(f"[dryrun] marker:    {marker_re.pattern}")
        print(f"[dryrun] min_line_chars: {args.min_line_chars}")
        print(f"[dryrun] files:     {len(head_files)}")
        for p in head_files:
            print(f"[dryrun]   - {p.relative_to(repo_root)}")
        print()

    result = run_checks(
        repo_root=repo_root,
        base_sha=base_sha,
        tag=args.tag,
        marker_re=marker_re,
        globs=args.globs,
        min_line_chars=args.min_line_chars,
        strict=args.strict,
    )

    if args.json:
        json.dump(result, sys.stdout, indent=2, sort_keys=False)
        sys.stdout.write("\n")
        print(format_human(result), file=sys.stderr)
    else:
        print(format_human(result))

    s = result["summary"]
    total = s["unquoted"] + s["fabricated"] + s["malformed"] + s["deleted"]
    return 1 if total > 0 else 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))