#!/usr/bin/env python3
"""Audit plan.yaml evidence for phantom commits and file references.

Detects references in evidence fields that don't exist in the repository:
- Commit hashes (7-40 hex characters) that don't resolve
- File paths that never existed in git history

Usage:
    uv run python tools/audit_plan_evidence.py
    uv run python tools/audit_plan_evidence.py --format json
    uv run python tools/audit_plan_evidence.py --output report.json

Exit codes:
    0: All references valid (integrity_verdict: OK)
    1: Phantom references found (integrity_verdict: BREACH)
    2: Error (invalid plan.yaml, etc.)
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml


@dataclass
class AuditReport:
    """Machine-readable audit report."""

    phantom_commits: list[str] = field(default_factory=list)
    phantom_files: list[str] = field(default_factory=list)
    valid_commits: list[str] = field(default_factory=list)
    valid_files: list[str] = field(default_factory=list)
    integrity_verdict: str = "OK"
    steps_audited: int = 0
    evidence_fields_scanned: int = 0

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary for JSON serialization."""
        return {
            "phantom_commits": self.phantom_commits,
            "phantom_files": self.phantom_files,
            "valid_commits": self.valid_commits,
            "valid_files": self.valid_files,
            "integrity_verdict": self.integrity_verdict,
            "steps_audited": self.steps_audited,
            "evidence_fields_scanned": self.evidence_fields_scanned,
        }


# Pattern for commit hashes: 7-40 hex characters
# Must be word-bounded to avoid matching within other text
COMMIT_PATTERN = re.compile(r"\b([0-9a-f]{7,40})\b")

# Pattern for session IDs that look like commit hashes but shouldn't be verified
# E.g., "Session: a2c83548..." or "session ad819d10..."
SESSION_ID_PATTERN = re.compile(r"session[:\s]+([0-9a-f]{7,40})", re.IGNORECASE)

# Pattern for file paths in evidence
# Matches common patterns like "src/path/file.py", "docs/file.md", "tests/path/test_file.py"
FILE_PATH_PATTERN = re.compile(
    r"(?:^|\s|[\"'`])([\w./-]+\.(?:py|md|toml|yaml|yml|json|txt|sh))(?:$|\s|[\"'`])",
    re.MULTILINE,
)

# Pattern for line references that should NOT be treated as file claims
# E.g., "cli.py:48" or "config.py:123-145"
LINE_REFERENCE_PATTERN = re.compile(
    r"[\w./-]+\.(?:py|md|toml|yaml|yml|json|txt|sh):\d+"
)

# Pattern for example/template file paths that should be ignored
# E.g., "proposal_TIMESTAMP.md", "foo.md", "file..backup.txt"
EXAMPLE_FILE_PATTERN = re.compile(
    r"(TIMESTAMP|/foo\.|/bar\.|/baz\.|^foo\.|^bar\.|^baz\."
    r"|example\.|sample\.|^test_.*\.(?!py$)|\.\.)",
    re.IGNORECASE,
)

# Basename-only file patterns that are likely shorthand references
# Files without directory separators (except for common root files)
# E.g., "auth.py", "cli.py" when the real file is in src/lattice/
BASENAME_ONLY_PATTERN = re.compile(r"^[^/]+\.(py|md|toml|yaml|yml|json|txt|sh)$")

# Common root files that SHOULD be verified (not suppressed)
COMMON_ROOT_FILES = {
    "pyproject.toml",
    "README.md",
    "CHANGELOG.md",
    "LICENSE",
    "setup.py",
    "setup.cfg",
    "Makefile",
    "requirements.txt",
    ".gitignore",
}

# Shorthand directory prefixes that indicate documentation references, not actual paths
# E.g., "core/auth.py" is shorthand for "src/lattice/core/auth.py"
# Note: "src/" is NOT a shorthand prefix - "src/module.py" is a valid path
SHORTHAND_PREFIXES = {"core/", "shell/", "types/"}

# Template placeholder patterns that should be ignored
TEMPLATE_PLACEHOLDER_PATTERN = re.compile(
    r"<(?:path|hash|command|outcome|list|if any|description|reason|timestamp|id|name)>",
    re.IGNORECASE,
)

