"""Tests for regression detection — compare scans for security regressions."""

from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest

from mcpaudit.regression import (
    EXIT_CODE_REGRESSION,
    RegressionFinding,
    RegressionResult,
    ScoreDelta,
    detect_regressions,
    detect_regressions_from_files,
    detect_regressions_from_history,
    format_regression_json,
    format_regression_text,
    _finding_key,
    _extract_findings,
    _extract_score,
    _estimate_score,
    _count_severities,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _finding(rule_id: str = "CMD-001", severity: str = "HIGH",
             title: str = "Test Finding", file: str = "server.py",
             line: int = 10, code: str = "subprocess.run(cmd)") -> dict:
    return {
        "rule_id": rule_id,
        "severity": severity,
        "title": title,
        "description": f"Finding: {title}",
        "file": file,
        "line": line,
        "code_snippet": code,
    }


def _scan_data(findings: list[dict], trust_score: int = 80,
               grade: str = "B") -> dict:
    return {
        "summary": {
            "files_scanned": 1,
            "total_findings": len(findings),
            "overall_grade": grade,
            "trust_score": {"score": trust_score, "grade": grade},
        },
        "results": [{"file": "server.py", "findings": findings}],
    }


def _write_json(data: dict, tmp_path: Path, name: str = "scan.json") -> Path:
    """Write dict to JSON file in tmp_path."""
    f = tmp_path / name
    f.write_text(json.dumps(data, indent=2), encoding="utf-8")
    return f


def _write_history(entries: list[dict], history_dir: Path) -> None:
    """Write scan history entries to files."""
    history_dir.mkdir(parents=True, exist_ok=True)
    for i, entry in enumerate(entries):
        fname = f"scan-{20240101 + i}T000000Z.json"
        (history_dir / fname).write_text(json.dumps(entry), encoding="utf-8")


# ---------------------------------------------------------------------------
# RegressionFinding
# ---------------------------------------------------------------------------

class TestRegressionFinding:
    def test_to_dict_new(self) -> None:
        rf = RegressionFinding(status="new", finding=_finding())
        d = rf.to_dict()
        assert d["status"] == "new"
        assert "finding" in d

    def test_to_dict_severity_changed(self) -> None:
        rf = RegressionFinding(
            status="severity_changed",
            finding=_finding(),
            old_severity="MEDIUM",
            new_severity="HIGH",
        )
        d = rf.to_dict()
        assert d["old_severity"] == "MEDIUM"
        assert d["new_severity"] == "HIGH"

    def test_to_dict_no_old_severity(self) -> None:
        rf = RegressionFinding(status="resolved", finding=_finding())
        d = rf.to_dict()
        assert "old_severity" not in d


# ---------------------------------------------------------------------------
# ScoreDelta
# ---------------------------------------------------------------------------

class TestScoreDelta:
    def test_delta_positive(self) -> None:
        sd = ScoreDelta(baseline_score=70, current_score=90)
        assert sd.delta == 20
        assert sd.improved is True
        assert sd.degraded is False

    def test_delta_negative(self) -> None:
        sd = ScoreDelta(baseline_score=90, current_score=70)
        assert sd.delta == -20
        assert sd.improved is False
        assert sd.degraded is True

    def test_delta_zero(self) -> None:
        sd = ScoreDelta(baseline_score=80, current_score=80)
        assert sd.delta == 0
        assert sd.improved is False
        assert sd.degraded is False

    def test_to_dict(self) -> None:
        sd = ScoreDelta(
            baseline_score=70, current_score=90,
            baseline_grade="C", current_grade="A",
        )
        d = sd.to_dict()
        assert d["delta"] == 20
        assert d["improved"] is True
        assert d["baseline_grade"] == "C"


# ---------------------------------------------------------------------------
# RegressionResult
# ---------------------------------------------------------------------------

class TestRegressionResult:
    def test_has_regressions(self) -> None:
        result = RegressionResult()
        result.new_findings.append(
            RegressionFinding(status="new", finding=_finding())
        )
        assert result.has_regressions is True
        assert result.exit_code == EXIT_CODE_REGRESSION

    def test_no_regressions(self) -> None:
        result = RegressionResult()
        assert result.has_regressions is False
        assert result.exit_code == 0

    def test_has_improvements(self) -> None:
        result = RegressionResult()
        result.resolved_findings.append(
            RegressionFinding(status="resolved", finding=_finding())
        )
        assert result.has_improvements is True

    def test_counts(self) -> None:
        result = RegressionResult()
        result.new_findings = [RegressionFinding(status="new", finding=_finding())] * 3
        result.resolved_findings = [RegressionFinding(status="resolved", finding=_finding())] * 2
        result.severity_changed = [RegressionFinding(status="severity_changed", finding=_finding())]
        result.unchanged = [RegressionFinding(status="unchanged", finding=_finding())] * 5
        assert result.total_new == 3
        assert result.total_resolved == 2
        assert result.total_changed == 1
        assert result.total_unchanged == 5

    def test_net_delta(self) -> None:
        result = RegressionResult()
        result.new_findings = [RegressionFinding(status="new", finding=_finding())] * 3
        result.resolved_findings = [RegressionFinding(status="resolved", finding=_finding())]
        assert result.net_delta == 2

    def test_to_dict(self) -> None:
        result = RegressionResult()
        result.new_findings.append(
            RegressionFinding(status="new", finding=_finding())
        )
        d = result.to_dict()
        assert d["summary"]["new"] == 1
        assert d["summary"]["has_regressions"] is True
        assert d["summary"]["exit_code"] == EXIT_CODE_REGRESSION
        assert len(d["new_findings"]) == 1


# ---------------------------------------------------------------------------
# Finding key
# ---------------------------------------------------------------------------

class TestFindingKey:
    def test_stable_across_line_changes(self) -> None:
        f1 = _finding(line=10)
        f2 = _finding(line=20)
        assert _finding_key(f1) == _finding_key(f2)

    def test_different_rules(self) -> None:
        f1 = _finding(rule_id="CMD-001")
        f2 = _finding(rule_id="SQL-001")
        assert _finding_key(f1) != _finding_key(f2)

    def test_different_files(self) -> None:
        f1 = _finding(file="a.py")
        f2 = _finding(file="b.py")
        assert _finding_key(f1) != _finding_key(f2)

    def test_same_basename_different_dir(self) -> None:
        f1 = _finding(file="/a/server.py")
        f2 = _finding(file="/b/server.py")
        # Same basename = same key (by design)
        assert _finding_key(f1) == _finding_key(f2)


# ---------------------------------------------------------------------------
# Extract findings
# ---------------------------------------------------------------------------

class TestExtractFindings:
    def test_standard_format(self) -> None:
        data = _scan_data([_finding()])
        findings = _extract_findings(data)
        assert len(findings) == 1

    def test_flat_format(self) -> None:
        data = {"findings": [_finding(), _finding()]}
        findings = _extract_findings(data)
        assert len(findings) == 2

    def test_empty(self) -> None:
        findings = _extract_findings({})
        assert findings == []

    def test_results_without_findings(self) -> None:
        data = {"results": [{"file": "a.py"}]}
        findings = _extract_findings(data)
        assert findings == []


# ---------------------------------------------------------------------------
# Score extraction
# ---------------------------------------------------------------------------

class TestExtractScore:
    def test_dict_trust_score(self) -> None:
        data = {"summary": {"trust_score": {"score": 85, "grade": "A"}}}
        score, grade = _extract_score(data)
        assert score == 85
        assert grade == "A"

    def test_int_trust_score(self) -> None:
        data = {"summary": {"trust_score": 90}}
        score, grade = _extract_score(data)
        assert score == 90

    def test_no_trust_score(self) -> None:
        data = _scan_data([])
        # When trust_score exists as dict with score=80
        score, _ = _extract_score(data)
        assert score == 80

    def test_no_summary(self) -> None:
        # {} has no summary, so trust_score defaults to {} (empty dict)
        # Empty dict falls through to estimate, no findings = 100
        score, _ = _extract_score({})
        assert score == 100

    def test_fallback_to_estimate(self) -> None:
        # When trust is not a dict or int, falls back to estimate
        data = {"summary": {"trust_score": None}, "results": []}
        score, _ = _extract_score(data)
        assert score == 100  # No findings = 100


# ---------------------------------------------------------------------------
# Estimate score
# ---------------------------------------------------------------------------

class TestEstimateScore:
    def test_no_findings(self) -> None:
        assert _estimate_score([]) == 100

    def test_critical_findings(self) -> None:
        findings = [_finding(severity="CRITICAL")]
        score = _estimate_score(findings)
        assert score == 90  # 100 - 10

    def test_multiple_findings(self) -> None:
        findings = [
            _finding(severity="CRITICAL"),
            _finding(severity="HIGH"),
        ]
        score = _estimate_score(findings)
        assert score == 85  # 100 - 10 - 5

    def test_floor_at_zero(self) -> None:
        findings = [_finding(severity="CRITICAL")] * 15
        score = _estimate_score(findings)
        assert score == 0


# ---------------------------------------------------------------------------
# Count severities
# ---------------------------------------------------------------------------

class TestCountSeverities:
    def test_counts(self) -> None:
        findings = [
            _finding(severity="CRITICAL"),
            _finding(severity="HIGH"),
            _finding(severity="HIGH"),
        ]
        counts = _count_severities(findings)
        assert counts["CRITICAL"] == 1
        assert counts["HIGH"] == 2
        assert counts["MEDIUM"] == 0

    def test_empty(self) -> None:
        counts = _count_severities([])
        assert all(v == 0 for v in counts.values())


# ---------------------------------------------------------------------------
# detect_regressions
# ---------------------------------------------------------------------------

class TestDetectRegressions:
    def test_new_findings(self) -> None:
        baseline = _scan_data([])
        current = _scan_data([_finding("CMD-001"), _finding("SQL-001")])
        result = detect_regressions(baseline, current)
        assert result.total_new == 2
        assert result.total_resolved == 0
        assert result.has_regressions is True

    def test_resolved_findings(self) -> None:
        baseline = _scan_data([_finding("CMD-001")])
        current = _scan_data([])
        result = detect_regressions(baseline, current)
        assert result.total_new == 0
        assert result.total_resolved == 1
        assert result.has_improvements is True

    def test_severity_changed(self) -> None:
        baseline = _scan_data([_finding("CMD-001", severity="MEDIUM")])
        current = _scan_data([_finding("CMD-001", severity="CRITICAL")])
        result = detect_regressions(baseline, current)
        assert result.total_changed == 1
        assert result.severity_changed[0].old_severity == "MEDIUM"
        assert result.severity_changed[0].new_severity == "CRITICAL"

    def test_unchanged(self) -> None:
        f = _finding("CMD-001")
        baseline = _scan_data([f])
        current = _scan_data([f])
        result = detect_regressions(baseline, current)
        assert result.total_unchanged == 1
        assert result.total_new == 0

    def test_mixed_changes(self) -> None:
        baseline = _scan_data([
            _finding("CMD-001", title="Old Issue"),
            _finding("PATH-001", title="Path Issue"),
        ])
        current = _scan_data([
            _finding("CMD-001", title="Old Issue"),  # unchanged
            _finding("SQL-001", title="New SQL"),  # new
        ])
        result = detect_regressions(baseline, current)
        assert result.total_new == 1
        assert result.total_resolved == 1
        assert result.total_unchanged == 1

    def test_severity_delta(self) -> None:
        baseline = _scan_data([_finding(severity="HIGH")])
        current = _scan_data([
            _finding(rule_id="SQL-001", severity="CRITICAL"),
        ])
        result = detect_regressions(baseline, current)
        assert result.severity_delta["CRITICAL"] == 1
        assert result.severity_delta["HIGH"] == -1

    def test_score_delta(self) -> None:
        baseline = _scan_data([], trust_score=90, grade="A")
        current = _scan_data([_finding(severity="CRITICAL")], trust_score=70, grade="C")
        result = detect_regressions(baseline, current)
        assert result.score_delta.baseline_score == 90
        assert result.score_delta.current_score == 70
        assert result.score_delta.degraded is True

    def test_empty_both(self) -> None:
        result = detect_regressions(_scan_data([]), _scan_data([]))
        assert result.total_new == 0
        assert result.total_resolved == 0
        assert result.has_regressions is False

    def test_exit_code_with_regressions(self) -> None:
        baseline = _scan_data([])
        current = _scan_data([_finding()])
        result = detect_regressions(baseline, current)
        assert result.exit_code == EXIT_CODE_REGRESSION

    def test_exit_code_without_regressions(self) -> None:
        result = detect_regressions(_scan_data([_finding()]), _scan_data([_finding()]))
        assert result.exit_code == 0


# ---------------------------------------------------------------------------
# detect_regressions_from_files
# ---------------------------------------------------------------------------

class TestDetectRegressionsFromFiles:
    def test_compare_files(self, tmp_path: Path) -> None:
        baseline = _write_json(_scan_data([_finding()]), tmp_path, "baseline.json")
        current = _write_json(
            _scan_data([_finding(), _finding("SQL-001", title="New")]),
            tmp_path, "current.json",
        )
        result = detect_regressions_from_files(baseline, current)
        assert result.total_new == 1
        assert result.baseline_source == str(baseline)
        assert result.current_source == str(current)

    def test_identical_files(self, tmp_path: Path) -> None:
        data = _scan_data([_finding()])
        f1 = _write_json(data, tmp_path, "a.json")
        f2 = _write_json(data, tmp_path, "b.json")
        result = detect_regressions_from_files(f1, f2)
        assert result.total_new == 0
        assert result.total_resolved == 0


# ---------------------------------------------------------------------------
# detect_regressions_from_history
# ---------------------------------------------------------------------------

class TestDetectRegressionsFromHistory:
    def test_compare_against_history(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "history"
        _write_history([
            {"timestamp": "20240101T000000Z", "scan": _scan_data([_finding()])}
        ], history_dir)
        current = _scan_data([_finding(), _finding("SQL-001", title="New")])
        result = detect_regressions_from_history(current, history_dir)
        assert result.total_new == 1
        assert "history" in result.baseline_source

    def test_no_history(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "no-history"
        current = _scan_data([_finding()])
        result = detect_regressions_from_history(current, history_dir)
        assert result.current_source == "no_history"
        assert result.total_new == 0

    def test_empty_history_dir(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "empty-history"
        history_dir.mkdir()
        current = _scan_data([_finding()])
        result = detect_regressions_from_history(current, history_dir)
        assert result.current_source == "no_history"

    def test_uses_latest_history(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "history"
        _write_history([
            {"timestamp": "20240101T000000Z", "scan": _scan_data([_finding("OLD-001", title="Old")])},
            {"timestamp": "20240102T000000Z", "scan": _scan_data([_finding("CMD-001")])},
        ], history_dir)
        current = _scan_data([_finding("CMD-001"), _finding("SQL-001", title="New")])
        result = detect_regressions_from_history(current, history_dir)
        assert result.total_new == 1  # SQL-001 is new vs latest
        new_rules = [rf.finding.get("rule_id") for rf in result.new_findings]
        assert "SQL-001" in new_rules

    def test_corrupt_history(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "corrupt"
        history_dir.mkdir()
        (history_dir / "scan-20240101T000000Z.json").write_text("not json")
        current = _scan_data([_finding()])
        result = detect_regressions_from_history(current, history_dir)
        assert result.current_source == "history_parse_error"


# ---------------------------------------------------------------------------
# Format: Text
# ---------------------------------------------------------------------------

class TestFormatText:
    def test_regressions_header(self) -> None:
        result = RegressionResult()
        result.new_findings.append(RegressionFinding(status="new", finding=_finding()))
        text = format_regression_text(result)
        assert "REGRESSIONS DETECTED" in text

    def test_no_regressions_header(self) -> None:
        result = RegressionResult()
        text = format_regression_text(result)
        assert "NO REGRESSIONS" in text

    def test_new_findings_section(self) -> None:
        result = RegressionResult()
        result.new_findings.append(RegressionFinding(status="new", finding=_finding("CMD-001")))
        text = format_regression_text(result)
        assert "CMD-001" in text
        assert "NEW FINDINGS" in text

    def test_resolved_section(self) -> None:
        result = RegressionResult()
        result.resolved_findings.append(
            RegressionFinding(status="resolved", finding=_finding("SQL-001"))
        )
        text = format_regression_text(result)
        assert "RESOLVED" in text
        assert "SQL-001" in text

    def test_severity_changed_section(self) -> None:
        result = RegressionResult()
        result.severity_changed.append(
            RegressionFinding(
                status="severity_changed",
                finding=_finding(),
                old_severity="MEDIUM",
                new_severity="HIGH",
            )
        )
        text = format_regression_text(result)
        assert "SEVERITY CHANGED" in text
        assert "MEDIUM" in text

    def test_score_delta_in_text(self) -> None:
        result = RegressionResult()
        result.score_delta = ScoreDelta(baseline_score=90, current_score=70)
        text = format_regression_text(result)
        assert "90" in text
        assert "70" in text

    def test_exit_code_in_text(self) -> None:
        result = RegressionResult()
        result.new_findings.append(RegressionFinding(status="new", finding=_finding()))
        text = format_regression_text(result)
        assert str(EXIT_CODE_REGRESSION) in text

    def test_sources_in_text(self) -> None:
        result = RegressionResult()
        result.baseline_source = "/path/baseline.json"
        result.current_source = "/path/current.json"
        text = format_regression_text(result)
        assert "baseline.json" in text
        assert "current.json" in text


# ---------------------------------------------------------------------------
# Format: JSON
# ---------------------------------------------------------------------------

class TestFormatJson:
    def test_valid_json(self) -> None:
        result = RegressionResult()
        result.new_findings.append(RegressionFinding(status="new", finding=_finding()))
        output = format_regression_json(result)
        data = json.loads(output)
        assert data["summary"]["new"] == 1
        assert data["summary"]["exit_code"] == EXIT_CODE_REGRESSION

    def test_empty_result(self) -> None:
        result = RegressionResult()
        output = format_regression_json(result)
        data = json.loads(output)
        assert data["summary"]["new"] == 0
        assert data["summary"]["has_regressions"] is False

    def test_contains_all_sections(self) -> None:
        result = RegressionResult()
        output = format_regression_json(result)
        data = json.loads(output)
        assert "summary" in data
        assert "score_delta" in data
        assert "severity_delta" in data
        assert "new_findings" in data
        assert "resolved_findings" in data


# ---------------------------------------------------------------------------
# CLI integration
# ---------------------------------------------------------------------------

class TestRegressionCLI:
    def test_baseline_comparison(self, tmp_path: Path) -> None:
        baseline = _write_json(_scan_data([_finding()]), tmp_path, "baseline.json")
        current = _write_json(
            _scan_data([_finding(), _finding("SQL-001", title="New")]),
            tmp_path, "current.json",
        )
        from mcpaudit.__main__ import main
        exit_code = main([
            "regression", str(current),
            "--baseline", str(baseline),
        ])
        assert exit_code == EXIT_CODE_REGRESSION

    def test_history_comparison(self, tmp_path: Path) -> None:
        history_dir = tmp_path / "history"
        _write_history([
            {"timestamp": "20240101T000000Z", "scan": _scan_data([_finding()])}
        ], history_dir)
        current_file = _write_json(
            _scan_data([_finding()]),
            tmp_path, "current.json",
        )
        from mcpaudit.__main__ import main
        exit_code = main([
            "regression", str(current_file),
            "--history-dir", str(history_dir),
        ])
        assert exit_code == 0  # No regressions (same findings)

    def test_json_output(self, tmp_path: Path) -> None:
        baseline = _write_json(_scan_data([]), tmp_path, "baseline.json")
        current = _write_json(_scan_data([_finding()]), tmp_path, "current.json")
        out = tmp_path / "regression.json"
        from mcpaudit.__main__ import main
        exit_code = main([
            "regression", str(current),
            "--baseline", str(baseline),
            "--format", "json",
            "-o", str(out),
        ])
        data = json.loads(out.read_text())
        assert data["summary"]["new"] == 1

    def test_nonexistent_baseline(self, tmp_path: Path) -> None:
        current = _write_json(_scan_data([]), tmp_path, "current.json")
        from mcpaudit.__main__ import main
        exit_code = main([
            "regression", str(current),
            "--baseline", "/nonexistent/baseline.json",
        ])
        assert exit_code == 1

    def test_nonexistent_current(self, tmp_path: Path) -> None:
        baseline = _write_json(_scan_data([]), tmp_path, "baseline.json")
        from mcpaudit.__main__ import main
        exit_code = main([
            "regression", "/nonexistent/current.json",
            "--baseline", str(baseline),
        ])
        assert exit_code == 1
