"""Tests for tools/audit_plan_evidence.py."""

from __future__ import annotations

import importlib.util
import json
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch

import pytest
import yaml

# Load the tool module directly from file path (tools/ is not a package)
_tool_path = Path(__file__).parent.parent.parent / "tools" / "audit_plan_evidence.py"
_spec = importlib.util.spec_from_file_location("audit_plan_evidence", _tool_path)
assert _spec is not None, f"Could not load spec from {_tool_path}"
_audit_module = importlib.util.module_from_spec(_spec)
sys.modules["audit_plan_evidence"] = _audit_module
_spec.loader.exec_module(_audit_module)  # type: ignore[arg-type]

from audit_plan_evidence import (  # type: ignore[import-untyped]
    AuditReport,
    extract_commits,
    extract_commits_with_suppression,
    extract_evidence_texts,
    extract_file_paths,
    extract_file_paths_with_suppression,
    filter_suppressed_content,
    format_text_report,
    is_safe_path,
    is_template_placeholder,
    load_plan,
    run_audit,
    verify_commit_exists,
    verify_file_exists_or_tracked,
)


class TestExtractCommits:
    """Tests for commit hash extraction."""

    def test_extracts_7_char_hash(self) -> None:
        """Extracts 7-character commit hash."""
        text = "Commit: abc1234"
        result = extract_commits(text)
        assert result == {"abc1234"}

    def test_extracts_40_char_hash(self) -> None:
        """Extracts full 40-character commit hash."""
        text = "Commit: 107132024ef0d8e0bb02911fcb1c7af1b44dfb60"
        result = extract_commits(text)
        assert result == {"107132024ef0d8e0bb02911fcb1c7af1b44dfb60"}

    def test_extracts_multiple_hashes(self) -> None:
        """Extracts multiple commit hashes from text."""
        text = "Commits: abc1234, def5678, 1234567890abcdef"
        result = extract_commits(text)
        assert result == {"abc1234", "def5678", "1234567890abcdef"}

    def test_ignores_uppercase_hex(self) -> None:
        """Ignores uppercase hex strings (git hashes are lowercase)."""
        text = "Not a commit: ABC1234"
        result = extract_commits(text)
        assert result == set()

    def test_ignores_0x_prefix(self) -> None:
        """Ignores hex strings with 0x prefix."""
        text = "Hex value: 0xabcdef12"
        result = extract_commits(text)
        assert result == set()

    def test_ignores_too_short_hex(self) -> None:
        """Ignores hex strings shorter than 7 characters."""
        text = "Short: abc123"
        result = extract_commits(text)
        assert result == set()


class TestExtractFilePaths:
    """Tests for file path extraction."""

    def test_extracts_py_file(self) -> None:
        """Extracts Python file path."""
        text = "Modified: src/lattice/core/types.py"
        result = extract_file_paths(text)
        assert "src/lattice/core/types.py" in result

    def test_extracts_md_file(self) -> None:
        """Extracts Markdown file path."""
        text = "Docs: docs/README.md"
        result = extract_file_paths(text)
        assert "docs/README.md" in result

    def test_extracts_toml_file(self) -> None:
        """Extracts TOML file path."""
        text = "Config: pyproject.toml"
        result = extract_file_paths(text)
        assert "pyproject.toml" in result

    def test_extracts_yaml_file(self) -> None:
        """Extracts YAML file path."""
        text = "Config: config.yaml"
        result = extract_file_paths(text)
        assert "config.yaml" in result

    def test_ignores_urls(self) -> None:
        """Ignores URLs in text."""
        text = "See https://example.com/file.py for details"
        result = extract_file_paths(text)
        # The URL portion should not be extracted
        assert "https://example.com/file.py" not in result

    def test_extracts_quoted_paths(self) -> None:
        """Extracts paths in quotes."""
        text = 'Files: "src/module.py" and `tests/test.py`'
        result = extract_file_paths(text)
        assert "src/module.py" in result
        assert "tests/test.py" in result