# Suppression block markers
SUPERSEDED_START = "[SUPERSEDED]"
PHANTOM_ITEMS_HEADER = "Phantom Items (DO NOT USE):"
# End suppression when we see actual implementation markers
SUPERSEDED_END_MARKERS = [
    "Actual Implementation",
    "Verification:",
    "---",
    "Gaps/Notes:",
]

# Output sections that document audit results (contain phantom item lists)
OUTPUT_SECTION_MARKER = "Output:"
OUTPUT_SECTION_END_MARKERS = ["Gaps/Notes:", "---", "\n\n"]

# Findings sections that list phantom items from audits
FINDINGS_SECTION_MARKER = "Findings:"
FINDINGS_SECTION_END_MARKERS = ["Gaps/Notes:", "---", "\n\n"]

# Pattern for lines that document phantom items as missing/non-resolving
# These are not claims that the items exist - they're documenting they DON'T exist
PHANTOM_DOCUMENTATION_PATTERN = re.compile(
    r"(missing|non-resolving|never\s+(existed|tracked)|phantom\s+(commits?|files?))",
    re.IGNORECASE,
)

# Pattern for tool output format examples
# These are demonstration outputs showing expected format, not actual file claims
# E.g., "[tool]: read — src/auth.py (success)" or "[tool]: edit — file.py (error: syntax)"
TOOL_OUTPUT_FORMAT_PATTERN = re.compile(
    r"\[tool\]:\s*\w+\s*—\s*[\w./-]+\.\w+\s*\([^)]+\)",
    re.IGNORECASE,
)

# Pattern to extract file paths from tool output format examples (for suppression)
# Matches paths within the tool output format: "[tool]: action — <path> (status)"
TOOL_OUTPUT_PATH_PATTERN = re.compile(
    r"\[tool\]:\s*\w+\s*—\s*([\w./-]+\.\w+)\s*\([^)]+\)",
    re.IGNORECASE,
)


def is_template_placeholder(text: str) -> bool:
    """Check if text contains template placeholders that should be ignored.

    Args:
        text: Text to check for placeholder patterns.

    Returns:
        True if text contains template placeholders like <path>, <hash>, etc.
    """
    return bool(TEMPLATE_PLACEHOLDER_PATTERN.search(text))


def filter_suppressed_content(text: str) -> str:
    """Filter out suppressed content from evidence text.

    Removes:
    - Content within [SUPERSEDED] blocks (until end marker or end of text)
    - Content within Output: sections (audit report outputs with phantom lists)
    - Content within Findings: sections (audit findings with phantom item lists)
    - Lines containing template placeholders

    The marker lines themselves are preserved for readability, but content
    between markers is removed.

    Args:
        text: Original evidence text.

    Returns:
        Filtered text with suppressed content removed.
    """
    lines = text.split("\n")
    filtered_lines: list[str] = []
    in_suppressed_block = False
    in_output_section = False
    in_findings_section = False

    for line in lines:
        # Check for start of [SUPERSEDED] block
        if SUPERSEDED_START in line:
            in_suppressed_block = True
            # Keep the marker line for context
            filtered_lines.append(line)
            continue

        # Check for phantom items header (within superseded block)
        if PHANTOM_ITEMS_HEADER in line:
            # Keep the header for context, but continue suppression
            filtered_lines.append(line)
            continue

        # Check for end of [SUPERSEDED] block
        if in_suppressed_block:
            for end_marker in SUPERSEDED_END_MARKERS:
                if end_marker in line:
                    in_suppressed_block = False
                    filtered_lines.append(line)
                    break
            else:
                # Still in suppressed block - SKIP this line entirely
                continue
            continue

        # Check for start of Output: section (audit report outputs)
        if line.strip().startswith(OUTPUT_SECTION_MARKER):
            in_output_section = True
            # Keep the marker line for context
            filtered_lines.append(line)
            continue

        # Check for end of Output: section
        if in_output_section:
            for end_marker in OUTPUT_SECTION_END_MARKERS:
                if end_marker in line and end_marker != "\n\n":
                    in_output_section = False
                    filtered_lines.append(line)
                    break
            else:
                # Check for blank line (end of output section)
                if not line.strip():
                    in_output_section = False
                    filtered_lines.append(line)
                    continue
                # Still in output section - SKIP this line
                continue
            continue

        # Check for start of Findings: section (audit findings)
        if line.strip().startswith(FINDINGS_SECTION_MARKER):
            in_findings_section = True
            # Keep the marker line for context
            filtered_lines.append(line)
            continue

        # Check for end of Findings: section
        if in_findings_section:
            for end_marker in FINDINGS_SECTION_END_MARKERS:
                if end_marker in line and end_marker != "\n\n":
                    in_findings_section = False
                    filtered_lines.append(line)
                    break
            else:
                # Check for blank line (end of findings section)
                if not line.strip():
                    in_findings_section = False
                    filtered_lines.append(line)
                    continue
                # Still in findings section - SKIP this line
                continue
            continue

        # Skip lines with template placeholders
        if is_template_placeholder(line):
            continue

        filtered_lines.append(line)

    return "\n".join(filtered_lines)


