"""Tests for the report pipeline: persistence, SARIF, JSON, Markdown, and CLI."""

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
from typer.testing import CliRunner

from sanicode.cli import app
from sanicode.compliance.enrichment import EnrichedFinding
from sanicode.compliance.mapper import ComplianceMapping
from sanicode.graph.builder import KnowledgeGraph
from sanicode.report.json_report import generate_json
from sanicode.report.markdown import generate_markdown
from sanicode.report.persist import (
    ScanResult,
    build_scan_result,
    load_scan_result,
    save_scan_result,
)
from sanicode.report.sarif import generate_sarif

runner = CliRunner()

# ---------------------------------------------------------------------------
# Test data helpers
# ---------------------------------------------------------------------------

_SCAN_ID_PATTERN = re.compile(r"^\d{8}T\d{6}Z$")


def _make_compliance(cwe_id: int) -> ComplianceMapping:
    """Build a minimal but realistic ComplianceMapping for a given CWE."""
    return ComplianceMapping(
        cwe_id=cwe_id,
        cwe_name="OS Command Injection" if cwe_id == 78 else "SQL Injection",
        owasp_asvs=[{"id": "v5.0.0-V1-5.3.8", "title": "", "level": "L1"}],
        nist_800_53=["SI-10", "SI-15"],
        asd_stig=[{"id": "APSC-DV-002510", "cat": "CAT I", "title": ""}],
        pci_dss=["6.2.4"],
        asvs_level="L1",
        stig_category="CAT I",
        remediation="Use parameterized queries",
    )


def _make_findings(base_path: Path | None = None) -> list[EnrichedFinding]:
    """Return a list of test EnrichedFinding objects with mixed severities."""
    root = base_path or Path("/app")
    return [
        EnrichedFinding(
            file=root / "app.py",
            line=42,
            column=8,
            rule_id="SC001",
            message="Use of eval() — arbitrary code execution via unsanitized input",
            severity="high",
            cwe_id=78,
            cwe_name="OS Command Injection",
            compliance=_make_compliance(78),
            derived_severity="critical",
            remediation="Use parameterized queries",
        ),
        EnrichedFinding(
            file=root / "db.py",
            line=17,
            column=4,
            rule_id="SC006",
            message="String formatting in SQL query — possible injection vector",
            severity="medium",
            cwe_id=89,
            cwe_name="SQL Injection",
            compliance=_make_compliance(89),
            derived_severity="high",
            remediation="Use parameterized queries",
        ),
        EnrichedFinding(
            file=root / "app.py",
            line=99,
            column=0,
            rule_id="SC001",  # same rule_id as first finding (for dedup tests)
            message="Another eval() call",
            severity="high",
            cwe_id=78,
            cwe_name="OS Command Injection",
            compliance=_make_compliance(78),
            derived_severity="critical",
            remediation="Avoid eval()",
        ),
    ]


def _make_scan_result(tmp_path: Path) -> ScanResult:
    """Build a ScanResult using test findings and an empty knowledge graph."""
    findings = _make_findings(base_path=tmp_path)
    kg = KnowledgeGraph()
    kg.add_entry_point("request_handler", file=tmp_path / "app.py", line=1)
    kg.add_sink("db_query", file=tmp_path / "db.py", line=17, cwe_id=89)
    return build_scan_result(findings, tmp_path, kg)


# ---------------------------------------------------------------------------
# 1. persist.py round-trip
# ---------------------------------------------------------------------------