class TestIsTemplatePlaceholder:
    """Tests for template placeholder detection."""

    def test_detects_path_placeholder(self) -> None:
        """Detects <path> placeholder."""
        assert is_template_placeholder("Tool path: <path>") is True

    def test_detects_hash_placeholder(self) -> None:
        """Detects <hash> placeholder."""
        assert is_template_placeholder("Commit: <hash>") is True

    def test_detects_command_placeholder(self) -> None:
        """Detects <command> placeholder."""
        assert is_template_placeholder("Run: <command> -> <outcome>") is True

    def test_detects_multiple_placeholders(self) -> None:
        """Detects text with multiple placeholders."""
        text = "- Tool path: <path>\n- Commit: <hash>\n- Run: <command>"
        assert is_template_placeholder(text) is True

    def test_returns_false_for_real_content(self) -> None:
        """Returns False for real evidence without placeholders."""
        assert is_template_placeholder("Commit: abc1234\nFile: src/test.py") is False

    def test_is_case_insensitive(self) -> None:
        """Placeholders are case-insensitive."""
        assert is_template_placeholder("Path: <PATH>") is True
        assert is_template_placeholder("Hash: <Hash>") is True


class TestFilterSuppressedContent:
    """Tests for suppression content filtering."""

    def test_filters_superseded_block(self) -> None:
        """Filters content within [SUPERSEDED] block."""
        text = """Commit: abc1234
[SUPERSEDED] Original evidence:
Phantom: deadbeef
Actual Implementation:
Commit: def5678"""
        result = filter_suppressed_content(text)
        # Should include non-suppressed content
        assert "abc1234" in result
        assert "def5678" in result
        # Markers should be preserved
        assert "[SUPERSEDED]" in result
        assert "Actual Implementation" in result
        # Suppressed content should be removed
        assert "Phantom:" not in result
        assert "deadbeef" not in result

    def test_filters_phantom_items_section(self) -> None:
        """Filters Phantom Items (DO NOT USE) section."""
        text = """[SUPERSEDED] Old evidence
Phantom Items (DO NOT USE):
- Commits: deadbeef, abcdef12
- Files: fake.py, missing.py
Actual Implementation:
Commit: real1234"""
        result = filter_suppressed_content(text)
        # Real content should be present
        assert "real1234" in result
        assert "Actual Implementation" in result
        # Suppressed content should be removed
        assert "deadbeef" not in result
        assert "fake.py" not in result

    def test_filters_template_placeholders(self) -> None:
        """Filters lines with template placeholders."""
        text = """Tool path: <path>
Commit: <hash>
Real commit: abc1234"""
        result = filter_suppressed_content(text)
        # Placeholder lines should be removed
        assert "<path>" not in result
        assert "<hash>" not in result
        # Real content should remain
        assert "abc1234" in result

    def test_preserves_normal_content(self) -> None:
        """Preserves content without suppression markers."""
        text = """Commit: abc1234
File: src/test.py
Verification: pytest passed"""
        result = filter_suppressed_content(text)
        assert "abc1234" in result
        assert "src/test.py" in result
        assert "pytest passed" in result

    def test_handles_empty_text(self) -> None:
        """Handles empty text."""
        result = filter_suppressed_content("")
        assert result == ""

    def test_ends_suppression_at_verification(self) -> None:
        """Ends suppression at Verification: marker."""
        text = """[SUPERSEDED] Old stuff
Phantom commit: deadbeef
Verification:
Commit: real1234"""
        result = filter_suppressed_content(text)
        # Verification marker ends suppression
        assert "Verification:" in result
        assert "real1234" in result
        # Suppressed content removed
        assert "Phantom commit" not in result
        assert "deadbeef" not in result