def extract_commits_with_suppression(text: str) -> set[str]:
    """Extract commit hashes from text, excluding suppressed content.

    Args:
        text: Text to search for commit hashes.

    Returns:
        Set of unique commit hashes found (excluding suppressed content).
    """
    # Filter out suppressed content first
    filtered_text = filter_suppressed_content(text)

    # Find session IDs to exclude (they look like commit hashes but aren't)
    session_ids = set(SESSION_ID_PATTERN.findall(filtered_text))

    # Process line by line to skip lines documenting phantom items
    commits: set[str] = set()
    for line in filtered_text.split("\n"):
        # Skip lines that document phantom items as missing/non-resolving
        if PHANTOM_DOCUMENTATION_PATTERN.search(line):
            continue
        # Extract commits from this line
        line_matches = COMMIT_PATTERN.findall(line)
        for m in line_matches:
            if m == m.lower() and not m.startswith("0x") and m not in session_ids:
                commits.add(m)

    return commits


def extract_file_paths_with_suppression(text: str) -> set[str]:
    """Extract file paths from text, excluding suppressed content.

    Args:
        text: Text to search for file paths.

    Returns:
        Set of unique file paths found (excluding suppressed content).
    """
    # Filter out suppressed content first
    filtered_text = filter_suppressed_content(text)

    # Find all paths that appear within tool output format examples
    # These are demonstration outputs, not actual file claims
    # E.g., "[tool]: read — src/auth.py (success)"
    tool_output_example_paths = set(TOOL_OUTPUT_PATH_PATTERN.findall(filtered_text))

    # Process line by line to skip lines documenting phantom items
    paths: set[str] = set()
    for line in filtered_text.split("\n"):
        # Skip lines that document phantom items as missing/non-resolving
        if PHANTOM_DOCUMENTATION_PATTERN.search(line):
            continue
        # Extract file paths from this line
        line_matches = FILE_PATH_PATTERN.findall(line)
        for m in line_matches:
            # Skip if it's a tool output format example path
            if m in tool_output_example_paths:
                continue
            # Skip if it looks like a URL or version number
            if "://" in m or m.startswith("http"):
                continue
            # Skip if it's just a version like "1.0.0.json" (unlikely real file)
            if re.match(r"^\d+\.\d+\.\d+\.", m):
                continue
            # Skip if it matches example/template patterns
            if EXAMPLE_FILE_PATTERN.search(m):
                continue
            # Skip basename-only references that aren't common root files
            if BASENAME_ONLY_PATTERN.match(m) and m not in COMMON_ROOT_FILES:
                continue
            # Skip shorthand directory prefixes
            # Check for shorthand patterns like "core/auth.py" or "core/types/event.py"
            # These are documentation shorthand, not actual file claims
            is_shorthand = False
            for prefix in SHORTHAND_PREFIXES:
                if m.startswith(prefix):
                    remaining = m[len(prefix) :]
                    # Check if remaining part has at most one directory level
                    # E.g., "auth.py" or "types/event.py" are OK (shorthand)
                    # But "src/lattice/types/event.py" = NOT shorthand
                    parts = remaining.split("/")
                    if len(parts) <= 2:  # filename only, or one dir + filename
                        is_shorthand = True
                    break
            if not is_shorthand:
                paths.add(m)

    return paths


