#!/usr/bin/env python3
"""
AIIR — AI Integrity Receipts

Generate cryptographic receipts for git commits. Detects AI authorship signals
(Copilot, ChatGPT, Claude, Cursor, Aider, etc.) and produces content-addressed
JSON receipts suitable for audit trails and compliance.

Zero dependencies — uses only Python standard library.

Copyright 2025-2026 Invariant Systems, Inc.
SPDX-License-Identifier: Apache-2.0
"""

from __future__ import annotations

import argparse
import difflib
import hashlib
import hmac
import json
import logging
import os
import re
import subprocess
import sys
import time
import unicodedata
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, NoReturn, Optional, Sequence, Tuple
from urllib.parse import urlparse, urlunparse

# Structured logging for observability/debuggability.
# Users enable with --verbose or AIIR_LOG_LEVEL=DEBUG.
logger = logging.getLogger("aiir")

# Windows does not have os.fchmod — guard all permission-setting calls.
_HAS_FCHMOD = hasattr(os, "fchmod")


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

RECEIPT_SCHEMA_VERSION = "aiir/commit_receipt.v1"

# Single source of truth — __version__ lives in __init__.py only.
from aiir import __version__ as CLI_VERSION

# Known AI authorship signals in commit messages (all lowercase for matching)
AI_SIGNALS = [
    "co-authored-by: copilot",
    "co-authored-by: github copilot",
    "co-authored-by: chatgpt",
    "co-authored-by: claude",
    "co-authored-by: cursor",
    "generated by copilot",
    "generated by chatgpt",
    "generated by claude",
    "generated by cursor",
    "generated by ai",
    "ai-generated",
    "llm-generated",
    "machine-generated",
    "aider:",
    "codeium",
    "github copilot",
    "cursor tab",
    "claude code",
    "windsurf",
    "sourcegraph cody",
    # Major AI coding tools that were missing from detection.
    # Amazon Q / CodeWhisperer, Devin, Google Gemini, Tabnine — top-10 by
    # market share.  A tool claiming AI detection that misses these is an
    # immediate credibility problem at launch.
    "amazon q",
    "amazon-q",
    "codewhisperer",
    "amazon codewhisperer",
    "co-authored-by: devin",
    "generated by devin",
    "devin ai",
    "gemini code assist",
    "google gemini",
    "tabnine",
    "codegen by gemini",
]

# Git trailer keys that indicate AI involvement
AI_TRAILERS = [
    "generated-by",
    "ai-assisted",
    "ai-model",
    "tool",
    "copilot",
]

# Safety limit: max commits to receipt in a single range (DoS prevention)
MAX_RECEIPTS_PER_RANGE = 1000


# ---------------------------------------------------------------------------
# Encoding-safe symbol helpers
# ---------------------------------------------------------------------------
# Emoji above U+2700 crash on Windows cmd.exe (cp437/cp1252), CI runners with
# LANG=C, and piped stderr (encoding=ascii).  Box-drawing chars (U+2500 range)
# survive cp437 but crash on cp1252 (PowerShell default) and ASCII/latin-1.
# We probe sys.stderr.encoding once and fall back to plain ASCII when needed.

_EMOJI: Dict[str, Tuple[str, str]] = {
    "ok":     ("\u2705", "[ok]"),       # ✅
    "error":  ("\u274c", "[error]"),     # ❌
    "hint":   ("\U0001f4a1", "[hint]"),  # 💡
    "ai":     ("\U0001f916", "[AI]"),    # 🤖
    "signed": ("\U0001f58a\ufe0f", "[signed]"),  # 🖊️
    "tip":    ("\U0001f4dd", "[tip]"),   # 📝
    "shrug":  ("\U0001f937", "[info]"),  # 🤷
    "check":  ("\u2714", "ok"),          # ✔
}

# Box-drawing glyphs used in format_receipt_pretty.  These survive on cp437
# (DOS/Windows Console Host) but crash on cp1252 (PowerShell default),
# latin-1, and ASCII.  Fall back to safe ASCII art (+, -, |).
_BOX: Dict[str, Tuple[str, str]] = {
    "tl":   ("\u250c", "+"),   # ┌  top-left corner
    "vl":   ("\u2502", "|"),   # │  vertical line
    "bl":   ("\u2514", "+"),   # └  bottom-left corner
    "hl":   ("\u2500", "-"),   # ─  horizontal line
}


def _can_encode(probe: str) -> bool:
    """Return True if stderr can encode *probe* without error."""
    try:
        enc = getattr(sys.stderr, "encoding", None) or "ascii"
        probe.encode(enc)
        return True
    except (UnicodeEncodeError, LookupError):
        return False


# Resolved once at import time; overridable in tests via monkeypatching.
_USE_EMOJI: bool = _can_encode("\u2705\U0001f916")
_USE_BOXDRAW: bool = _can_encode("\u250c\u2500\u2502\u2514")


def _e(name: str) -> str:
    """Return emoji glyph if the terminal supports it, else ASCII fallback."""
    pair = _EMOJI.get(name)
    if pair is None:
        return ""
    return pair[0] if _USE_EMOJI else pair[1]


def _b(name: str) -> str:
    """Return box-drawing glyph if the terminal supports it, else ASCII."""
    pair = _BOX.get(name)
    if pair is None:
        return ""
    return pair[0] if _USE_BOXDRAW else pair[1]
# Common Unicode confusables that NFKC normalization does NOT resolve.
# Cyrillic/Greek letters visually identical to Latin — used to bypass AI detection.
_CONFUSABLE_TO_ASCII = {
    "\u0430": "a",  # Cyrillic а → Latin a
    "\u0410": "A",  # Cyrillic А → Latin A
    "\u0435": "e",  # Cyrillic е → Latin e
    "\u0415": "E",  # Cyrillic Е → Latin E
    "\u043e": "o",  # Cyrillic о → Latin o
    "\u041e": "O",  # Cyrillic О → Latin O
    "\u0441": "c",  # Cyrillic с → Latin c
    "\u0421": "C",  # Cyrillic С → Latin C
    "\u0440": "p",  # Cyrillic р → Latin p
    "\u0420": "P",  # Cyrillic Р → Latin P
    "\u0456": "i",  # Cyrillic і → Latin i
    "\u0406": "I",  # Cyrillic І → Latin I
    "\u0443": "u",  # Cyrillic у → Latin u
    "\u0423": "U",  # Cyrillic У → Latin U
    "\u0445": "x",  # Cyrillic х → Latin x
    "\u0425": "X",  # Cyrillic Х → Latin X
    "\u04bb": "h",  # Cyrillic һ → Latin h
    "\u0455": "s",  # Cyrillic ѕ → Latin s
    "\u0458": "j",  # Cyrillic ј → Latin j
    "\u0501": "d",  # Cyrillic ԁ → Latin d
    # Removed \u0491 (ґ→g) and \u0510 (Ԑ→q) — inaccurate visual matches.
    "\u03bf": "o",  # Greek ο → Latin o
    "\u03b1": "a",  # Greek α → Latin a (close match)
    "\u0391": "A",  # Greek Α → Latin A
    "\u0392": "B",  # Greek Β → Latin B
    "\u0395": "E",  # Greek Ε → Latin E
    "\u0397": "H",  # Greek Η → Latin H
    "\u0399": "I",  # Greek Ι → Latin I
    "\u039a": "K",  # Greek Κ → Latin K
    "\u039c": "M",  # Greek Μ → Latin M
    "\u039d": "N",  # Greek Ν → Latin N
    "\u039f": "O",  # Greek Ο → Latin O
    "\u03a1": "P",  # Greek Ρ → Latin P
    "\u03a4": "T",  # Greek Τ → Latin T
    "\u03a5": "Y",  # Greek Υ → Latin Y
    "\u03a7": "X",  # Greek Χ → Latin X
    "\u03b5": "e",  # Greek ε → close to e
}

# Unicode dash/hyphen variants that NFKC does NOT normalize to ASCII '-'.
# These are the delimiter in "co-authored-by" — missing them is a detection bypass.
_DASH_TO_ASCII = {
    "\u2010": "-",  # Hyphen (‐)
    "\u2011": "-",  # Non-breaking hyphen (‑)
    "\u2013": "-",  # En dash (–)
    "\u2014": "-",  # Em dash (—)
    "\u2015": "-",  # Horizontal bar (―)
    "\u2212": "-",  # Minus sign (−)
    "\u00AD": "-",  # Soft hyphen
    "\uFE58": "-",  # Small em dash (﹘)
    "\uFE63": "-",  # Small hyphen-minus (﹣) — NFKC covers this but be explicit
    "\uFF0D": "-",  # Fullwidth hyphen-minus (－) — NFKC covers this but be explicit
}


def _validate_ref(ref: str) -> str:
    """Reject refs that look like git options, path traversal, or shell metacharacters."""
    if ref.lstrip().startswith("-"):
        raise ValueError(f"Invalid git ref (looks like an option): {ref!r}")
    if "\x00" in ref:
        raise ValueError(f"Invalid git ref (contains NUL byte): {ref!r}")
    if "\n" in ref or "\r" in ref:
        raise ValueError(f"Invalid git ref (contains newline/CR): {ref!r}")
    if len(ref) > 1024:
        raise ValueError(f"Git ref too long ({len(ref)} chars, max 1024)")
    # Reject path traversal sequences — git rejects these too, but
    # defense-in-depth means catching them before they reach the subprocess.
    # IMPORTANT: '..' and '...' are valid git range operators (main..HEAD,
    # main...HEAD), so only reject path-traversal patterns like '../' or '/../'.
    if "/../" in ref or ref.startswith("../"):
        raise ValueError(f"Invalid git ref (contains path traversal): {ref!r}")
    # Reject shell metacharacters that have no place in a git ref.
    _SHELL_METACHARS = set(";&|$`!><{}()")
    found = _SHELL_METACHARS.intersection(ref)
    if found:
        raise ValueError(
            f"Invalid git ref (contains shell metacharacters {found!r}): {ref!r}"
        )
    return ref