class TestExtractCommitsWithSuppression:
    """Tests for commit extraction with suppression."""

    def test_extracts_from_normal_content(self) -> None:
        """Extracts commits from normal (non-suppressed) content."""
        text = "Commit: abc1234\nAnother: def5678"
        result = extract_commits_with_suppression(text)
        assert result == {"abc1234", "def5678"}

    def test_ignores_commits_in_superseded_block(self) -> None:
        """Ignores commits in [SUPERSEDED] blocks."""
        text = """[SUPERSEDED] Old evidence
Phantom Items (DO NOT USE):
- Commits: deadbeef, abcdef12
Actual Implementation:
Commit: abc1234"""
        result = extract_commits_with_suppression(text)
        # Should only extract the real commit
        assert result == {"abc1234"}

    def test_ignores_template_placeholders(self) -> None:
        """Ignores commits in lines with template placeholders."""
        text = """Commit: <hash>
Real commit: abc1234"""
        result = extract_commits_with_suppression(text)
        assert result == {"abc1234"}

    def test_handles_complex_superseded_block(self) -> None:
        """Handles complex real-world [SUPERSEDED] block."""
        text = """[SUPERSEDED] Original evidence contained phantom commits.
Phantom Items (DO NOT USE):
- Commits: 5e9b8aa, 382f26f, a0eb93a (NEVER EXISTED)
Actual Implementation (USE THESE):
- gate-remediation-step-not-found.implement-safeguard (commits: 34ada49, 00bdb69)
Verification:
- Run: git rev-parse 34ada49 -> EXISTS"""
        result = extract_commits_with_suppression(text)
        # Should extract real commits, not phantom ones
        assert "34ada49" in result
        assert "00bdb69" in result
        # Phantom commits should not be in result
        assert "5e9b8aa" not in result
        assert "382f26f" not in result


class TestExtractFilePathsWithSuppression:
    """Tests for file path extraction with suppression."""

    def test_extracts_from_normal_content(self) -> None:
        """Extracts file paths from normal (non-suppressed) content."""
        text = "Modified: src/test.py\nAlso: docs/README.md"
        result = extract_file_paths_with_suppression(text)
        assert "src/test.py" in result
        assert "docs/README.md" in result

    def test_ignores_files_in_superseded_block(self) -> None:
        """Ignores file paths in [SUPERSEDED] blocks."""
        text = """[SUPERSEDED] Old evidence
Phantom Items (DO NOT USE):
- Files: fake.py, missing.py
Actual Implementation:
Real files: src/lattice/core.py"""
        result = extract_file_paths_with_suppression(text)
        # Should only extract real files
        assert "src/lattice/core.py" in result

    def test_ignores_template_placeholders(self) -> None:
        """Ignores file paths in lines with template placeholders."""
        text = """Tool path: <path>
Real file: src/module.py"""
        result = extract_file_paths_with_suppression(text)
        assert "src/module.py" in result

    def test_preserves_urls(self) -> None:
        """Still filters out URLs even with suppression."""
        text = "See https://example.com/file.py for details"
        result = extract_file_paths_with_suppression(text)
        assert "https://example.com/file.py" not in result

    def test_ignores_tool_output_format_examples(self) -> None:
        """Ignores file paths in tool output format examples."""
        text = """Feature verification:
- Rich formatting `[tool]: read — src/auth.py (success)` can be triggered
- Also `[tool]: edit — src/auth.py (error: syntax)` works
Real file: src/lattice/core/compiler.py"""
        result = extract_file_paths_with_suppression(text)
        # Tool output example paths should NOT be extracted
        assert "src/auth.py" not in result
        # Real files should be extracted
        assert "src/lattice/core/compiler.py" in result

    def test_ignores_tool_output_format_various_extensions(self) -> None:
        """Ignores paths in tool output format with various extensions."""
        text = """[tool]: read — config.yaml (success)
[tool]: write — data.json (success)
[tool]: edit — readme.md (error: not found)
Real: pyproject.toml"""
        result = extract_file_paths_with_suppression(text)
        # All example paths should be ignored
        assert "config.yaml" not in result
        assert "data.json" not in result
        assert "readme.md" not in result
        # Common root files should still be extracted
        assert "pyproject.toml" in result