def load_plan(plan_path: Path) -> dict[str, Any]:
    """Load plan.yaml file.

    Args:
        plan_path: Path to plan.yaml

    Returns:
        Parsed YAML content as dictionary.

    Raises:
        FileNotFoundError: If plan.yaml doesn't exist.
        yaml.YAMLError: If YAML parsing fails.
    """
    with open(plan_path) as f:
        return yaml.safe_load(f)


def extract_evidence_texts(plan: dict[str, Any]) -> list[tuple[str, str]]:
    """Extract all evidence text from plan.

    Args:
        plan: Parsed plan.yaml content.

    Returns:
        List of (step_id, evidence_text) tuples.
    """
    evidence_texts: list[tuple[str, str]] = []

    phases = plan.get("phases", [])
    for phase in phases:
        for step in phase.get("steps", []):
            step_id = step.get("id", "unknown")
            evidence = step.get("evidence", "")
            if evidence and isinstance(evidence, str):
                evidence_texts.append((step_id, evidence))

    return evidence_texts


def extract_commits(text: str) -> set[str]:
    """Extract commit hashes from text.

    Args:
        text: Text to search for commit hashes.

    Returns:
        Set of unique commit hashes found.
    """
    matches = COMMIT_PATTERN.findall(text)
    # Filter to only lowercase hex (git hashes are lowercase)
    return {m for m in matches if m == m.lower() and not m.startswith("0x")}


def extract_file_paths(text: str) -> set[str]:
    """Extract file paths from evidence text.

    Args:
        text: Evidence text to search.

    Returns:
        Set of unique file paths found.
    """
    matches = FILE_PATH_PATTERN.findall(text)
    # Normalize paths and filter out obvious non-files
    paths: set[str] = set()
    for m in matches:
        # Skip if it looks like a URL or version number
        if "://" in m or m.startswith("http"):
            continue
        # Skip if it's just a version like "1.0.0.json" (unlikely real file)
        if re.match(r"^\d+\.\d+\.\d+\.", m):
            continue
        paths.add(m)
    return paths