class TestPersistRoundTrip:
    def test_scan_id_format(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        assert _SCAN_ID_PATTERN.match(result.scan_id), (
            f"scan_id '{result.scan_id}' does not match expected format YYYYMMDDTHHMMSSz"
        )

    def test_summary_total_findings(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        assert result.summary["total_findings"] == 3, (
            f"Expected 3 total findings, got {result.summary['total_findings']}"
        )

    def test_summary_severity_counts(self, tmp_path: Path) -> None:
        # _build_summary buckets by finding.severity (the raw scanner severity),
        # not derived_severity.  Our test data has severity="high" for the two
        # SC001 findings and severity="medium" for the SC006 finding.
        result = _make_scan_result(tmp_path)
        by_sev = result.summary["by_severity"]
        assert by_sev.get("high", 0) == 2, (
            f"Expected 2 high-severity findings (raw scanner severity), got {by_sev}"
        )
        assert by_sev.get("medium", 0) == 1, (
            f"Expected 1 medium-severity finding, got {by_sev}"
        )

    def test_summary_graph_nodes(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        assert result.summary["graph_nodes"] == 2, (
            f"Expected 2 graph nodes, got {result.summary['graph_nodes']}"
        )
        assert result.summary["graph_entry_points"] == 1
        assert result.summary["graph_sinks"] == 1

    def test_findings_stored_as_relative_paths(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        for f in result.findings:
            file_val = f["file"]
            assert not Path(file_val).is_absolute(), (
                f"Expected relative file path in serialized finding, got: {file_val!r}"
            )

    def test_round_trip_preserves_all_fields(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        saved_path = save_scan_result(result, tmp_path / "out")
        loaded = load_scan_result(saved_path)

        assert loaded.sanicode_version == result.sanicode_version, (
            f"sanicode_version mismatch: {loaded.sanicode_version!r} != {result.sanicode_version!r}"
        )
        assert loaded.scan_id == result.scan_id, (
            f"scan_id mismatch: {loaded.scan_id!r} != {result.scan_id!r}"
        )
        assert loaded.scanned_path == result.scanned_path, (
            f"scanned_path mismatch: {loaded.scanned_path!r} != {result.scanned_path!r}"
        )
        assert loaded.scan_timestamp == result.scan_timestamp, (
            f"scan_timestamp mismatch: {loaded.scan_timestamp!r} != {result.scan_timestamp!r}"
        )
        assert loaded.summary == result.summary, (
            f"summary mismatch:\n  got:      {loaded.summary}\n  expected: {result.summary}"
        )
        assert len(loaded.findings) == len(result.findings), (
            f"Findings count mismatch: {len(loaded.findings)} != {len(result.findings)}"
        )

    def test_round_trip_preserves_finding_detail(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        saved_path = save_scan_result(result, tmp_path / "out")
        loaded = load_scan_result(saved_path)

        orig = result.findings[0]
        restored = loaded.findings[0]
        for field in ("rule_id", "line", "column", "severity", "cwe_id", "message"):
            assert restored[field] == orig[field], (
                f"Field '{field}' mismatch after round-trip: "
                f"{restored[field]!r} != {orig[field]!r}"
            )

    def test_save_creates_output_dir(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        nested = tmp_path / "does" / "not" / "exist"
        out_file = save_scan_result(result, nested)
        assert out_file.exists(), f"Expected {out_file} to be created"
        assert out_file.name == "scan-result.json"

    def test_load_missing_file_raises(self, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError, match="not found"):
            load_scan_result(tmp_path / "nonexistent.json")

    def test_load_missing_required_fields_raises(self, tmp_path: Path) -> None:
        bad_json = tmp_path / "bad.json"
        bad_json.write_text(json.dumps({"sanicode_version": "0.1"}), encoding="utf-8")
        with pytest.raises(ValueError, match="missing required fields"):
            load_scan_result(bad_json)


# ---------------------------------------------------------------------------
# 2. SARIF generator
# ---------------------------------------------------------------------------


class TestSarifGenerator:
    def test_schema_uri(self, tmp_path: Path) -> None:
        sarif = generate_sarif(_make_scan_result(tmp_path))
        assert sarif["$schema"] == "https://json.schemastore.org/sarif-2.1.0.json", (
            f"Unexpected $schema: {sarif['$schema']!r}"
        )

    def test_version(self, tmp_path: Path) -> None:
        sarif = generate_sarif(_make_scan_result(tmp_path))
        assert sarif["version"] == "2.1.0", (
            f"Unexpected SARIF version: {sarif['version']!r}"
        )

    def test_exactly_one_run(self, tmp_path: Path) -> None:
        sarif = generate_sarif(_make_scan_result(tmp_path))
        assert len(sarif["runs"]) == 1, (
            f"Expected exactly 1 run entry, got {len(sarif['runs'])}"
        )

    def test_tool_driver_name(self, tmp_path: Path) -> None:
        sarif = generate_sarif(_make_scan_result(tmp_path))
        driver = sarif["runs"][0]["tool"]["driver"]
        assert driver["name"] == "sanicode", (
            f"Unexpected tool driver name: {driver['name']!r}"
        )

    def test_rules_deduplicated(self, tmp_path: Path) -> None:
        """Two findings sharing rule_id SC001 must produce only one rule descriptor."""
        sarif = generate_sarif(_make_scan_result(tmp_path))
        rules = sarif["runs"][0]["tool"]["driver"]["rules"]
        rule_ids = [r["id"] for r in rules]
        assert len(rule_ids) == len(set(rule_ids)), (
            f"Duplicate rule IDs found in SARIF rules: {rule_ids}"
        )
        # We have SC001 and SC006 — two unique rule IDs across 3 findings.
        assert set(rule_ids) == {"SC001", "SC006"}, (
            f"Expected rule IDs {{SC001, SC006}}, got {set(rule_ids)}"
        )

    @pytest.mark.parametrize(
        "severity, expected_level",
        [
            ("critical", "error"),
            ("high", "error"),
            ("medium", "warning"),
            ("low", "note"),
            ("info", "note"),
        ],
    )
    def test_severity_mapping(
        self, tmp_path: Path, severity: str, expected_level: str
    ) -> None:
        findings = [
            EnrichedFinding(
                file=tmp_path / "x.py",
                line=1,
                column=0,
                rule_id="SC001",
                message="test",
                severity=severity,
                cwe_id=78,
                derived_severity=severity,
                remediation="",
            )
        ]
        kg = KnowledgeGraph()
        result = build_scan_result(findings, tmp_path, kg)
        sarif = generate_sarif(result)
        sarif_results = sarif["runs"][0]["results"]
        assert sarif_results[0]["level"] == expected_level, (
            f"severity={severity!r} should map to level={expected_level!r}, "
            f"got {sarif_results[0]['level']!r}"
        )

    def test_column_is_one_based(self, tmp_path: Path) -> None:
        """AST col_offset is 0-based; SARIF columns must be 1-based (offset + 1)."""
        findings = [
            EnrichedFinding(
                file=tmp_path / "app.py",
                line=10,
                column=8,  # 0-based from AST
                rule_id="SC001",
                message="test",
                severity="high",
                cwe_id=78,
                derived_severity="critical",
                remediation="",
            )
        ]
        kg = KnowledgeGraph()
        result = build_scan_result(findings, tmp_path, kg)
        sarif = generate_sarif(result)
        region = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"]
        assert region["startColumn"] == 9, (
            f"Expected startColumn 9 (0-based col 8 → 1-based 9), got {region['startColumn']}"
        )

    def test_empty_findings_valid_sarif(self, tmp_path: Path) -> None:
        kg = KnowledgeGraph()
        result = build_scan_result([], tmp_path, kg)
        sarif = generate_sarif(result)

        assert sarif["$schema"] == "https://json.schemastore.org/sarif-2.1.0.json"
        assert sarif["runs"][0]["tool"]["driver"]["rules"] == []
        assert sarif["runs"][0]["results"] == []


# ---------------------------------------------------------------------------
# 3. JSON generator
# ---------------------------------------------------------------------------


class TestJsonGenerator:
    def test_required_top_level_keys(self, tmp_path: Path) -> None:
        report = generate_json(_make_scan_result(tmp_path))
        for key in ("summary", "findings", "report_generated_at", "scan_id", "sanicode_version"):
            assert key in report, f"Expected key '{key}' in JSON report, got keys: {list(report)}"

    def test_findings_sorted_critical_first(self, tmp_path: Path) -> None:
        report = generate_json(_make_scan_result(tmp_path))
        severities = [
            f.get("derived_severity") or f.get("severity") for f in report["findings"]
        ]
        order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
        ranks = [order.get(s, 4) for s in severities]
        assert ranks == sorted(ranks), (
            f"Findings are not sorted by severity (critical first): {severities}"
        )

    def test_report_generated_at_present(self, tmp_path: Path) -> None:
        report = generate_json(_make_scan_result(tmp_path))
        assert report["report_generated_at"], "report_generated_at should be non-empty"

    def test_findings_count_matches(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        report = generate_json(result)
        assert len(report["findings"]) == len(result.findings), (
            f"JSON report findings count {len(report['findings'])} != "
            f"scan result findings count {len(result.findings)}"
        )

    def test_scan_id_preserved(self, tmp_path: Path) -> None:
        result = _make_scan_result(tmp_path)
        report = generate_json(result)
        assert report["scan_id"] == result.scan_id, (
            f"scan_id mismatch: {report['scan_id']!r} != {result.scan_id!r}"
        )

    def test_empty_findings_still_valid(self, tmp_path: Path) -> None:
        kg = KnowledgeGraph()
        result = build_scan_result([], tmp_path, kg)
        report = generate_json(result)
        assert report["findings"] == []
        assert report["summary"]["total_findings"] == 0


# ---------------------------------------------------------------------------
# 4. Markdown generator
# ---------------------------------------------------------------------------


class TestMarkdownGenerator:
    def test_contains_title(self, tmp_path: Path) -> None:
        md = generate_markdown(_make_scan_result(tmp_path))
        assert "# Sanicode Security Report" in md, (
            f"Expected '# Sanicode Security Report' in markdown:\n{md[:400]}"
        )

    def test_contains_summary_section(self, tmp_path: Path) -> None:
        md = generate_markdown(_make_scan_result(tmp_path))
        assert "## Summary" in md, (
            f"Expected '## Summary' in markdown:\n{md[:400]}"
        )

    def test_contains_findings_section(self, tmp_path: Path) -> None:
        md = generate_markdown(_make_scan_result(tmp_path))
        assert "## Findings" in md, (
            f"Expected '## Findings' in markdown:\n{md[:400]}"
        )

    def test_findings_sorted_critical_first(self, tmp_path: Path) -> None:
        """The first finding heading in the report should be a CRITICAL entry."""
        md = generate_markdown(_make_scan_result(tmp_path))
        # Find all severity labels in finding headings (e.g. [CRITICAL], [HIGH])
        headings = re.findall(r"\[(\w+)\]", md)
        valid = {"CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"}
        severity_headings = [h for h in headings if h in valid]
        assert severity_headings, f"No severity headings found in markdown:\n{md[:600]}"
        assert severity_headings[0] == "CRITICAL", (
            f"Expected first finding to be CRITICAL, got {severity_headings[0]}:\n{md[:600]}"
        )

    def test_empty_findings_message(self, tmp_path: Path) -> None:
        kg = KnowledgeGraph()
        result = build_scan_result([], tmp_path, kg)
        md = generate_markdown(result)
        assert "No security findings detected." in md, (
            f"Expected 'No security findings detected.' in empty-findings markdown:\n{md}"
        )

    def test_cwe_referenced_in_output(self, tmp_path: Path) -> None:
        md = generate_markdown(_make_scan_result(tmp_path))
        assert "CWE-78" in md, (
            f"Expected 'CWE-78' in markdown findings:\n{md[:800]}"
        )

    def test_remediation_present(self, tmp_path: Path) -> None:
        md = generate_markdown(_make_scan_result(tmp_path))
        assert "Use parameterized queries" in md, (
            f"Expected remediation text in markdown:\n{md[:800]}"
        )


# ---------------------------------------------------------------------------
# 5. CLI report command
# ---------------------------------------------------------------------------


class TestReportCommand:
    def _save_result(self, tmp_path: Path) -> Path:
        """Write a scan-result.json to tmp_path and return its path."""
        result = _make_scan_result(tmp_path)
        return save_scan_result(result, tmp_path)

    def test_basic_invocation_exit_zero(self, tmp_path: Path) -> None:
        scan_result = self._save_result(tmp_path)
        out = runner.invoke(app, ["report", str(scan_result)])
        assert out.exit_code == 0, (
            f"Expected exit code 0, got {out.exit_code}:\n{out.output}"
        )

    def test_default_formats_create_files(self, tmp_path: Path) -> None:
        """Default formats are markdown + sarif, so both files should be written."""
        scan_result = self._save_result(tmp_path)
        out_dir = tmp_path / "reports"
        runner.invoke(app, ["report", str(scan_result), "--output-dir", str(out_dir)])
        assert (out_dir / "report.md").exists(), (
            f"Expected report.md to be created in {out_dir}"
        )
        assert (out_dir / "report.sarif").exists(), (
            f"Expected report.sarif to be created in {out_dir}"
        )

    def test_json_format_creates_file(self, tmp_path: Path) -> None:
        scan_result = self._save_result(tmp_path)
        out_dir = tmp_path / "reports"
        out = runner.invoke(
            app, ["report", str(scan_result), "--format", "json", "--output-dir", str(out_dir)]
        )
        assert out.exit_code == 0, f"Exit code {out.exit_code}:\n{out.output}"
        assert (out_dir / "report.json").exists(), (
            f"Expected report.json to be created in {out_dir}"
        )

    def test_severity_filter_high_excludes_medium(self, tmp_path: Path) -> None:
        """--severity high should exclude medium findings from the report."""
        scan_result = self._save_result(tmp_path)
        out_dir = tmp_path / "reports"
        out = runner.invoke(
            app,
            [
                "report", str(scan_result),
                "--format", "json",
                "--severity", "high",
                "--output-dir", str(out_dir),
            ],
        )
        assert out.exit_code == 0, f"Exit code {out.exit_code}:\n{out.output}"
        report_json = json.loads((out_dir / "report.json").read_text(encoding="utf-8"))
        for finding in report_json["findings"]:
            sev = finding.get("derived_severity") or finding.get("severity")
            assert sev in ("critical", "high"), (
                f"Severity filter --severity high should exclude '{sev}' findings;\n"
                f"finding: {finding}"
            )

    def test_cwe_filter_restricts_findings(self, tmp_path: Path) -> None:
        """--cwe 78 should include only CWE-78 findings."""
        scan_result = self._save_result(tmp_path)
        out_dir = tmp_path / "reports"
        out = runner.invoke(
            app,
            [
                "report", str(scan_result),
                "--format", "json",
                "--cwe", "78",
                "--output-dir", str(out_dir),
            ],
        )
        assert out.exit_code == 0, f"Exit code {out.exit_code}:\n{out.output}"
        report_json = json.loads((out_dir / "report.json").read_text(encoding="utf-8"))
        for finding in report_json["findings"]:
            assert finding["cwe_id"] == 78, (
                f"--cwe 78 should only include CWE-78 findings, got cwe_id={finding['cwe_id']}"
            )
        # Should have 2 CWE-78 findings (SC001 appears twice)
        assert len(report_json["findings"]) == 2, (
            f"Expected 2 CWE-78 findings, got {len(report_json['findings'])}"
        )

    def test_nonexistent_path_exits_one(self, tmp_path: Path) -> None:
        out = runner.invoke(app, ["report", str(tmp_path / "does-not-exist.json")])
        assert out.exit_code == 1, (
            f"Expected exit code 1 for nonexistent path, got {out.exit_code}:\n{out.output}"
        )

    def test_output_written_to_scan_result_dir_by_default(self, tmp_path: Path) -> None:
        """Without --output-dir the reports should land beside the scan-result.json."""
        out_dir = tmp_path / "results"
        result = _make_scan_result(tmp_path)
        scan_result = save_scan_result(result, out_dir)
        out = runner.invoke(
            app, ["report", str(scan_result), "--format", "markdown"]
        )
        assert out.exit_code == 0, f"Exit code {out.exit_code}:\n{out.output}"
        assert (out_dir / "report.md").exists(), (
            f"Expected report.md to be created in {out_dir} (beside scan-result.json)"
        )