class TestVerifyCommitExists:
    """Tests for commit verification."""

    def test_returns_true_for_existing_commit(self, tmp_path: Path) -> None:
        """Returns True for commit that exists in repo."""
        # Use actual repo root (this project)
        repo_root = Path(__file__).parent.parent.parent
        # Get a real commit from the repo
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=repo_root,
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            pytest.skip("Not in a git repo")
        head_commit = result.stdout.strip()[:7]

        assert verify_commit_exists(head_commit, repo_root) is True

    def test_returns_false_for_phantom_commit(self, tmp_path: Path) -> None:
        """Returns False for commit that doesn't exist."""
        repo_root = Path(__file__).parent.parent.parent
        # Use a clearly fake commit hash
        assert verify_commit_exists("deadbee", repo_root) is False


class TestIsSafePath:
    """Tests for path security validation."""

    def test_rejects_parent_traversal(self) -> None:
        """Rejects paths with .. that escape repo."""
        repo_root = Path(__file__).parent.parent.parent
        assert is_safe_path("../secret.txt", repo_root) is False
        assert is_safe_path("../../etc/passwd", repo_root) is False
        assert is_safe_path("src/../../../secret.txt", repo_root) is False

    def test_rejects_absolute_unix_path(self) -> None:
        """Rejects Unix absolute paths starting with /."""
        repo_root = Path(__file__).parent.parent.parent
        assert is_safe_path("/etc/passwd", repo_root) is False
        assert is_safe_path("/root/.ssh/id_rsa", repo_root) is False
        assert is_safe_path("/var/log/auth.log", repo_root) is False

    def test_rejects_absolute_windows_path(self) -> None:
        """Rejects Windows absolute paths with drive letters."""
        repo_root = Path(__file__).parent.parent.parent
        assert is_safe_path("C:\\Windows\\System32", repo_root) is False
        assert is_safe_path("D:\\secrets\\keys", repo_root) is False
        assert is_safe_path("c:/windows/system32", repo_root) is False

    def test_accepts_valid_relative_paths(self) -> None:
        """Accepts valid relative paths within repo."""
        repo_root = Path(__file__).parent.parent.parent
        assert is_safe_path("src/lattice/core/types.py", repo_root) is True
        assert is_safe_path("pyproject.toml", repo_root) is True
        assert is_safe_path("tests/tools/test_audit.py", repo_root) is True

    def test_accepts_current_dir_reference(self) -> None:
        """Accepts paths with ./ prefix."""
        repo_root = Path(__file__).parent.parent.parent
        assert is_safe_path("./pyproject.toml", repo_root) is True
        assert is_safe_path("./src/file.py", repo_root) is True

    def test_rejects_symlink_escape(self) -> None:
        """Rejects paths that could escape via symlinks after resolution."""
        repo_root = Path(__file__).parent.parent.parent
        # Even if the path doesn't contain .., check that it resolves within repo
        # This test verifies the resolve() + relative_to() logic works
        assert is_safe_path("src/../../../etc/passwd", repo_root) is False


class TestVerifyFileExistsOrTracked:
    """Tests for file verification."""

    def test_returns_true_for_existing_file(self, tmp_path: Path) -> None:
        """Returns True for file that exists on disk."""
        repo_root = Path(__file__).parent.parent.parent
        # Use a file we know exists
        assert verify_file_exists_or_tracked("pyproject.toml", repo_root) is True

    def test_returns_false_for_never_tracked_file(self, tmp_path: Path) -> None:
        """Returns False for file that never existed."""
        repo_root = Path(__file__).parent.parent.parent
        # Use a clearly fake file path
        assert (
            verify_file_exists_or_tracked("nonexistent/fake_file_xyz.py", repo_root)
            is False
        )

    def test_rejects_parent_traversal_path(self) -> None:
        """Rejects file paths with .. traversal (security fix)."""
        repo_root = Path(__file__).parent.parent.parent
        assert verify_file_exists_or_tracked("../secret.txt", repo_root) is False
        assert verify_file_exists_or_tracked("../../etc/passwd", repo_root) is False

    def test_rejects_absolute_unix_path(self) -> None:
        """Rejects Unix absolute paths (security fix)."""
        repo_root = Path(__file__).parent.parent.parent
        assert verify_file_exists_or_tracked("/etc/passwd", repo_root) is False

    def test_rejects_absolute_windows_path(self) -> None:
        """Rejects Windows absolute paths (security fix)."""
        repo_root = Path(__file__).parent.parent.parent
        assert (
            verify_file_exists_or_tracked("C:\\Windows\\System32", repo_root) is False
        )

    def test_accepts_valid_relative_path(self) -> None:
        """Accepts and processes valid relative paths."""
        repo_root = Path(__file__).parent.parent.parent
        assert verify_file_exists_or_tracked("pyproject.toml", repo_root) is True