def _sanitize_md(text: str) -> str:
    """Sanitize text for safe inclusion in GitHub Markdown step summaries.

    Escapes: \\ & < > | ` [ ! * _ ~ :// and dangerous Unicode (bidi, Cc, etc.).
    """
    # Strip dangerous Unicode control characters (RTL override, etc.)
    # Use targeted deny-list instead of blanket Cf strip, since we
    # intentionally use ZWSP (U+200B) below for autolink breaking.
    _DANGEROUS_CATEGORIES = {"Cc"}  # C0/C1 control codes
    _DANGEROUS_CODEPOINTS = {
        "\u200E", "\u200F",          # LRM, RLM
        "\u200C", "\u200D",          # ZWNJ, ZWJ (homoglyph risk)
        "\u202A", "\u202B", "\u202C", "\u202D", "\u202E",  # Bidi overrides
        "\u2066", "\u2067", "\u2068", "\u2069",              # Bidi isolates
        "\u00AD",                      # Soft hyphen
        "\uFEFF",                      # BOM / ZWNBSP
    }
    text = "".join(
        c for c in text
        if unicodedata.category(c) not in _DANGEROUS_CATEGORIES
        and c not in _DANGEROUS_CODEPOINTS
    )
    # Escape & → &amp; FIRST so that pre-encoded HTML entities
    # (e.g., &lt;script&gt;) become &amp;lt; and are displayed literally, not
    # decoded back to <script> by GFM renderers.
    text = text.replace("&", "&amp;")
    # Escape backslashes BEFORE all \-based escapes.  Without
    # this, a commit subject containing \| survives as \\| in the output,
    # and GFM interprets \\\\ as a literal backslash followed by | as a
    # pipe delimiter — breaking the summary table structure.  Similarly,
    # \* \_ \~ would bypass the emphasis escaping below.
    text = text.replace("\\", "\\\\")
    text = text.replace("|", "\\|")
    text = text.replace("`", "\\`")
    text = text.replace("[", "\\[")
    text = text.replace("!", "\\!")
    text = text.replace("<", "&lt;")
    text = text.replace(">", "&gt;")
    # Escape GFM emphasis/strikethrough markers to prevent
    # formatting injection in table cells (*bold*, _italic_, ~~strike~~).
    text = text.replace("*", "\\*")
    text = text.replace("_", "\\_")
    text = text.replace("~", "\\~")
    # Break GFM autolink detection to prevent phishing links in summaries
    text = text.replace("://", "\u200B://")
    return text


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class CommitInfo:
    """Parsed git commit metadata."""

    sha: str
    author_name: str
    author_email: str
    author_date: str
    committer_name: str
    committer_email: str
    committer_date: str
    subject: str
    body: str
    diff_stat: str
    diff_hash: str  # SHA-256 of the full diff
    files_changed: List[str] = field(default_factory=list)
    ai_signals_detected: List[str] = field(default_factory=list)
    is_ai_authored: bool = False


# ---------------------------------------------------------------------------
# Git helpers (subprocess, no dependencies)
# ---------------------------------------------------------------------------


# Default subprocess timeout (seconds) to prevent indefinite hangs
GIT_TIMEOUT = 300

# Prevent auth hangs (GIT_TERMINAL_PROMPT) and credential helpers (GIT_ASKPASS)
# in CI environments where no human is present.
_GIT_SAFE_ENV: Dict[str, str] = {
    **os.environ,
    "GIT_TERMINAL_PROMPT": "0",
    "GIT_ASKPASS": "",
}


def _run_git(args: List[str], cwd: Optional[str] = None) -> str:
    """Run a git command and return stdout."""
    logger.debug("git %s (cwd=%s)", " ".join(args[:3]), cwd or os.getcwd())
    result = subprocess.run(
        ["git", "--no-optional-locks"] + args,
        cwd=cwd or os.getcwd(),
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",  # Explicit UTF-8 — git outputs UTF-8 but Windows defaults to cp1252
        errors="replace",   # Replace undecodable bytes rather than crash
        check=False,
        timeout=GIT_TIMEOUT,  # Prevent indefinite hangs
        env=_GIT_SAFE_ENV,  # Prevent auth hangs
    )
    if result.returncode != 0:
        # Truncate stderr and redact command args to prevent leaking
        # internal format strings, paths, or auth details
        stderr_safe = result.stderr.strip().split('\n')[0][:200]
        # Redact anything that looks like a filesystem path to
        # prevent leaking internal directory structure in error messages.
        stderr_safe = re.sub(r'/[\w./-]{5,}', '<path>', stderr_safe)
        # Strip terminal escape sequences — a crafted ref name
        # (e.g., containing ESC[2J) would be echoed in git's stderr and
        # survive the truncation + path-redaction above.
        stderr_safe = _strip_terminal_escapes(stderr_safe)
        subcmd = args[0] if args else "command"
        raise RuntimeError(f"git {subcmd} failed: {stderr_safe}")
    return result.stdout


def _sha256(data: str) -> str:
    """SHA-256 hex digest of a UTF-8 string."""
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


def _canonical_json(obj: Any) -> str:
    """Deterministic JSON serialization (sorted keys, no whitespace).

    Uses an explicit depth limit instead of relying on Python's recursion limit.
    Prevents stack overflow from deeply nested JSON structures.
    """
    _check_json_depth(obj, max_depth=64)
    return json.dumps(
        obj, sort_keys=True, separators=(",", ":"),
        ensure_ascii=True, allow_nan=False,
    )


# Explicit depth checker for JSON structures.
_MAX_JSON_DEPTH = 64


def _check_json_depth(obj: Any, max_depth: int = _MAX_JSON_DEPTH) -> None:
    """Raise ValueError if a JSON structure exceeds max_depth nesting.

    Uses iterative stack-based traversal instead of recursion.
    The recursive version added to Python's call stack alongside any caller
    frames (e.g., MCP server → handler → verify → canonical_json → check),
    making the 64-level limit fragile under deep call chains.
    Removed dead `_current` parameter from old recursive API.
    """
    stack = [(obj, 0)]
    while stack:
        node, depth = stack.pop()
        if depth > max_depth:
            raise ValueError(f"JSON structure exceeds maximum depth of {max_depth}")
        if isinstance(node, dict):
            for v in node.values():
                stack.append((v, depth + 1))
        elif isinstance(node, (list, tuple)):
            for item in node:
                stack.append((item, depth + 1))


def _hash_diff_streaming(parent: str, sha: str, cwd: Optional[str] = None) -> str:
    """Stream-hash a git diff to avoid loading it all into memory.

    R3-02: stderr goes to DEVNULL to prevent bidirectional pipe deadlock.
    (If stdout and stderr are both PIPE and stderr fills the 64KB buffer,
    git blocks on stderr write while Python blocks on stdout read.)
    """
    # --no-ext-diff and --no-textconv prevent malicious .gitattributes
    # from invoking custom diff drivers/textconv filters that could inject
    # arbitrary data into the diff hash.
    proc = subprocess.Popen(
        ["git", "--no-optional-locks", "diff", "--no-ext-diff", "--no-textconv", parent, sha],
        cwd=cwd or os.getcwd(),
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        env=_GIT_SAFE_ENV,  # Consistent with _run_git
    )
    h = hashlib.sha256()
    # Track elapsed time to enforce timeout
    deadline = time.monotonic() + GIT_TIMEOUT
    try:
        while True:
            if time.monotonic() > deadline:
                proc.kill()
                proc.wait()  # Reap child to prevent zombie process
                raise RuntimeError(
                    f"git diff {parent} {sha} timed out after {GIT_TIMEOUT}s"
                )
            chunk = proc.stdout.read(65536)
            if not chunk:
                break
            h.update(chunk)
    except RuntimeError:
        raise  # Re-raise timeout error (already cleaned up above)
    except Exception:
        # Unexpected I/O error — kill and reap the subprocess
        proc.kill()
        proc.wait()
        raise
    finally:
        proc.stdout.close()
    # Final wait with timeout — kill if git hangs during cleanup.
    _killed_for_cleanup = False
    try:
        proc.wait(timeout=30)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait()
        _killed_for_cleanup = True
    if proc.returncode != 0 and not _killed_for_cleanup:
        raise RuntimeError(f"git diff {parent} {sha} failed (exit code {proc.returncode})")
    return "sha256:" + h.hexdigest()


def _strip_url_credentials(url: str) -> str:
    """Remove embedded credentials and query params from a git remote URL."""
    try:
        parsed = urlparse(url)
        needs_clean = False
        clean_netloc = parsed.netloc
        if parsed.username or parsed.password:
            # Reconstruct without credentials
            clean_netloc = parsed.hostname or ""
            if parsed.port:
                clean_netloc += f":{parsed.port}"
            needs_clean = True
        # Strip query params and fragments that may contain tokens
        if parsed.query or parsed.fragment:
            needs_clean = True
        if needs_clean:
            return urlunparse(parsed._replace(
                netloc=clean_netloc, query="", fragment="",
            ))
    except Exception:
        # If URL parsing/reconstruction fails, return a safe
        # placeholder instead of the original (which may contain credentials).
        # The old code did `pass` + `return url`, leaking embedded PATs.
        return "[credential-redacted-url]"
    return url


def _now_rfc3339() -> str:
    """Current time in RFC 3339 format."""
    return (
        datetime.now(timezone.utc)
        .replace(microsecond=0)
        .isoformat()
        .replace("+00:00", "Z")
    )


def get_repo_root(cwd: Optional[str] = None) -> str:
    """Get the git repository root."""
    return _run_git(["rev-parse", "--show-toplevel"], cwd=cwd).strip()


def get_commit_info(
    commit_ref: str = "HEAD", cwd: Optional[str] = None
) -> CommitInfo:
    """Parse full commit metadata from git."""
    _validate_ref(commit_ref)  # Reject option-like refs

    # Use NUL byte delimiter (%x00) instead of pipe.
    # Pipe characters can appear in author names and would shift all fields.
    fmt = "%H%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%s"
    # --no-mailmap prevents .mailmap from rewriting bot/AI author
    # identities to human emails, which would bypass AI detection heuristics.
    line = _run_git(["log", "-1", "--no-mailmap", f"--format={fmt}", commit_ref], cwd=cwd).strip()
    parts = line.split("\x00", 7)
    if len(parts) < 8:
        raise ValueError(f"Failed to parse commit {commit_ref}: got {line!r}")

    sha, a_name, a_email, a_date, c_name, c_email, c_date, subject = parts

    # Validate SHA format — a corrupted repo or malicious git
    # wrapper could return non-hex data that propagates into receipts.
    if not re.match(r'^[0-9a-f]{40,64}$', sha):
        raise ValueError(f"Invalid commit SHA format: {sha[:80]!r}")

    # Full commit message body
    body = _run_git(["log", "-1", "--no-mailmap", "--format=%B", commit_ref], cwd=cwd).strip()

    # Handle root commits (no parent) by falling back to empty tree
    try:
        parent = _run_git(
            ["rev-parse", "--verify", f"{sha}~1"], cwd=cwd
        ).strip()
    except RuntimeError:
        # Root commit — compute empty tree hash dynamically
        # (works with both SHA-1 and SHA-256 repos)
        try:
            result = subprocess.run(
                ["git", "--no-optional-locks", "hash-object", "-t", "tree", "--stdin"],
                cwd=cwd or os.getcwd(),
                input=b"",
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                check=True,
                timeout=GIT_TIMEOUT,  # Prevent hang on corrupt git
                env=_GIT_SAFE_ENV,    # Consistent with _run_git
            )
            parent = result.stdout.decode().strip()
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
            raise RuntimeError(f"Failed to compute empty tree hash: {e}") from e

    # Diff stat
    # --no-ext-diff/--no-textconv prevent custom diff driver injection
    diff_stat = _run_git(
        ["diff", "--no-ext-diff", "--no-textconv", "--stat", parent, sha], cwd=cwd
    ).strip()

    # Full diff for hashing — stream to avoid OOM on huge diffs
    diff_hash = _hash_diff_streaming(parent, sha, cwd=cwd)

    # Changed files
    files_raw = _run_git(
        ["diff", "--no-ext-diff", "--no-textconv", "--name-only", parent, sha], cwd=cwd
    ).strip()
    files_changed = [f for f in files_raw.split("\n") if f.strip()]

    # Detect AI signals
    ai_signals = detect_ai_signals(body, a_name, a_email, c_name, c_email)

    return CommitInfo(
        sha=sha,
        author_name=a_name,
        author_email=a_email,
        author_date=a_date,
        committer_name=c_name,
        committer_email=c_email,
        committer_date=c_date,
        subject=subject,
        body=body,
        diff_stat=diff_stat,
        diff_hash=diff_hash,
        files_changed=files_changed,
        ai_signals_detected=ai_signals,
        is_ai_authored=len(ai_signals) > 0,
    )