def verify_commit_exists(commit: str, repo_root: Path) -> bool:
    """Check if a commit exists in the repository.

    Args:
        commit: Commit hash to verify.
        repo_root: Path to git repository root.

    Returns:
        True if commit exists, False otherwise.
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--verify", f"{commit}^{{commit}}"],
            cwd=repo_root,
            capture_output=True,
            text=True,
            timeout=10,
        )
        return result.returncode == 0
    except (subprocess.TimeoutExpired, OSError):
        return False


def is_safe_path(file_path: str, repo_root: Path) -> bool:
    """Check if a file path is safe (no traversal, stays within repo).

    Security checks:
    - Rejects absolute paths (Unix / and Windows C:\\ etc.)
    - Rejects paths containing .. segments after normalization
    - Ensures resolved path stays within repo_root

    Args:
        file_path: File path to validate.
        repo_root: Path to git repository root.

    Returns:
        True if path is safe and stays within repo, False otherwise.
    """
    # Reject absolute paths (Unix-style starting with /)
    if file_path.startswith("/"):
        return False

    # Reject Windows absolute paths (e.g., C:\, D:\)
    if len(file_path) >= 2 and file_path[1] == ":" and file_path[0].isalpha():
        return False

    # Reject paths with .. segments in any part
    parts = Path(file_path).parts
    if ".." in parts:
        return False

    # Ensure resolved path stays within repo_root
    try:
        resolved_path = (repo_root / file_path).resolve()
        resolved_repo = repo_root.resolve()
        # relative_to raises ValueError if not a subpath
        resolved_path.relative_to(resolved_repo)
        return True
    except (ValueError, OSError):
        return False


def verify_file_exists_or_tracked(file_path: str, repo_root: Path) -> bool:
    """Check if a file exists or was ever tracked in git history.

    Security: Rejects unsafe paths before checking existence to prevent
    path traversal attacks.

    Args:
        file_path: Relative file path to check.
        repo_root: Path to git repository root.

    Returns:
        True if file exists on disk or has git history (and path is safe).
        False if path is unsafe or file never existed.
    """
    # Security: Reject unsafe paths first
    if not is_safe_path(file_path, repo_root):
        return False

    full_path = repo_root / file_path

    # Check if file exists on disk
    if full_path.exists():
        return True

    # Check if file was ever tracked in git history
    try:
        result = subprocess.run(
            ["git", "log", "--oneline", "-n", "1", "--", file_path],
            cwd=repo_root,
            capture_output=True,
            text=True,
            timeout=10,
        )
        # If git log returns any output, the file was tracked
        return bool(result.stdout.strip())
    except (subprocess.TimeoutExpired, OSError):
        return False


def run_audit(plan_path: Path, repo_root: Path) -> AuditReport:
    """Run the full audit on plan.yaml.

    Args:
        plan_path: Path to plan.yaml.
        repo_root: Path to git repository root.

    Returns:
        AuditReport with findings.
    """
    report = AuditReport()

    # Load plan
    plan = load_plan(plan_path)

    # Extract all evidence
    evidence_texts = extract_evidence_texts(plan)
    report.steps_audited = len(evidence_texts)

    # Collect all commits and files
    all_commits: set[str] = set()
    all_files: set[str] = set()

    for step_id, evidence in evidence_texts:
        report.evidence_fields_scanned += 1
        all_commits.update(extract_commits_with_suppression(evidence))
        all_files.update(extract_file_paths_with_suppression(evidence))

    # Verify commits
    for commit in sorted(all_commits):
        if verify_commit_exists(commit, repo_root):
            report.valid_commits.append(commit)
        else:
            report.phantom_commits.append(commit)

    # Verify files
    for file_path in sorted(all_files):
        if verify_file_exists_or_tracked(file_path, repo_root):
            report.valid_files.append(file_path)
        else:
            report.phantom_files.append(file_path)

    # Determine verdict
    if report.phantom_commits or report.phantom_files:
        report.integrity_verdict = "BREACH"
    else:
        report.integrity_verdict = "OK"

    return report


def format_text_report(report: AuditReport) -> str:
    """Format report as human-readable text.

    Args:
        report: Audit report to format.

    Returns:
        Human-readable text report.
    """
    lines = [
        "Plan Evidence Integrity Audit",
        "=" * 40,
        f"Steps audited: {report.steps_audited}",
        f"Evidence fields scanned: {report.evidence_fields_scanned}",
        "",
        f"Valid commits: {len(report.valid_commits)}",
        f"Phantom commits: {len(report.phantom_commits)}",
        f"Valid files: {len(report.valid_files)}",
        f"Phantom files: {len(report.phantom_files)}",
        "",
        f"Integrity verdict: {report.integrity_verdict}",
    ]

    if report.phantom_commits:
        lines.append("")
        lines.append("Phantom commits:")
        for c in report.phantom_commits:
            lines.append(f"  - {c}")

    if report.phantom_files:
        lines.append("")
        lines.append("Phantom files:")
        for f in report.phantom_files:
            lines.append(f"  - {f}")

    return "\n".join(lines)


def main() -> int:
    """Main entry point.

    Returns:
        Exit code: 0 (OK), 1 (BREACH), 2 (error).
    """
    parser = argparse.ArgumentParser(
        description="Audit plan.yaml evidence for phantom references"
    )
    parser.add_argument(
        "--format",
        choices=["json", "text"],
        default="text",
        help="Output format (default: text)",
    )
    parser.add_argument(
        "--output",
        type=Path,
        help="Write output to file instead of stdout",
    )
    parser.add_argument(
        "--plan-path",
        type=Path,
        default=Path("plan.yaml"),
        help="Path to plan.yaml (default: plan.yaml)",
    )
    parser.add_argument(
        "--repo-root",
        type=Path,
        default=Path("."),
        help="Path to git repository root (default: current directory)",
    )
    args = parser.parse_args()

    try:
        report = run_audit(args.plan_path, args.repo_root)
    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 2
    except yaml.YAMLError as e:
        print(f"Error parsing plan.yaml: {e}", file=sys.stderr)
        return 2
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr)
        return 2

    # Format output
    if args.format == "json":
        output = json.dumps(report.to_dict(), indent=2)
    else:
        output = format_text_report(report)

    # Write or print output
    if args.output:
        args.output.write_text(output)
        print(f"Report written to {args.output}")
    else:
        print(output)

    # Return exit code based on verdict
    if report.integrity_verdict == "BREACH":
        return 1
    return 0


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