class TestExtractEvidenceTexts:
    """Tests for evidence extraction from plan."""

    def test_extracts_evidence_from_steps(self) -> None:
        """Extracts evidence fields from plan steps."""
        plan = {
            "phases": [
                {
                    "id": "P1",
                    "steps": [
                        {
                            "id": "P1-S1",
                            "evidence": "Commit: abc1234\nFile: src/test.py",
                        },
                        {"id": "P1-S2", "evidence": None},
                        {"id": "P1-S3", "evidence": "Commit: def5678"},
                    ],
                }
            ]
        }
        result = extract_evidence_texts(plan)
        assert len(result) == 2
        step_ids = [r[0] for r in result]
        assert "P1-S1" in step_ids
        assert "P1-S3" in step_ids
        assert "P1-S2" not in step_ids

    def test_returns_empty_for_no_evidence(self) -> None:
        """Returns empty list when no evidence fields exist."""
        plan = {"phases": [{"id": "P1", "steps": [{"id": "P1-S1"}]}]}
        result = extract_evidence_texts(plan)
        assert result == []


class TestAuditReport:
    """Tests for AuditReport dataclass."""

    def test_to_dict(self) -> None:
        """Converts report to dictionary."""
        report = AuditReport(
            phantom_commits=["abc123"],
            phantom_files=["fake.py"],
            valid_commits=["def456"],
            valid_files=["real.py"],
            integrity_verdict="BREACH",
            steps_audited=5,
            evidence_fields_scanned=3,
        )
        result = report.to_dict()
        assert result["phantom_commits"] == ["abc123"]
        assert result["phantom_files"] == ["fake.py"]
        assert result["integrity_verdict"] == "BREACH"
        assert result["steps_audited"] == 5

    def test_default_values(self) -> None:
        """Default values are empty lists and OK verdict."""
        report = AuditReport()
        assert report.phantom_commits == []
        assert report.phantom_files == []
        assert report.valid_commits == []
        assert report.valid_files == []
        assert report.integrity_verdict == "OK"


class TestFormatTextReport:
    """Tests for text report formatting."""

    def test_format_ok_report(self) -> None:
        """Formats OK report correctly."""
        report = AuditReport(
            valid_commits=["abc123"],
            valid_files=["real.py"],
            integrity_verdict="OK",
            steps_audited=1,
            evidence_fields_scanned=1,
        )
        result = format_text_report(report)
        assert "Integrity verdict: OK" in result
        assert "Phantom commits: 0" in result
        assert "Phantom files: 0" in result

    def test_format_breach_report(self) -> None:
        """Formats BREACH report with phantom details."""
        report = AuditReport(
            phantom_commits=["deadbee"],
            phantom_files=["fake.py"],
            integrity_verdict="BREACH",
            steps_audited=1,
            evidence_fields_scanned=1,
        )
        result = format_text_report(report)
        assert "Integrity verdict: BREACH" in result
        assert "Phantom commits:" in result
        assert "deadbee" in result
        assert "fake.py" in result


class TestLoadPlan:
    """Tests for plan loading."""

    def test_loads_valid_yaml(self, tmp_path: Path) -> None:
        """Loads valid YAML file."""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text("version: 1\nproject: test\nphases: []\n")
        result = load_plan(plan_file)
        assert result["version"] == 1
        assert result["project"] == "test"

    def test_raises_file_not_found(self, tmp_path: Path) -> None:
        """Raises FileNotFoundError for missing file."""
        with pytest.raises(FileNotFoundError):
            load_plan(tmp_path / "nonexistent.yaml")

    def test_raises_yaml_error(self, tmp_path: Path) -> None:
        """Raises YAMLError for invalid YAML."""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text("invalid: [yaml: content\n")
        with pytest.raises(yaml.YAMLError):
            load_plan(plan_file)