def _normalize_for_detection(text: str) -> str:
    """Normalize text for AI signal detection.

    Strips invisible/formatting characters, resolves Unicode homoglyphs,
    and removes combining marks to produce a canonical ASCII-ish string
    suitable for substring matching against known AI signals.

    Order: strip Cf → NFKC → confusable map → strip Mn/Me.
    """
    # Strip Cf (format chars: ZWJ, ZWNJ, ZWSP, variation selectors)
    # before NFKC to prevent zero-width insertion from affecting normalization.
    text = "".join(c for c in text if unicodedata.category(c) != "Cf")
    # NFKC collapses compatibility variants (e.g., fullwidth Ｃ→C)
    text = unicodedata.normalize("NFKC", text)
    # Normalize Unicode dash/hyphen variants to ASCII '-'
    text = "".join(_DASH_TO_ASCII.get(c, c) for c in text)
    # Resolve cross-script homoglyphs that NFKC doesn't cover
    text = "".join(_CONFUSABLE_TO_ASCII.get(c, c) for c in text)
    # Strip combining marks (Mn) and enclosing marks (Me) — variation
    # selectors and diacriticals that survive NFKC
    text = "".join(
        c for c in text if unicodedata.category(c) not in ("Mn", "Me")
    )
    return text


def detect_ai_signals(
    message: str,
    author_name: str = "",
    author_email: str = "",
    committer_name: str = "",
    committer_email: str = "",
) -> List[str]:
    """Detect AI authorship signals in commit metadata.

    Returns a list of detected signal descriptions.
    """
    signals = []
    # Use shared _normalize_for_detection helper for consistent
    # normalization across message body and author/committer fields.
    msg_clean = _normalize_for_detection(message)
    # Collapse whitespace runs (tab, double-space, etc.) to single
    # space so "Co-authored-by:\tCopilot" matches the canonical signal.
    msg_lower = re.sub(r'\s+', ' ', msg_clean).lower()

    # Check message body for AI signals
    for signal in AI_SIGNALS:
        if signal in msg_lower:
            signals.append(f"message_match:{signal}")

    # Check for AI-related trailers (git trailer format: Key: Value)
    # Only record trailer key, not value (avoids leaking internal tool names)
    # S2: Normalize each line before matching to prevent Unicode homoglyph bypass
    # (e.g., Cyrillic 'е' in "Generated-by:" evading detection).
    for line in message.split("\n"):
        line_normalized = _normalize_for_detection(line).strip().lower()
        for trailer in AI_TRAILERS:
            if line_normalized.startswith(f"{trailer}:"):
                signals.append(f"trailer:{trailer}")

    # Check author/committer for known AI bot patterns
    bot_patterns = [
        "copilot",
        "github-actions",
        "dependabot",
        "renovate",
        "snyk-bot",
        "deepsource",
        "coderabbit",
        # Missing bot patterns for major AI tools.
        # Use specific patterns to avoid false positives on human names.
        "devin[bot]",
        "devin-bot",
        "amazon-q",
        "tabnine",
        "gemini[bot]",
        "gemini-bot",
    ]
    for name_field, label in [
        (author_name, "author_name"),
        (author_email, "author_email"),
        (committer_name, "committer_name"),
        (committer_email, "committer_email"),
    ]:
        # Use shared helper (consistent with message body path).
        clean_field = _normalize_for_detection(name_field or "").lower()
        for pattern in bot_patterns:
            if pattern in clean_field:
                signals.append(f"{label}_bot:{pattern}")

    return signals


def list_commits_in_range(
    range_spec: str,
    cwd: Optional[str] = None,
    max_count: int = MAX_RECEIPTS_PER_RANGE,
) -> List[str]:
    """List commit SHAs in a range (e.g., 'origin/main..HEAD').

    Limited to max_count commits to prevent DoS.
    """
    if max_count < 1:
        raise ValueError(f"max_count must be positive, got {max_count}")
    _validate_ref(range_spec)  # Reject option-like refs
    output = _run_git(
        ["rev-list", f"--max-count={max_count}", range_spec], cwd=cwd
    ).strip()
    return [sha for sha in output.split("\n") if sha.strip()]


# ---------------------------------------------------------------------------
# Receipt generation
# ---------------------------------------------------------------------------


def build_commit_receipt(
    commit: CommitInfo, repo_root: Optional[str] = None,
    redact_files: bool = False,
    instance_id: Optional[str] = None,
    namespace: Optional[str] = None,
) -> Dict[str, Any]:
    """Build a cryptographic receipt for a git commit.

    The receipt is a content-addressed JSON object. The receipt_id is derived
    from the SHA-256 of the canonical JSON of the receipt core (everything
    except the receipt_id itself and the timestamp).
    """
    now = _now_rfc3339()

    # Determine repo identity (None when no remote configured)
    repo_url: Optional[str] = None
    try:
        repo_url = _run_git(
            ["remote", "get-url", "origin"], cwd=repo_root
        ).strip() or None
        if repo_url:
            repo_url = _strip_url_credentials(repo_url)
    except RuntimeError:
        pass

    # Build receipt core (everything that gets hashed)
    receipt_core = {
        "type": "aiir.commit_receipt",
        "schema": RECEIPT_SCHEMA_VERSION,
        "version": CLI_VERSION,
        "commit": {
            "sha": commit.sha,
            "author": {
                "name": commit.author_name,
                "email": commit.author_email,
                "date": commit.author_date,
            },
            "committer": {
                "name": commit.committer_name,
                "email": commit.committer_email,
                "date": commit.committer_date,
            },
            "subject": commit.subject,
            "message_hash": "sha256:" + _sha256(commit.body),
            "diff_hash": commit.diff_hash,
            "files_changed": len(commit.files_changed),
            # When --redact-files is set, omit individual file paths
            # to prevent internal project structure enumeration.
            **({"files": commit.files_changed[:100]} if not redact_files else {"files_redacted": True}),
            # Indicate when file list was truncated so verifiers know
            # the full list is not present.
            **({"files_capped": True} if not redact_files and len(commit.files_changed) > 100 else {}),
        },
        "ai_attestation": {
            "is_ai_authored": commit.is_ai_authored,
            "signals_detected": commit.ai_signals_detected,
            "signal_count": len(commit.ai_signals_detected),
            "detection_method": "heuristic_v1",
        },
        "provenance": {
            "repository": repo_url,
            # URI-based tool identifier for SLSA/in-toto compatibility.
            "tool": f"https://github.com/invariant-systems-ai/aiir@{CLI_VERSION}",
            "generator": "aiir.cli",
        },
    }

    # Compute content-addressed receipt ID
    core_json = _canonical_json(receipt_core)
    content_hash = "sha256:" + _sha256(core_json)
    receipt_id = f"g1-{_sha256(core_json)[:32]}"

    # Final receipt = core + derived fields
    receipt = {
        **receipt_core,
        "receipt_id": receipt_id,
        "content_hash": content_hash,
        "timestamp": now,
        # Extension point for downstream integrations.
        # This field is NOT in CORE_KEYS and is excluded from
        # content_hash/receipt_id, so integrations can populate it
        # without breaking receipt verification.
        "extensions": {
            **(({"instance_id": instance_id}) if instance_id else {}),
            **(({"namespace": namespace}) if namespace else {}),
        },
    }

    return receipt


def generate_receipt(
    commit_ref: str = "HEAD",
    cwd: Optional[str] = None,
    ai_only: bool = False,
    redact_files: bool = False,
    instance_id: Optional[str] = None,
    namespace: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
    """Generate a receipt for a single commit. Returns None if skipped."""
    _validate_ref(commit_ref)
    commit = get_commit_info(commit_ref, cwd=cwd)

    if ai_only and not commit.is_ai_authored:
        return None

    return build_commit_receipt(
        commit, repo_root=cwd, redact_files=redact_files,
        instance_id=instance_id, namespace=namespace,
    )


def generate_receipts_for_range(
    range_spec: str,
    cwd: Optional[str] = None,
    ai_only: bool = False,
    redact_files: bool = False,
    instance_id: Optional[str] = None,
    namespace: Optional[str] = None,
) -> List[Dict[str, Any]]:
    """Generate receipts for all commits in a range."""
    shas = list_commits_in_range(range_spec, cwd=cwd)
    receipts = []
    for sha in shas:
        receipt = generate_receipt(
            sha, cwd=cwd, ai_only=ai_only, redact_files=redact_files,
            instance_id=instance_id, namespace=namespace,
        )
        if receipt is not None:
            receipts.append(receipt)
    return receipts


# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------


def _strip_terminal_escapes(text: str) -> str:
    """Strip ANSI escape sequences and ASCII control chars from text (R5-10).

    Prevents terminal injection via crafted commit subjects or author names
    (e.g., overwriting lines with ESC[A, changing colors, setting title).
    """
    # Strip ANSI CSI sequences (ESC[...X) and OSC sequences (ESC]...BEL/ST)
    # CSI final byte is 0x40-0x7E per ECMA-48, not just [A-Za-z].
    # Includes @, ~, {, |, } etc. (e.g., ESC[2~ = Insert key).
    text = re.sub(r'\x1b\[[0-9;]*[@-~]', '', text)
    text = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', text)
    # Strip PM (ESC ^...ST) and APC (ESC _...ST) sequences.
    # Also strip SOS (ESC X...ST) and DCS (ESC P...ST) sequences.
    # ECMA-48 §5.6 defines six C1 control strings; we now cover all of them.
    # ST terminator is now optional — unterminated control strings
    # (payload with no ESC \) are also stripped to prevent payload leakage.
    text = re.sub(r'\x1b[\^_XP][^\x1b]*(?:\x1b\\)?', '', text)
    # Strip remaining control characters:
    # - C0 range (0x00-0x1F except tab)
    # - DEL (0x7F) — erases characters in some terminals
    # 8-bit C1 controls (0x80-0x9F) — single-byte equivalents of
    # ESC-based C1 sequences (e.g., U+009B = CSI, U+0090 = DCS). Some terminals
    # interpret these as control sequences even in UTF-8 mode.
    text = ''.join(
        c for c in text
        if (ord(c) >= 0x20 or c == '\t')
        and ord(c) != 0x7F
        and not (0x80 <= ord(c) <= 0x9F)
    )
    return text


def format_receipt_pretty(receipt: Dict[str, Any]) -> str:
    """Human-readable receipt summary."""
    commit = receipt.get("commit", {})
    ai = receipt.get("ai_attestation", {})
    # Guard against non-dict commit/ai_attestation — a crafted
    # receipt with these fields as strings, lists, or ints would crash all
    # subsequent .get() calls with AttributeError.
    if not isinstance(commit, dict):
        commit = {}
    if not isinstance(ai, dict):
        ai = {}
    # Sanitize ALL user-controlled or display fields
    # against terminal escape injection.
    subject = _strip_terminal_escapes(commit.get('subject', ''))
    # Guard author sub-field type — extends defensive pattern to
    # nested fields.  A crafted receipt with author as string/int/list/None
    # would crash the .get('name', '') call with AttributeError.
    author = commit.get('author', {})
    if not isinstance(author, dict):
        author = {}
    author_name = _strip_terminal_escapes(author.get('name', ''))
    author_email = _strip_terminal_escapes(author.get('email', ''))
    receipt_id = _strip_terminal_escapes(str(receipt.get('receipt_id', 'unknown')))
    commit_sha = _strip_terminal_escapes(str(commit.get('sha', 'unknown'))[:12])
    content_hash = _strip_terminal_escapes(str(receipt.get('content_hash', '')))
    timestamp = _strip_terminal_escapes(str(receipt.get('timestamp', '')))
    # Coerce files_changed to int — a crafted receipt could
    # inject arbitrary strings via this field into terminal output.
    # Also catch OverflowError — int(float('inf')) raises
    # OverflowError, and json.loads('1e999') produces float('inf').
    try:
        files_count = int(commit.get('files_changed', 0))
    except (TypeError, ValueError, OverflowError):
        files_count = 0
    # Guard against non-list signals_detected — a crafted
    # receipt with signals_detected as a dict or string would crash on
    # slicing (dict[:3] raises TypeError).  Coerce to empty list.
    signals = ai.get('signals_detected', [])
    if not isinstance(signals, (list, tuple)):
        signals = []
    # Box-drawing chars → _b() for encoding safety.
    tl, vl, bl, hl = _b("tl"), _b("vl"), _b("bl"), _b("hl")
    lines = [
        f"{tl}{hl} Receipt: {receipt_id}",
        f"{vl}  Commit:  {commit_sha}",
        f"{vl}  Subject: {subject}",
        f"{vl}  Author:  {author_name} <{author_email}>",
        f"{vl}  Files:   {files_count} changed",
        f"{vl}  AI:      {'YES' if ai.get('is_ai_authored') else 'no'}"
        + (
            # Sanitize signal strings — a crafted receipt could
            # inject ANSI escapes via signals_detected list items.
            # Also validate signal types and cap length — a crafted
            # receipt could smuggle long or non-string items into the output.
            # Use guarded `signals` (coerced to list above).
            f" ({', '.join(_strip_terminal_escapes(str(s))[:80] for s in signals[:3] if isinstance(s, (str, int, float)))})"
            if signals
            else ""
        ),
        f"{vl}  Hash:    {content_hash}",
        f"{vl}  Time:    {timestamp}",
        f"{bl}{hl * 42}",
    ]
    return "\n".join(lines)


def write_receipt(
    receipt: Dict[str, Any],
    output_dir: Optional[str] = None,
    jsonl: bool = False,
) -> str:
    """Write receipt to file or stdout. Returns the output path or 'stdout'."""
    receipt_json = json.dumps(receipt, indent=2, ensure_ascii=False)

    if output_dir:
        out_path = Path(output_dir).resolve()
        # Prevent path traversal — use relative_to (not startswith, which
        # has a prefix-collision bug: /repo vs /repo_evil both pass startswith).
        cwd_resolved = Path(os.getcwd()).resolve()
        try:
            out_path.relative_to(cwd_resolved)
        except ValueError:
            raise ValueError(
                f"output-dir must be within the working directory: {output_dir!r} "
                f"resolves outside {cwd_resolved}"
            )
        out_path.mkdir(parents=True, exist_ok=True)
        # Re-verify after mkdir to narrow TOCTOU/symlink race window
        real_out = out_path.resolve()
        try:
            real_out.relative_to(cwd_resolved)
        except ValueError:
            raise ValueError(
                "output-dir escaped working directory after creation "
                "(possible symlink attack)"
            )
        commit_sha = receipt.get("commit", {}).get("sha", "unknown")[:12]
        commit_sha = re.sub(r'[^a-zA-Z0-9_-]', '_', commit_sha)
        # Deterministic filename from content_hash — makes it easy to
        # check if a receipt already exists for a given commit.
        chash = receipt.get("content_hash", "")
        chash_short = re.sub(r'[^a-fA-F0-9]', '', chash)[:16]
        filename = f"receipt_{commit_sha}_{chash_short}.json"
        filepath = out_path / filename
        # If this exact receipt already exists, return existing path
        if filepath.exists():
            return str(filepath)
        fd = os.open(str(filepath), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
        # os.open mode is masked by umask — force 0o644 after creation
        if _HAS_FCHMOD:
            os.fchmod(fd, 0o644)
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(receipt_json + "\n")
        return str(filepath)
    elif jsonl:
        # JSON Lines: one receipt per line, no pretty printing
        print(_canonical_json(receipt), flush=True)
        return "stdout:jsonl"
    else:
        print(receipt_json, flush=True)
        return "stdout:json"


# ---------------------------------------------------------------------------
# Ledger — append-only JSONL receipt log with auto-index
# ---------------------------------------------------------------------------

# Default ledger directory and filenames.
_LEDGER_DIR = ".aiir"
_LEDGER_FILE = "receipts.jsonl"
_INDEX_FILE = "index.json"
_CONFIG_FILE = "config.json"


def _ledger_paths(
    ledger_dir: Optional[str] = None,
) -> Tuple[Path, Path, Path]:
    """Return (dir, ledger_path, index_path) for the ledger."""
    base = Path(ledger_dir or _LEDGER_DIR).resolve()
    return base, base / _LEDGER_FILE, base / _INDEX_FILE


def _config_path(config_dir: Optional[str] = None) -> Path:
    """Return the path to the config file."""
    return Path(config_dir or _LEDGER_DIR).resolve() / _CONFIG_FILE


def _load_config(config_dir: Optional[str] = None) -> Dict[str, Any]:
    """Load .aiir/config.json, creating it with a fresh instance_id if absent."""
    dir_path = Path(config_dir or _LEDGER_DIR).resolve()
    cfg_path = dir_path / _CONFIG_FILE
    if cfg_path.is_file():
        try:
            data = json.loads(cfg_path.read_text(encoding="utf-8"))
            if isinstance(data, dict) and isinstance(data.get("instance_id"), str):
                return data
        except (json.JSONDecodeError, OSError):
            pass
    # Generate a new config with a stable instance_id.
    config: Dict[str, Any] = {
        "instance_id": str(uuid.uuid4()),
        "created": _now_rfc3339(),
    }
    dir_path.mkdir(parents=True, exist_ok=True)
    _save_config(cfg_path, config)
    return config


def _save_config(cfg_path: Path, config: Dict[str, Any]) -> None:
    """Atomically write the config file."""
    tmp = cfg_path.with_suffix(".tmp")
    fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    if _HAS_FCHMOD:
        os.fchmod(fd, 0o644)
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(config, f, indent=2, ensure_ascii=False)
        f.write("\n")
    os.replace(str(tmp), str(cfg_path))


def _load_index(index_path: Path) -> Dict[str, Any]:
    """Load the ledger index, or return a fresh skeleton."""
    if index_path.is_file():
        try:
            data = json.loads(index_path.read_text(encoding="utf-8"))
            if isinstance(data, dict) and data.get("version") == 1:
                return data
        except (json.JSONDecodeError, OSError):
            pass
    return {
        "version": 1,
        "receipt_count": 0,
        "ai_commit_count": 0,
        "ai_percentage": 0.0,
        "first_receipt": None,
        "latest_timestamp": None,
        "unique_authors": 0,
        "commits": {},
    }


def _save_index(index_path: Path, index: Dict[str, Any]) -> None:
    """Atomically write the ledger index."""
    tmp = index_path.with_suffix(".tmp")
    fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    if _HAS_FCHMOD:
        os.fchmod(fd, 0o644)
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        json.dump(index, f, indent=2, ensure_ascii=False)
        f.write("\n")
    os.replace(str(tmp), str(index_path))


def append_to_ledger(
    receipts: List[Dict[str, Any]],
    ledger_dir: Optional[str] = None,
) -> Tuple[int, int, str]:
    """Append receipts to the JSONL ledger, skipping duplicates.

    Returns (appended_count, skipped_count, ledger_path_str).
    """
    dir_path, ledger_path, index_path = _ledger_paths(ledger_dir)

    # Path-traversal guard — same logic as write_receipt.
    cwd_resolved = Path(os.getcwd()).resolve()
    try:
        dir_path.relative_to(cwd_resolved)
    except ValueError:
        raise ValueError(
            f"ledger dir must be within the working directory: "
            f"{dir_path} resolves outside {cwd_resolved}"
        )
    dir_path.mkdir(parents=True, exist_ok=True)
    # Re-verify after mkdir (symlink TOCTOU defence).
    real_dir = dir_path.resolve()
    try:
        real_dir.relative_to(cwd_resolved)
    except ValueError:
        raise ValueError(
            "ledger dir escaped working directory after creation "
            "(possible symlink attack)"
        )

    index = _load_index(index_path)
    known_commits: Dict[str, Any] = index.get("commits", {})

    appended = 0
    skipped = 0

    # Append new receipts — open once, write all, then flush.
    fd = os.open(
        str(ledger_path),
        os.O_WRONLY | os.O_CREAT | os.O_APPEND,
        0o644,
    )
    if _HAS_FCHMOD:
        os.fchmod(fd, 0o644)
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        for receipt in receipts:
            sha = receipt.get("commit", {}).get("sha", "")
            if not sha:
                continue

            # Dedup: skip if this commit SHA is already in the index.
            if sha in known_commits:
                skipped += 1
                continue

            line = _canonical_json(receipt)
            f.write(line + "\n")
            appended += 1

            is_ai = receipt.get("ai_attestation", {}).get("is_ai_authored", False)
            rid = receipt.get("receipt_id", "")
            ts = receipt.get("timestamp", "")

            author_email = receipt.get("commit", {}).get("author", {}).get("email", "")
            known_commits[sha] = {
                "receipt_id": rid,
                "ai": is_ai,
                "author": author_email,
                "line": index.get("receipt_count", 0) + appended,
            }
            if is_ai:
                index["ai_commit_count"] = index.get("ai_commit_count", 0) + 1
            if index.get("first_receipt") is None:
                index["first_receipt"] = ts
            index["latest_timestamp"] = ts

    index["receipt_count"] = index.get("receipt_count", 0) + appended
    index["commits"] = known_commits
    # Compute derived stats.
    authors = {v.get("author", "") for v in known_commits.values() if isinstance(v, dict) and v.get("author")}
    index["unique_authors"] = len(authors)
    total = index["receipt_count"]
    index["ai_percentage"] = round(index.get("ai_commit_count", 0) / total * 100, 1) if total > 0 else 0.0
    _save_index(index_path, index)

    return appended, skipped, str(ledger_path)


def export_ledger(
    ledger_dir: Optional[str] = None,
) -> Dict[str, Any]:
    """Bundle the .aiir/ ledger into a portable JSON export.

    Returns a dict suitable for serialization.  The format is designed so
    that managed services can ingest it in a single upload.
    """
    dir_path, ledger_path, index_path = _ledger_paths(ledger_dir)

    # Load existing data.
    index = _load_index(index_path)
    config = _load_config(str(dir_path))
    receipts: List[Dict[str, Any]] = []
    if ledger_path.is_file():
        for line in ledger_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line:
                try:
                    receipts.append(json.loads(line))
                except json.JSONDecodeError:
                    continue

    return {
        "format": "aiir.export.v1",
        "exported_at": _now_rfc3339(),
        "instance_id": config.get("instance_id"),
        "namespace": config.get("namespace"),
        "index": index,
        "receipts": receipts,
    }


# ---------------------------------------------------------------------------
# Badge, stats, and policy check helpers
# ---------------------------------------------------------------------------


def format_badge(
    index: Dict[str, Any],
    namespace: Optional[str] = None,
) -> Dict[str, str]:
    """Generate a shields.io badge URL and Markdown snippet from ledger stats.

    Returns a dict with keys: url, markdown, text.
    """
    total = index.get("receipt_count", 0)
    ai_pct = index.get("ai_percentage", 0.0)
    # URL-encode the percent sign for shields.io.
    label = "AI Transparency"
    value = f"{ai_pct}%25 AI" if total > 0 else "no receipts"
    color = "blue" if total > 0 else "lightgrey"
    # shields.io static badge: label-value-color
    # Spaces → underscores, hyphens → double-dash per shields.io spec.
    safe_label = label.replace("-", "--").replace(" ", "_")
    safe_value = value.replace("-", "--").replace(" ", "_")
    url = f"https://img.shields.io/badge/{safe_label}-{safe_value}-{color}"
    md = f"[![{label}]({url})](https://github.com/invariant-systems-ai/aiir)"
    text = f"{label}: {ai_pct}% AI ({total} receipts)"
    return {"url": url, "markdown": md, "text": text}


def format_stats(
    index: Dict[str, Any],
    config: Optional[Dict[str, Any]] = None,
) -> str:
    """Format a human-readable stats dashboard from the ledger index."""
    total = index.get("receipt_count", 0)
    ai_count = index.get("ai_commit_count", 0)
    ai_pct = index.get("ai_percentage", 0.0)
    authors = index.get("unique_authors", 0)
    first = index.get("first_receipt") or "—"
    latest = index.get("latest_timestamp") or "—"
    # Truncate timestamps to date for readability.
    if first and "T" in str(first):
        first = str(first).split("T")[0]
    if latest and "T" in str(latest):
        latest = str(latest).split("T")[0]
    ns = ""
    if config and config.get("namespace"):
        ns = f"  Namespace: {config['namespace']}\n"
    iid = ""
    if config and config.get("instance_id"):
        iid = f"  Instance:  {config['instance_id'][:8]}…\n"
    bar = "─" * 44 if _USE_BOXDRAW else "-" * 44
    return (
        f"{bar}\n"
        f"  AIIR Ledger — {total} receipt{'s' if total != 1 else ''}"
        f", {authors} author{'s' if authors != 1 else ''}\n"
        f"{bar}\n"
        f"  AI-authored:  {ai_count} commit{'s' if ai_count != 1 else ''}"
        f" ({ai_pct}%)\n"
        f"  First:        {first}\n"
        f"  Latest:       {latest}\n"
        f"{ns}"
        f"{iid}"
        f"{bar}"
    )


def check_policy(
    index: Dict[str, Any],
    *,
    max_ai_percent: Optional[float] = None,
) -> Tuple[bool, str]:
    """Evaluate policy gates against ledger stats.

    Returns (passed, message).  `passed` is True when all gates pass.
    """
    total = index.get("receipt_count", 0)
    if total == 0:
        return False, "No receipts found — run 'aiir' first to generate receipts."

    ai_pct = index.get("ai_percentage", 0.0)

    if max_ai_percent is not None:
        if ai_pct > max_ai_percent:
            return (
                False,
                f"FAIL: AI percentage {ai_pct}% exceeds threshold {max_ai_percent}%"
                f" ({index.get('ai_commit_count', 0)}/{total} commits)",
            )
        return (
            True,
            f"PASS: AI percentage {ai_pct}% within threshold {max_ai_percent}%"
            f" ({index.get('ai_commit_count', 0)}/{total} commits)",
        )

    return True, f"OK: {total} receipts, {ai_pct}% AI"


# ---------------------------------------------------------------------------
# GitHub Actions integration helpers
# ---------------------------------------------------------------------------


def set_github_output(key: str, value: str) -> None:
    """Set a GitHub Actions output variable."""
    # Reject newlines, '=', control chars, and '<<' in keys
    if not key or any(c in key for c in '\n\r=') or any(ord(c) < 0x20 for c in key) or '<<' in key:
        raise ValueError(
            f"Invalid GitHub output key (contains forbidden characters): {key!r}"
        )
    # Cap value length — GitHub Actions limits GITHUB_OUTPUT to 8 MB total.
    _MAX_OUTPUT_VALUE_SIZE = 4 * 1024 * 1024  # 4 MB
    if len(value.encode("utf-8", errors="replace")) > _MAX_OUTPUT_VALUE_SIZE:
        raise ValueError(
            f"GitHub output value too large ({len(value)} chars, max {_MAX_OUTPUT_VALUE_SIZE})"
        )
    output_file = os.environ.get("GITHUB_OUTPUT")
    if output_file:
        with open(output_file, "a", encoding="utf-8") as f:
            # Use heredoc for values with control chars or '<<'
            needs_heredoc = (
                "\n" in value or "\r" in value
                or any(ord(c) < 0x20 for c in value)
                or "<<" in value
            )
            if needs_heredoc:
                delimiter = f"ghadelimiter_{uuid.uuid4()}"
                f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
            else:
                f.write(f"{key}={value}\n")


# GitHub Actions step summary size limit (1 MB matches GitHub's own limit).
MAX_SUMMARY_SIZE = 1024 * 1024


def set_github_summary(markdown: str) -> None:
    """Append to the GitHub Actions step summary."""
    # Byte-aware truncation for multi-byte safety.
    md_bytes = markdown.encode("utf-8", errors="replace")
    if len(md_bytes) > MAX_SUMMARY_SIZE:
        _SUFFIX = b"\n\n*(truncated \xe2\x80\x94 exceeded 1 MB limit)*"
        budget = MAX_SUMMARY_SIZE - len(_SUFFIX)
        markdown = md_bytes[:budget].decode("utf-8", errors="ignore") + _SUFFIX.decode("utf-8")
    summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
    if summary_file:
        with open(summary_file, "a", encoding="utf-8") as f:
            f.write(markdown + "\n")


def format_github_summary(receipts: List[Dict[str, Any]]) -> str:
    """Format receipts as a GitHub Actions step summary (Markdown table)."""
    # Guard ai_attestation type in header count (same as loop body).
    ai_count = sum(
        1
        for r in receipts
        if isinstance(r.get("ai_attestation"), dict)
        and r["ai_attestation"].get("is_ai_authored")
    )
    total = len(receipts)

    lines = [
        "## 🔐 AIIR Receipt Summary",
        "",
        f"**{total}** commit{'s' if total != 1 else ''} receipted"
        + (f" · **{ai_count}** AI-authored" if ai_count else ""),
        "",
        "| Commit | Subject | AI | Receipt ID |",
        "|--------|---------|-----|-----------|",
    ]

    for r in receipts:
        commit = r.get("commit", {})
        ai = r.get("ai_attestation", {})
        # Guard nested field types (same pattern as format_receipt_pretty).
        if not isinstance(commit, dict):
            commit = {}
        if not isinstance(ai, dict):
            ai = {}
        # Sanitize ALL user-controlled fields in the markdown table,
        # not just subject. A tampered receipt could have backticks/pipes in
        # sha or receipt_id, breaking the table or injecting content.
        sha_short = _sanitize_md(commit.get("sha", "")[:8])
        # Sanitize subject to prevent markdown/HTML injection
        subject = _sanitize_md(commit.get("subject", "")[:50])
        ai_flag = "🤖" if ai.get("is_ai_authored") else ""
        rid = _sanitize_md(r.get("receipt_id", "")[:16]) + "…"
        # Use double-backtick delimiters for code spans. In GFM,
        # backslash-escaped backticks inside single-backtick spans are literal
        # (not escapes), so a backtick in sha_short would break the code span.
        # Double-backtick delimiters with spaces handle embedded backticks safely.
        lines.append(f"| `` {sha_short} `` | {subject} | {ai_flag} | `` {rid} `` |")

    lines.extend(
        [
            "",
            f"*Generated by aiir v{CLI_VERSION}*",
        ]
    )
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Receipt verification
# ---------------------------------------------------------------------------


def verify_receipt(receipt: Dict[str, Any]) -> Dict[str, Any]:
    """Verify a receipt's content-addressed integrity.

    Recomputes content_hash and receipt_id from the receipt core and checks
    they match the stored values. Returns a dict with verification results.
    """
    # Guard against non-dict input (e.g., null entries in a receipt array).
    if not isinstance(receipt, dict):
        return {"valid": False, "errors": ["receipt is not a dict"]}

    # Validate receipt type and schema before proceeding.
    # A receipt from a different tool or an unknown schema version should
    # not silently pass verification — it could be misattributed to AIIR.
    errors = []
    rtype = receipt.get("type")
    if rtype != "aiir.commit_receipt":
        errors.append(f"unknown receipt type: {rtype!r}")
    schema = receipt.get("schema", "")
    if not isinstance(schema, str) or not schema.startswith("aiir/"):
        errors.append(f"unknown schema: {schema!r}")
    # Validate version field — reject non-string or strings with
    # HTML/control chars that could be rendered unsafely in downstream contexts.
    version = receipt.get("version")
    if not isinstance(version, str) or not re.match(r'^[0-9a-zA-Z.+-]+$', version):
        errors.append(f"invalid version format: {version!r}")
    if errors:
        return {"valid": False, "errors": errors}

    # Use explicit allowlist (not denylist) for forward-compatibility.
    # New derived fields in future versions won't accidentally enter the core.
    CORE_KEYS = {"type", "schema", "version", "commit", "ai_attestation", "provenance"}
    receipt_core = {k: v for k, v in receipt.items() if k in CORE_KEYS}

    # Guard against deeply nested JSON that would blow the call stack.
    # _canonical_json now raises ValueError for depth > 64.
    try:
        core_json = _canonical_json(receipt_core)
    except (RecursionError, ValueError):
        return {"valid": False, "errors": ["receipt structure too deeply nested"]}
    expected_hash = "sha256:" + _sha256(core_json)
    expected_id = f"g1-{_sha256(core_json)[:32]}"

    stored_hash = receipt.get("content_hash", "")
    stored_id = receipt.get("receipt_id", "")

    # Constant-time comparison to prevent timing side-channel attacks.
    # hmac.compare_digest requires ASCII strings or bytes — encode to bytes
    # to safely handle any input (non-ASCII stored values are always invalid).
    try:
        hash_ok = hmac.compare_digest(
            stored_hash.encode("utf-8"), expected_hash.encode("utf-8")
        )
        id_ok = hmac.compare_digest(
            stored_id.encode("utf-8"), expected_id.encode("utf-8")
        )
    except (AttributeError, UnicodeDecodeError):
        hash_ok = False
        id_ok = False

    valid = hash_ok and id_ok
    _commit_field = receipt.get("commit")
    result: Dict[str, Any] = {
        "valid": valid,
        "receipt_id": stored_id,
        "content_hash_match": hash_ok,
        "receipt_id_match": id_ok,
        "commit_sha": _commit_field.get("sha", "unknown") if isinstance(_commit_field, dict) else "unknown",
        "errors": [],
    }
    if not hash_ok:
        result["errors"].append("content hash mismatch")
    if not id_ok:
        result["errors"].append("receipt_id mismatch")
    # Only expose expected hashes on valid receipts — on failure they
    # would be a forgery oracle.
    if valid:
        result["expected_content_hash"] = expected_hash
        result["expected_receipt_id"] = expected_id
    return result


# Safety limit: max file size for receipt verification (50 MB)
MAX_RECEIPT_FILE_SIZE = 50 * 1024 * 1024


def verify_receipt_file(filepath: str) -> Dict[str, Any]:
    """Load and verify a receipt JSON file."""
    path = Path(filepath)
    if not path.exists():
        return {"valid": False, "error": f"File not found: {filepath}"}
    # Reject symlinks to prevent probing arbitrary files
    if path.is_symlink():
        return {
            "valid": False,
            "error": f"Receipt file is a symlink (refusing to verify): {filepath}",
        }
    # Reject oversized files to prevent memory exhaustion
    try:
        file_size = path.stat().st_size
    except OSError as e:
        return {"valid": False, "error": f"Cannot stat file: {e}"}
    if file_size > MAX_RECEIPT_FILE_SIZE:
        return {
            "valid": False,
            "error": f"File too large ({file_size} bytes, max {MAX_RECEIPT_FILE_SIZE})",
        }
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as e:
        return {"valid": False, "error": f"Invalid JSON: {e}"}

    # Handle single receipt or array of receipts
    if isinstance(data, list):
        # Cap array size to prevent quadratic verification DoS
        if len(data) > MAX_RECEIPTS_PER_RANGE:
            return {
                "valid": False,
                "error": f"Receipt array too large ({len(data)} items, max {MAX_RECEIPTS_PER_RANGE})",
            }
        results = [verify_receipt(r) for r in data]
        all_valid = all(r["valid"] for r in results)
        return {"valid": all_valid, "receipts": results, "count": len(results)}
    elif isinstance(data, dict):
        return verify_receipt(data)
    else:
        return {"valid": False, "error": "Expected JSON object or array"}


# ---------------------------------------------------------------------------
# Sigstore signing (optional — requires `pip install sigstore`)
# ---------------------------------------------------------------------------


def _sigstore_available() -> bool:
    """Check if the sigstore package is available for import."""
    try:
        import sigstore  # noqa: F401

        return True
    except ImportError:
        return False


def sign_receipt(receipt_json_bytes: bytes) -> str:
    """Sign receipt bytes using Sigstore keyless signing.

    Uses ambient OIDC credentials in CI (GitHub Actions, GitLab CI, etc.)
    or falls back to interactive browser-based OIDC flow for local use.

    Returns the Sigstore bundle as a JSON string.
    """
    try:
        from sigstore.models import ClientTrustConfig
        from sigstore.oidc import IdentityToken, Issuer, detect_credential
        from sigstore.sign import SigningContext
    except ImportError:
        raise RuntimeError(
            "Sigstore signing requires the 'sigstore' package.\n"
            "Install with: pip install sigstore"
        )

    # Try ambient credential first (GitHub Actions, GitLab CI, etc.)
    raw_token = detect_credential()
    if raw_token is not None:
        identity_token = IdentityToken(raw_token)
    else:
        # Fall back to interactive OIDC flow (opens browser)
        issuer = Issuer.production()
        identity_token = issuer.identity_token()

    config = ClientTrustConfig.production()
    ctx = SigningContext.from_trust_config(config)
    with ctx.signer(identity_token) as signer:
        bundle = signer.sign_artifact(receipt_json_bytes)
    return bundle.to_json()


def sign_receipt_file(receipt_path: str) -> str:
    """Sign a receipt file and write the Sigstore bundle alongside it.

    Given 'receipt_abc.json', writes 'receipt_abc.json.sigstore'.
    Returns the bundle file path.
    """
    path = Path(receipt_path)
    if not path.exists():
        raise FileNotFoundError(f"Receipt file not found: {receipt_path}")

    # Reject symlinks to prevent signing arbitrary files (info leak)
    if path.is_symlink():
        raise ValueError(
            f"Receipt file is a symlink (refusing to sign): {receipt_path}"
        )

    # Cap file size before loading into memory.
    # verify_receipt_file has a 50 MB cap but sign_receipt_file did not,
    # allowing a 1 GB file to be loaded entirely into RAM.
    try:
        file_size = path.stat().st_size
    except OSError as e:
        raise ValueError(f"Cannot stat receipt file: {e}") from e
    if file_size > MAX_RECEIPT_FILE_SIZE:
        raise ValueError(
            f"Receipt file too large for signing ({file_size} bytes, max {MAX_RECEIPT_FILE_SIZE})"
        )

    receipt_bytes = path.read_bytes()

    # Validate content is JSON before sending to Sigstore
    try:
        json.loads(receipt_bytes)
    except (json.JSONDecodeError, UnicodeDecodeError):
        raise ValueError(
            f"Receipt file is not valid JSON (refusing to sign): {receipt_path}"
        )

    bundle_json = sign_receipt(receipt_bytes)

    bundle_path = str(path) + ".sigstore"
    fd = os.open(bundle_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
    if _HAS_FCHMOD:
        os.fchmod(fd, 0o644)  # Force permissions regardless of umask
    with os.fdopen(fd, "w", encoding="utf-8") as f:
        f.write(bundle_json)
    return bundle_path


def verify_receipt_signature(
    receipt_path: str,
    bundle_path: Optional[str] = None,
    expected_identity: Optional[str] = None,
    expected_issuer: Optional[str] = None,
) -> Dict[str, Any]:
    """Verify a receipt's Sigstore signature bundle.

    Args:
        receipt_path: Path to the receipt JSON file.
        bundle_path: Path to the .sigstore bundle. If None, looks for
            <receipt_path>.sigstore.
        expected_identity: Expected signer identity (email or OIDC subject).
            If None, accepts any signer (UnsafeNoOp policy).
        expected_issuer: Expected OIDC issuer URL.

    Returns a dict with verification results.
    """
    rpath = Path(receipt_path)
    if not rpath.exists():
        return {"valid": False, "error": f"Receipt not found: {receipt_path}"}
    # Reject symlinks to prevent probing arbitrary files
    if rpath.is_symlink():
        return {
            "valid": False,
            "error": f"Receipt file is a symlink (refusing to verify): {receipt_path}",
        }

    if bundle_path is None:
        bundle_path = str(rpath) + ".sigstore"
    bpath = Path(bundle_path)
    if not bpath.exists():
        return {
            "valid": False,
            "error": f"Sigstore bundle not found: {bundle_path}",
        }
    # Reject symlinks for bundle path as well
    if bpath.is_symlink():
        return {
            "valid": False,
            "error": f"Bundle file is a symlink (refusing to verify): {bundle_path}",
        }

    # Reject oversized files to prevent memory exhaustion
    for fpath, label in [(rpath, "Receipt"), (bpath, "Bundle")]:
        try:
            fsize = fpath.stat().st_size
        except OSError as e:
            return {"valid": False, "error": f"Cannot stat {label.lower()}: {e}"}
        if fsize > MAX_RECEIPT_FILE_SIZE:
            return {
                "valid": False,
                "error": f"{label} too large ({fsize} bytes, max {MAX_RECEIPT_FILE_SIZE})",
            }

    try:
        from sigstore.models import Bundle
        from sigstore.verify import Verifier
        from sigstore.verify.policy import Identity, UnsafeNoOp
    except ImportError:
        raise RuntimeError(
            "Sigstore verification requires the 'sigstore' package.\n"
            "Install with: pip install sigstore"
        )

    try:
        receipt_bytes = rpath.read_bytes()
        bundle = Bundle.from_json(bpath.read_text(encoding="utf-8"))
        verifier = Verifier.production()

        if expected_identity:
            policy = Identity(
                identity=expected_identity,
                issuer=expected_issuer,
            )
        else:
            policy = UnsafeNoOp()

        verifier.verify_artifact(receipt_bytes, bundle, policy)

        return {
            "valid": True,
            "signature_valid": True,
            "receipt_path": str(receipt_path),
            "bundle_path": str(bundle_path),
            "policy": "identity" if expected_identity else "any",
        }
    except Exception as e:
        # Sanitize error to prevent leaking internal paths or OIDC details
        error_msg = str(e)
        # Strip potential file paths and tokens from error messages
        safe_error = error_msg.split("\n")[0][:200]  # First line, capped
        # Redact filesystem paths — previously missed here, unlike
        # _run_git and _sanitize_error which both apply this regex.
        safe_error = re.sub(r'/[\w./-]{5,}', '<path>', safe_error)
        return {
            "valid": False,
            "signature_valid": False,
            "error": safe_error,
        }


# ---------------------------------------------------------------------------
# CLI main
# ---------------------------------------------------------------------------


# Subclass ArgumentParser to show did-you-mean suggestions when
# the user passes an unrecognised flag (e.g. --prettty → did you mean --pretty?).
class _FriendlyParser(argparse.ArgumentParser):
    """ArgumentParser that suggests close matches for unrecognised flags."""

    def error(self, message: str) -> NoReturn:  # noqa: D401 (argparse override)
        if "unrecognized arguments:" in message:
            bad = message.split("unrecognized arguments:")[-1].strip().split()
            # Sanitise user tokens before echoing to stderr
            safe_bad = " ".join(_strip_terminal_escapes(t) for t in bad)
            known = [a.option_strings for a in self._actions if a.option_strings]
            flat = [f for opts in known for f in opts]
            hints = []
            for token in bad:
                close = difflib.get_close_matches(token, flat, n=1, cutoff=0.6)
                if close:
                    hints.append(f"  {_e('hint')} Did you mean {close[0]} ?")
            if hints:
                self.exit(
                    2,
                    f"{_e('error')} Unknown flag: {safe_bad}\n"
                    + "\n".join(hints) + "\n",
                )
            # Always use friendly format, no fallthrough
            self.exit(2, f"{_e('error')} Unknown flag: {safe_bad}\n")
        # Fall through for all other argparse errors (including
        # mutually-exclusive group violations) — keep default behaviour.
        super().error(message)


def main(argv: Optional[Sequence[str]] = None) -> int:
    """CLI entry point."""
    parser = _FriendlyParser(
        prog="aiir",
        description="AIIR - AI Integrity Receipts. Generate cryptographic receipts for git commits.",
        epilog=(
            "examples:\n"
            "  aiir                        Receipt HEAD → .aiir/receipts.jsonl\n"
            "  aiir --pretty               Same, plus human-readable summary\n"
            "  aiir -r origin/main..HEAD   Receipt every commit in a range\n"
            "  aiir --ai-only              Only AI-authored commits\n"
            "  aiir --json                 Print JSON to stdout (for piping)\n"
            "  aiir -o .receipts           Individual receipt files in a dir\n"
            "  aiir --verify receipt.json   Verify a receipt's integrity\n"
            "  aiir --sign -o .receipts    Sign receipt with Sigstore\n"
            "\n"
            "default: receipts are appended to .aiir/receipts.jsonl and\n"
            "auto-indexed in .aiir/index.json (one entry per commit SHA,\n"
            "duplicates skipped).  Add .aiir/ to your repo for an audit trail.\n"
            "\n"
            "exit codes:\n"
            "  0  Success (receipts generated, or verification passed)\n"
            "  1  Error (verification failed, git error, bad input)\n"
            "\n"
            "https://github.com/invariant-systems-ai/aiir  |  "
            "https://invariantsystems.io"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--commit",
        "-c",
        default=None,
        help="Specific commit SHA to receipt (default: HEAD)",
    )
    parser.add_argument(
        "--range",
        "-r",
        default=None,
        dest="range_spec",
        help="Commit range to receipt (e.g., 'origin/main..HEAD')",
    )
    parser.add_argument(
        "--ai-only",
        action="store_true",
        help="Only generate receipts for AI-authored commits",
    )
    parser.add_argument(
        "--output",
        "-o",
        default=None,
        help="Output directory for individual receipt JSON files (for CI)",
    )
    parser.add_argument(
        "--ledger",
        "-l",
        nargs="?",
        const=_LEDGER_DIR,
        default=None,
        help=(
            "Append receipts to a JSONL ledger (default: .aiir/receipts.jsonl). "
            "Duplicates are auto-skipped via .aiir/index.json. "
            "This is the default when no output flags are given."
        ),
    )
    parser.add_argument(
        "--json",
        action="store_true",
        dest="json_stdout",
        help="Print raw JSON to stdout instead of writing to ledger (for piping)",
    )
    parser.add_argument(
        "--jsonl",
        action="store_true",
        help="Output as JSON Lines to stdout (one receipt per line)",
    )
    parser.add_argument(
        "--pretty",
        action="store_true",
        help="Print human-readable summary (combines with ledger by default)",
    )
    parser.add_argument(
        "--github-action",
        action="store_true",
        help="Run in GitHub Actions mode (set outputs + step summary)",
    )
    # --quiet and --verbose are mutually exclusive.
    verbosity_group = parser.add_mutually_exclusive_group()
    verbosity_group.add_argument(
        "--quiet",
        "-q",
        action="store_true",
        help="Suppress non-essential output",
    )
    verbosity_group.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Enable debug logging (git commands, timing, signal evaluation)",
    )
    parser.add_argument(
        "--version",
        "-V",
        action="version",
        version=f"aiir {CLI_VERSION}",
    )
    parser.add_argument(
        "--verify",
        default=None,
        metavar="FILE",
        help="Verify a receipt JSON file's content-addressed integrity",
    )
    parser.add_argument(
        "--sign",
        action="store_true",
        help="Sign receipts using Sigstore keyless signing (requires pip install sigstore)",
    )
    parser.add_argument(
        "--verify-signature",
        action="store_true",
        help="Also verify Sigstore signature when using --verify (looks for .sigstore bundle)",
    )
    parser.add_argument(
        "--signer-identity",
        default=None,
        metavar="IDENTITY",
        help="Expected signer identity for --verify-signature (email or OIDC subject)",
    )
    parser.add_argument(
        "--signer-issuer",
        default=None,
        metavar="ISSUER",
        help="Expected OIDC issuer URL for --verify-signature",
    )
    parser.add_argument(
        "--redact-files",
        action="store_true",
        help="Omit individual file paths from receipts (privacy; mitigates I-05 file enumeration)",
    )
    parser.add_argument(
        "--namespace",
        default=None,
        metavar="NS",
        help="Tag receipts with an organization namespace (e.g., 'acme-corp')",
    )
    parser.add_argument(
        "--export",
        nargs="?",
        const="aiir-export.json",
        default=None,
        metavar="FILE",
        help="Export .aiir/ ledger as a portable JSON bundle (for backup or import)",
    )
    parser.add_argument(
        "--badge",
        action="store_true",
        help="Print a shields.io badge Markdown snippet from ledger stats",
    )
    parser.add_argument(
        "--stats",
        action="store_true",
        help="Print a summary dashboard of ledger statistics",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="Run policy checks against ledger stats (for CI gates)",
    )
    parser.add_argument(
        "--max-ai-percent",
        type=float,
        default=None,
        metavar="N",
        help="Policy gate: fail if AI-authored percentage exceeds N (use with --check)",
    )

    args = parser.parse_args(argv)

    # Configure logging based on --verbose flag or AIIR_LOG_LEVEL env.
    log_level = os.environ.get("AIIR_LOG_LEVEL", "DEBUG" if args.verbose else "WARNING")
    logging.basicConfig(
        level=getattr(logging, log_level.upper(), logging.WARNING),
        format="%(name)s %(levelname)s: %(message)s",
        stream=sys.stderr,
    )

    # --- Verify mode (no git repo needed) ---
    if args.verify:
        result = verify_receipt_file(args.verify)
        if result.get("valid"):
            count = result.get("count", 1)
            print(
                f"{_e('ok')} All good! {count} receipt{'s' if count != 1 else ''} verified -- integrity intact.",
                file=sys.stderr,
            )
            if "receipts" in result:
                for r in result["receipts"]:
                    print(
                        f"   {r['receipt_id'][:24]}... "
                        f"commit={r['commit_sha'][:12]} {_e('check')}",
                        file=sys.stderr,
                    )
            else:
                print(
                    f"   {result['receipt_id'][:24]}... "
                    f"commit={result['commit_sha'][:12]} {_e('check')}",
                    file=sys.stderr,
                )

            # Sigstore signature verification (optional)
            if args.verify_signature:
                sig_result = verify_receipt_signature(
                    args.verify,
                    expected_identity=args.signer_identity,
                    expected_issuer=args.signer_issuer,
                )
                result["signature"] = sig_result
                if sig_result.get("valid"):
                    policy = sig_result.get("policy", "any")
                    print(
                        f"{_e('ok')} Signature verified (policy={policy})",
                        file=sys.stderr,
                    )
                else:
                    sig_err = sig_result.get("error", "Unknown signature error")
                    print(
                        f"{_e('error')} Signature FAILED: {sig_err}",
                        file=sys.stderr,
                    )
                    print(json.dumps(result, indent=2))
                    return 1

            print(json.dumps(result, indent=2))
            return 0
        else:
            # When an array of receipts has failures, show
            # which specific receipts failed instead of a generic message.
            if "receipts" in result:
                failed = [r for r in result["receipts"] if not r.get("valid")]
                total = result.get("count", len(result["receipts"]))
                print(
                    f"{_e('error')} Verification failed: {len(failed)} of {total}"
                    f" receipt{'s' if total != 1 else ''} invalid.",
                    file=sys.stderr,
                )
                for r in failed[:5]:
                    rid = str(r.get("receipt_id", "unknown"))[:24]
                    errs = r.get("errors", [])
                    err_msg = errs[0] if errs else "content hash mismatch"
                    print(f"   * {rid}... -- {err_msg}", file=sys.stderr)
            else:
                error = result.get("error", "Content hash mismatch -- receipt may be tampered")
                print(f"{_e('error')} Verification failed: {error}", file=sys.stderr)
            # Context-aware verify tips — "receipt was changed" is
            # misleading for file-not-found and parse errors.  Show a tip that
            # matches the actual failure category.
            error_str = str(result.get("error", ""))
            if "File not found" in error_str:
                print(
                    f"   {_e('hint')} Check the file path and try again.",
                    file=sys.stderr,
                )
            elif "Invalid JSON" in error_str or "not a dict" in error_str:
                print(
                    f"   {_e('hint')} The file doesn't look like an aiir receipt."
                    " Receipts are JSON objects (or arrays of objects).",
                    file=sys.stderr,
                )
            elif "symlink" in error_str:
                print(
                    f"   {_e('hint')} Point --verify at the actual file, not a symlink.",
                    file=sys.stderr,
                )
            elif "too large" in error_str:
                print(
                    f"   {_e('hint')} The file exceeds the size limit."
                    " Is this the right file?",
                    file=sys.stderr,
                )
            else:
                print(
                    f"   {_e('hint')} This means the receipt was changed after it was created,"
                    " or it wasn't generated by aiir.",
                    file=sys.stderr,
                )
            print(json.dumps(result, indent=2))
            return 1

    # --- Export mode (no git repo needed) ---
    if args.export is not None:
        # Guard: fail early if no ledger exists (matches --badge / --stats).
        try:
            _idx = _load_index(_ledger_paths()[2])
        except OSError:
            _idx = {}
        if _idx.get("receipt_count", 0) == 0:
            print(
                f"{_e('error')} No ledger found — run 'aiir' first to generate receipts.",
                file=sys.stderr,
            )
            return 1
        try:
            bundle = export_ledger()
        except Exception as e:
            print(f"{_e('error')} Export failed: {e}", file=sys.stderr)
            return 1
        export_path = args.export
        # Validate export path — reject path traversal.
        if ".." in export_path or export_path.startswith("/"):
            print(f"{_e('error')} Export path must be relative and within the project.", file=sys.stderr)
            return 1
        out = Path(export_path)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(
            json.dumps(bundle, indent=2, ensure_ascii=False) + "\n",
            encoding="utf-8",
        )
        n = len(bundle.get("receipts", []))
        iid = bundle.get("instance_id", "none")[:8]
        print(
            f"{_e('ok')} Exported {n} receipt{'s' if n != 1 else ''} → {export_path}  (instance: {iid}…)",
            file=sys.stderr,
        )
        return 0

    # --- Badge mode (no git repo needed) ---
    if args.badge:
        try:
            idx = _load_index(_ledger_paths()[2])
        except OSError:
            idx = {}
        if idx.get("receipt_count", 0) == 0:
            print(f"{_e('error')} No ledger found — run 'aiir' first to generate receipts.", file=sys.stderr)
            return 1
        cfg = {}
        try:
            cfg = _load_config()
        except OSError:
            pass
        badge = format_badge(idx, namespace=cfg.get("namespace"))
        print(badge["markdown"])
        print(file=sys.stderr)
        print(f"   {_e('hint')} Paste into your README.md for an AI transparency badge.", file=sys.stderr)
        print(f"   URL: {badge['url']}", file=sys.stderr)
        return 0

    # --- Stats mode (no git repo needed) ---
    if args.stats:
        try:
            idx = _load_index(_ledger_paths()[2])
        except OSError:
            idx = {}
        if idx.get("receipt_count", 0) == 0:
            print(f"{_e('error')} No ledger found — run 'aiir' first to generate receipts.", file=sys.stderr)
            return 1
        cfg = {}
        try:
            cfg = _load_config()
        except OSError:
            pass
        print(format_stats(idx, config=cfg), file=sys.stderr)
        return 0

    # --- Check / policy gate mode (no git repo needed) ---
    if args.check or args.max_ai_percent is not None:
        try:
            idx = _load_index(_ledger_paths()[2])
        except OSError:
            idx = {}
        passed, message = check_policy(
            idx, max_ai_percent=args.max_ai_percent,
        )
        if passed:
            print(f"{_e('ok')} {message}", file=sys.stderr)
            return 0
        else:
            print(f"{_e('error')} {message}", file=sys.stderr)
            return 1

    # --- Receipt generation mode ---
    try:
        cwd = get_repo_root()
    except RuntimeError:
        print(f"{_e('error')} Not a git repository.", file=sys.stderr)
        print(
            f"   {_e('hint')} cd into your project first, or run 'git init' to start one.",
            file=sys.stderr,
        )
        return 1
    except FileNotFoundError:
        # Git binary not found on PATH
        print(f"{_e('error')} Can't find git!", file=sys.stderr)
        print(
            f"   {_e('hint')} Install it: https://git-scm.com/downloads",
            file=sys.stderr,
        )
        return 1
    except OSError as e:
        # Catch OS-level errors from get_repo_root() such as
        # permission denied on .git/HEAD, network mount dropped, or
        # corrupted working tree.  Without this the OSError produces an
        # unhandled traceback that leaks internal paths.
        print(f"{_e('error')} Something went wrong: {e}", file=sys.stderr)
        print(
            f"   {_e('hint')} Check that you have permission to read this repo.",
            file=sys.stderr,
        )
        return 1

    receipts: List[Dict[str, Any]] = []

    # Determine output mode early — config loading creates .aiir/ so
    # we only load it when the ledger (or explicit --namespace) is active.
    explicit_stdout = args.json_stdout or args.jsonl
    use_ledger = not explicit_stdout and not args.output and not args.github_action
    if args.ledger is not None:
        use_ledger = True

    # Load config for instance_id and namespace only when .aiir/ will be
    # used anyway (ledger mode) or the user explicitly set --namespace.
    instance_id: Optional[str] = None
    namespace: Optional[str] = getattr(args, "namespace", None)
    if use_ledger or namespace:
        try:
            config = _load_config()
        except OSError:
            config = {}
        instance_id = config.get("instance_id")
        namespace = namespace or config.get("namespace")
        # Persist namespace to config if set via CLI flag.
        if getattr(args, "namespace", None) and args.namespace != config.get("namespace"):
            config["namespace"] = args.namespace
            try:
                _save_config(_config_path(), config)
            except OSError:
                pass

    try:
        if args.range_spec:
            receipts = generate_receipts_for_range(
                args.range_spec, cwd=cwd, ai_only=args.ai_only,
                redact_files=args.redact_files,
                instance_id=instance_id, namespace=namespace,
            )
        else:
            commit_ref = args.commit or "HEAD"
            receipt = generate_receipt(
                commit_ref, cwd=cwd, ai_only=args.ai_only,
                redact_files=args.redact_files,
                instance_id=instance_id, namespace=namespace,
            )
            if receipt:
                receipts = [receipt]
    except (RuntimeError, ValueError) as e:
        # Detect empty repo (no commits) and show a friendly
        # message instead of leaking the raw git stderr.
        err_str = str(e)
        if "unknown revision" in err_str or "bad default revision" in err_str:
            print(f"{_e('error')} No commits yet.", file=sys.stderr)
            print(
                f"   {_e('hint')} Make your first commit, then run aiir again.",
                file=sys.stderr,
            )
        else:
            print(f"{_e('error')} {e}", file=sys.stderr)
        return 1
    except OSError as e:
        # Catch filesystem errors (e.g., network mount dropped,
        # permission denied on .git, corrupted working tree). Without this,
        # an OSError from subprocess or file I/O produces an unhandled
        # traceback that leaks internal paths.
        print(f"{_e('error')} {e}", file=sys.stderr)
        return 1
    except subprocess.TimeoutExpired:
        # Git subprocess timeout — surface a clean message instead of
        # an unhandled traceback that leaks internal paths and command strings.
        print(
            f"{_e('error')} Git took too long (>{GIT_TIMEOUT}s). Is the repo very large?",
            file=sys.stderr,
        )
        print(
            f"   {_e('hint')} Try a smaller commit range, or check your network connection.",
            file=sys.stderr,
        )
        return 1

    if not receipts:
        if not args.quiet:
            if args.ai_only:
                print(
                    f"{_e('shrug')} No AI-authored commits found -- --ai-only filtered everything out.",
                    file=sys.stderr,
                )
                print(
                    f"   {_e('hint')} Remove --ai-only to receipt all commits.",
                    file=sys.stderr,
                )
            else:
                print(f"{_e('shrug')} Nothing to receipt here.", file=sys.stderr)
                if args.range_spec:
                    # Sanitize range_spec — user input may
                    # contain terminal escape sequences.
                    print(
                        f"   {_e('hint')} The range may be empty -- check: "
                        "git log --oneline " + _strip_terminal_escapes(args.range_spec),
                        file=sys.stderr,
                    )
                else:
                    print(
                        f"   {_e('hint')} Make sure you have at least one commit: git log --oneline -1",
                        file=sys.stderr,
                    )
        if args.github_action:
            set_github_output("receipt_count", "0")
            set_github_output("ai_commit_count", "0")
        return 0

    # Validate signing requirements
    if args.sign and not args.output:
        print(
            f"{_e('error')} --sign needs --output too (bundles are saved as sidecar files).",
            file=sys.stderr,
        )
        print(
            f"   {_e('hint')} Try: aiir --sign --output .receipts",
            file=sys.stderr,
        )
        return 1
    if args.sign and not _sigstore_available():
        print(
            f"{_e('error')} Signing needs the 'sigstore' package.",
            file=sys.stderr,
        )
        print(
            f"   {_e('hint')} Install it: pip install sigstore",
            file=sys.stderr,
        )
        return 1

    # ── Determine output mode ──────────────────────────────────────────
    # (use_ledger was computed earlier to control config loading)
    # --pretty is orthogonal — it prints to stderr and combines with any mode.

    # ── Output ─────────────────────────────────────────────────────────
    signed_count = 0
    stdout_json_receipts: List[Dict[str, Any]] = []

    # Pretty-print (always goes to stderr so it can combine with any mode).
    if args.pretty:
        for receipt in receipts:
            print(format_receipt_pretty(receipt), file=sys.stderr)

    # Mode A: individual files (--output / --sign)
    if args.output or args.sign:
        for receipt in receipts:
            try:
                path = write_receipt(receipt, output_dir=args.output, jsonl=args.jsonl)
            except ValueError as e:
                print(f"{_e('error')} {e}", file=sys.stderr)
                print(
                    f"   {_e('hint')} Use a folder inside your project, e.g. --output .receipts",
                    file=sys.stderr,
                )
                return 1
            if args.sign and path and not path.startswith("stdout:"):
                try:
                    bundle_path = sign_receipt_file(path)
                    signed_count += 1
                    if not args.quiet:
                        print(
                            f"  {_e('signed')} Signed: {bundle_path}",
                            file=sys.stderr,
                        )
                except Exception as e:
                    err_msg = _strip_terminal_escapes(str(e))[:200]
                    print(f"{_e('error')} Signing failed: {err_msg}", file=sys.stderr)
                    print(
                        f"   {_e('hint')} Check your internet connection and try again.",
                        file=sys.stderr,
                    )
                    return 1

    # Mode B: stdout (--json or --jsonl)
    if explicit_stdout:
        if args.jsonl:
            for receipt in receipts:
                try:
                    write_receipt(receipt, output_dir=None, jsonl=True)
                except ValueError as e:
                    print(f"{_e('error')} {e}", file=sys.stderr)
                    return 1
        else:
            # --json: single object or array
            if len(receipts) == 1:
                print(json.dumps(receipts[0], indent=2, ensure_ascii=False), flush=True)
            else:
                print(json.dumps(receipts, indent=2, ensure_ascii=False), flush=True)

    # Mode C: ledger (default)
    if use_ledger:
        ledger_dir = args.ledger if args.ledger is not None else _LEDGER_DIR
        try:
            appended, skipped_count, ledger_path = append_to_ledger(
                receipts, ledger_dir=ledger_dir,
            )
        except ValueError as e:
            print(f"{_e('error')} {e}", file=sys.stderr)
            return 1

    # ── Compute stats ──────────────────────────────────────────────────
    ai_count = sum(
        1
        for r in receipts
        if r.get("ai_attestation", {}).get("is_ai_authored")
    )

    # ── Summary (stderr) ───────────────────────────────────────────────
    if not args.quiet and not args.jsonl:
        n = len(receipts)
        parts = [f"{_e('ok')} {n} receipt{'s' if n != 1 else ''} generated"]
        if ai_count:
            parts.append(f"{_e('ai')} {ai_count} AI-authored")
        if signed_count:
            parts.append(f"{_e('signed')} {signed_count} signed")
        print("\n" + " | ".join(parts), file=sys.stderr)

        if use_ledger:
            print(
                f"   {_e('hint')} Saved to {ledger_path}"  # type: ignore[possibly-undefined]
                + (f" ({skipped_count} duplicate{'s' if skipped_count != 1 else ''} skipped)"  # type: ignore[possibly-undefined]
                   if skipped_count else ""),  # type: ignore[possibly-undefined]
                file=sys.stderr,
            )

    # Unsigned receipt warning
    if not args.sign and not args.quiet and receipts:
        print(file=sys.stderr)
        print(
            f"{_e('tip')} Tip: these receipts are unsigned. Add --sign for "
            "cryptographic proof (requires: pip install sigstore)",
            file=sys.stderr,
        )

    # Privacy hint
    if not args.redact_files and not args.quiet and receipts:
        print(
            f"   {_e('hint')} Receipts include author emails and filenames. "
            "Add --redact-files to omit file paths.",
            file=sys.stderr,
        )

    # GitHub Actions integration
    if args.github_action:
        set_github_output("receipt_count", str(len(receipts)))
        set_github_output("ai_commit_count", str(ai_count))
        _MAX_OUTPUT_SIZE = 1024 * 1024  # 1 MB
        receipts_payload = _canonical_json(receipts)
        if len(receipts_payload.encode("utf-8", errors="replace")) > _MAX_OUTPUT_SIZE:
            set_github_output("receipts_json", '"OVERFLOW"')
            set_github_output("receipts_overflow", "true")
            print(
                f"WARNING: receipts_json exceeds 1 MB ({len(receipts_payload)} bytes) "
                f"-- output set to OVERFLOW. Use --output to write receipts to files.",
                file=sys.stderr,
            )
        else:
            set_github_output("receipts_json", receipts_payload)

        summary = format_github_summary(receipts)
        set_github_summary(summary)

    return 0


if __name__ == "__main__":
    sys.exit(main())