class TestRunAudit:
    """Tests for full audit execution."""

    def test_audit_detects_phantoms(self, tmp_path: Path) -> None:
        """Audit detects phantom commits and files."""
        # Create a minimal plan with phantom references
        plan_content = """
version: 1
project: test
phases:
  - id: P1
    steps:
      - id: P1-S1
        evidence: |
          Commit: deadbeef1234567890
          Files: nonexistent/fake_file.py
"""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text(plan_content)

        # Use this project's repo root
        repo_root = Path(__file__).parent.parent.parent

        report = run_audit(plan_file, repo_root)

        assert "deadbeef1234567890" in report.phantom_commits
        assert report.integrity_verdict == "BREACH"

    def test_audit_passes_clean_plan(self, tmp_path: Path) -> None:
        """Audit passes for plan with no phantoms."""
        # Get a real commit from this repo
        repo_root = Path(__file__).parent.parent.parent
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=repo_root,
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            pytest.skip("Not in a git repo")
        real_commit = result.stdout.strip()[:7]

        plan_content = f"""
version: 1
project: test
phases:
  - id: P1
    steps:
      - id: P1-S1
        evidence: |
          Commit: {real_commit}
          Files: pyproject.toml
"""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text(plan_content)

        report = run_audit(plan_file, repo_root)

        assert real_commit in report.valid_commits
        assert "pyproject.toml" in report.valid_files
        assert report.integrity_verdict == "OK"


class TestMainCLI:
    """Tests for CLI interface."""

    def test_exit_code_0_for_ok(self, tmp_path: Path) -> None:
        """Returns exit code 0 when no phantoms found."""
        repo_root = Path(__file__).parent.parent.parent
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=repo_root,
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            pytest.skip("Not in a git repo")
        real_commit = result.stdout.strip()[:7]

        plan_content = f"""
version: 1
project: test
phases:
  - id: P1
    steps:
      - id: P1-S1
        evidence: |
          Commit: {real_commit}
"""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text(plan_content)

        # Use the already-loaded module
        with patch.object(
            sys,
            "argv",
            [
                "audit_plan_evidence.py",
                "--plan-path",
                str(plan_file),
                "--format",
                "json",
            ],
        ):
            exit_code = _audit_module.main()

        assert exit_code == 0

    def test_exit_code_1_for_breach(self, tmp_path: Path) -> None:
        """Returns exit code 1 when phantoms found."""
        plan_content = """
version: 1
project: test
phases:
  - id: P1
    steps:
      - id: P1-S1
        evidence: |
          Commit: deadbeef1234567890
"""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text(plan_content)

        with patch.object(
            sys,
            "argv",
            [
                "audit_plan_evidence.py",
                "--plan-path",
                str(plan_file),
                "--format",
                "json",
            ],
        ):
            exit_code = _audit_module.main()

        assert exit_code == 1

    def test_exit_code_2_for_missing_plan(self, tmp_path: Path) -> None:
        """Returns exit code 2 when plan.yaml doesn't exist."""
        with patch.object(
            sys,
            "argv",
            [
                "audit_plan_evidence.py",
                "--plan-path",
                str(tmp_path / "nonexistent.yaml"),
            ],
        ):
            exit_code = _audit_module.main()

        assert exit_code == 2

    def test_json_output_format(self, tmp_path: Path) -> None:
        """Outputs valid JSON with --format json."""
        plan_content = """
version: 1
project: test
phases: []
"""
        plan_file = tmp_path / "plan.yaml"
        plan_file.write_text(plan_content)

        output_file = tmp_path / "output.json"
        with patch.object(
            sys,
            "argv",
            [
                "audit_plan_evidence.py",
                "--plan-path",
                str(plan_file),
                "--format",
                "json",
                "--output",
                str(output_file),
            ],
        ):
            _audit_module.main()

        # Verify output is valid JSON
        content = output_file.read_text()
        data = json.loads(content)
        assert "integrity_verdict" in data
        assert "phantom_commits" in data
        assert "phantom_files" in data
