"""Regression Detection for MCP Security Audit.

Compares current scan results against a baseline or historical scan
to detect security regressions (new findings), improvements (resolved
findings), severity changes, and score deltas.

Designed for CI/CD integration with exit code 3 for new regressions.
Zero external dependencies. Python 3.11+ stdlib only.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


__all__ = [
    "Regression",
    "RegressionFinding",
    "RegressionResult",
    "ScoreDelta",
    "detect_regressions",
    "detect_regressions_from_files",
    "detect_regressions_from_history",
    "export_regression_html",
    "format_regression_report",
    "format_regression_text",
    "format_regression_json",
    "EXIT_CODE_REGRESSION",
]


EXIT_CODE_REGRESSION = 3


# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------

@dataclass
class Regression:
    """A finding that was previously fixed but has reappeared.

    Represents a high-priority security regression: something that was
    known, fixed, and then reintroduced.
    """

    rule_id: str
    file: str
    message: str
    first_seen: str = ""
    resolved_at: str = ""
    reintroduced_at: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "file": self.file,
            "message": self.message,
            "first_seen": self.first_seen,
            "resolved_at": self.resolved_at,
            "reintroduced_at": self.reintroduced_at,
        }


@dataclass
class RegressionFinding:
    """A finding categorized by its regression status.

    Attributes:
        status: One of 'new', 'resolved', 'severity_changed', 'unchanged'.
        finding: The finding dict.
        old_severity: Previous severity (only for severity_changed).
        new_severity: Current severity (only for severity_changed).
    """

    status: str
    finding: dict[str, Any]
    old_severity: str = ""
    new_severity: str = ""

    def to_dict(self) -> dict[str, Any]:
        result: dict[str, Any] = {
            "status": self.status,
            "finding": self.finding,
        }
        if self.old_severity:
            result["old_severity"] = self.old_severity
        if self.new_severity:
            result["new_severity"] = self.new_severity
        return result


@dataclass
class Regression:
    """A finding that was previously fixed but has reappeared.

    Represents a high-priority security regression: something that was
    known, fixed, and then reintroduced.
    """

    rule_id: str
    file: str
    message: str
    first_seen: str = ""
    resolved_at: str = ""
    reintroduced_at: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "file": self.file,
            "message": self.message,
            "first_seen": self.first_seen,
            "resolved_at": self.resolved_at,
            "reintroduced_at": self.reintroduced_at,
        }


@dataclass
class ScoreDelta:
    """Score comparison between baseline and current scan."""

    baseline_score: int = 0
    current_score: int = 0
    baseline_grade: str = ""
    current_grade: str = ""

    @property
    def delta(self) -> int:
        return self.current_score - self.baseline_score

    @property
    def improved(self) -> bool:
        return self.delta > 0

    @property
    def degraded(self) -> bool:
        return self.delta < 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "baseline_score": self.baseline_score,
            "current_score": self.current_score,
            "delta": self.delta,
            "baseline_grade": self.baseline_grade,
            "current_grade": self.current_grade,
            "improved": self.improved,
            "degraded": self.degraded,
        }


@dataclass
class RegressionResult:
    """Complete regression analysis result.

    Attributes:
        new_findings: Findings present in current but not in baseline.
        resolved_findings: Findings in baseline but not in current.
        severity_changed: Findings whose severity changed.
        unchanged: Findings present in both with same severity.
        score_delta: Trust score comparison.
        severity_delta: Per-severity count changes.
        baseline_source: Description of baseline origin.
        current_source: Description of current scan origin.
    """

    new_findings: list[RegressionFinding] = field(default_factory=list)
    resolved_findings: list[RegressionFinding] = field(default_factory=list)
    severity_changed: list[RegressionFinding] = field(default_factory=list)
    unchanged: list[RegressionFinding] = field(default_factory=list)
    score_delta: ScoreDelta = field(default_factory=ScoreDelta)
    severity_delta: dict[str, int] = field(default_factory=dict)
    baseline_source: str = ""
    current_source: str = ""

    @property
    def has_regressions(self) -> bool:
        """True if any new findings were detected."""
        return len(self.new_findings) > 0

    @property
    def has_improvements(self) -> bool:
        """True if any findings were resolved."""
        return len(self.resolved_findings) > 0

    @property
    def total_new(self) -> int:
        return len(self.new_findings)

    @property
    def total_resolved(self) -> int:
        return len(self.resolved_findings)

    @property
    def total_changed(self) -> int:
        return len(self.severity_changed)

    @property
    def total_unchanged(self) -> int:
        return len(self.unchanged)

    @property
    def exit_code(self) -> int:
        """Exit code for CI/CD: 0=no regressions, 3=regressions found."""
        if self.has_regressions:
            return EXIT_CODE_REGRESSION
        return 0

    @property
    def net_delta(self) -> int:
        """Net change in finding count (positive = more findings)."""
        return self.total_new - self.total_resolved

    def to_dict(self) -> dict[str, Any]:
        return {
            "summary": {
                "new": self.total_new,
                "resolved": self.total_resolved,
                "severity_changed": self.total_changed,
                "unchanged": self.total_unchanged,
                "has_regressions": self.has_regressions,
                "has_improvements": self.has_improvements,
                "net_delta": self.net_delta,
                "exit_code": self.exit_code,
            },
            "score_delta": self.score_delta.to_dict(),
            "severity_delta": self.severity_delta,
            "baseline_source": self.baseline_source,
            "current_source": self.current_source,
            "new_findings": [f.to_dict() for f in self.new_findings],
            "resolved_findings": [f.to_dict() for f in self.resolved_findings],
            "severity_changed": [f.to_dict() for f in self.severity_changed],
        }


# ---------------------------------------------------------------------------
# Finding Fingerprinting
# ---------------------------------------------------------------------------

def _finding_key(finding: dict[str, Any]) -> str:
    """Generate a stable key for matching findings across scans.

    Uses rule_id + file basename + title for resilience against
    line number changes.
    """
    rule_id = finding.get("rule_id", "")
    file_path = finding.get("file", "")
    file_base = Path(file_path).name if file_path else ""
    title = finding.get("title", "")
    raw = f"{rule_id}|{file_base}|{title}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]


# ---------------------------------------------------------------------------
# Finding Extraction
# ---------------------------------------------------------------------------

def _extract_findings(data: dict[str, Any]) -> list[dict[str, Any]]:
    """Extract finding dicts from various scan output formats."""
    findings: list[dict[str, Any]] = []

    # Standard mcpaudit output: {"results": [{"findings": [...]}]}
    if "results" in data:
        for r in data["results"]:
            if isinstance(r, dict) and "findings" in r:
                findings.extend(r["findings"])
        return findings

    # Baseline/flat format: {"findings": [...]}
    if "findings" in data and isinstance(data["findings"], list):
        return data["findings"]

    return findings


def _extract_score(data: dict[str, Any]) -> tuple[int, str]:
    """Extract trust score and grade from scan data."""
    summary = data.get("summary", {})
    trust = summary.get("trust_score", {})

    if isinstance(trust, dict) and trust:
        return trust.get("score", 0), trust.get("grade", "")
    if isinstance(trust, (int, float)):
        return int(trust), ""

    # Fallback: estimate from findings
    findings = _extract_findings(data)
    score = _estimate_score(findings)
    grade = summary.get("overall_grade", "")
    return score, grade


def _estimate_score(findings: list[dict[str, Any]]) -> int:
    """Estimate trust score from findings (100 = clean)."""
    if not findings:
        return 100
    weights = {
        "CRITICAL": 10,
        "HIGH": 5,
        "MEDIUM": 2,
        "LOW": 1,
        "INFO": 0,
    }
    penalty = sum(
        weights.get(f.get("severity", ""), 0) for f in findings
    )
    return max(0, 100 - penalty)


def _count_severities(
    findings: list[dict[str, Any]],
) -> dict[str, int]:
    """Count findings by severity level."""
    counts: dict[str, int] = {
        "CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0,
    }
    for f in findings:
        sev = f.get("severity", "INFO")
        if sev in counts:
            counts[sev] += 1
    return counts


# ---------------------------------------------------------------------------
# Core Regression Detection
# ---------------------------------------------------------------------------

def detect_regressions(
    baseline_data: dict[str, Any],
    current_data: dict[str, Any],
) -> RegressionResult:
    """Compare baseline scan against current scan to detect regressions.

    Args:
        baseline_data: Previous scan result dict.
        current_data: Current scan result dict.

    Returns:
        RegressionResult with categorized findings and score delta.
    """
    result = RegressionResult()

    baseline_findings = _extract_findings(baseline_data)
    current_findings = _extract_findings(current_data)

    # Index by stable key
    baseline_by_key: dict[str, dict[str, Any]] = {}
    for f in baseline_findings:
        key = _finding_key(f)
        baseline_by_key[key] = f

    current_by_key: dict[str, dict[str, Any]] = {}
    for f in current_findings:
        key = _finding_key(f)
        current_by_key[key] = f

    baseline_keys = set(baseline_by_key.keys())
    current_keys = set(current_by_key.keys())

    # New findings (regressions)
    for key in sorted(current_keys - baseline_keys):
        result.new_findings.append(RegressionFinding(
            status="new",
            finding=current_by_key[key],
        ))

    # Resolved findings (improvements)
    for key in sorted(baseline_keys - current_keys):
        result.resolved_findings.append(RegressionFinding(
            status="resolved",
            finding=baseline_by_key[key],
        ))

    # Unchanged or severity changed
    for key in sorted(baseline_keys & current_keys):
        bf = baseline_by_key[key]
        cf = current_by_key[key]
        old_sev = bf.get("severity", "")
        new_sev = cf.get("severity", "")

        if old_sev != new_sev:
            result.severity_changed.append(RegressionFinding(
                status="severity_changed",
                finding=cf,
                old_severity=old_sev,
                new_severity=new_sev,
            ))
        else:
            result.unchanged.append(RegressionFinding(
                status="unchanged",
                finding=cf,
            ))

    # Compute severity delta
    baseline_counts = _count_severities(baseline_findings)
    current_counts = _count_severities(current_findings)
    for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"):
        result.severity_delta[sev] = (
            current_counts.get(sev, 0) - baseline_counts.get(sev, 0)
        )

    # Compute score delta
    bs, bg = _extract_score(baseline_data)
    cs, cg = _extract_score(current_data)
    result.score_delta = ScoreDelta(
        baseline_score=bs,
        current_score=cs,
        baseline_grade=bg,
        current_grade=cg,
    )

    return result


def detect_regressions_from_files(
    baseline_path: Path,
    current_path: Path,
) -> RegressionResult:
    """Compare two scan JSON files for regressions.

    Args:
        baseline_path: Path to baseline scan JSON.
        current_path: Path to current scan JSON.

    Returns:
        RegressionResult with source paths set.
    """
    baseline_data = json.loads(
        baseline_path.read_text(encoding="utf-8")
    )
    current_data = json.loads(
        current_path.read_text(encoding="utf-8")
    )
    result = detect_regressions(baseline_data, current_data)
    result.baseline_source = str(baseline_path)
    result.current_source = str(current_path)
    return result


def detect_regressions_from_history(
    current_data: dict[str, Any],
    history_dir: Path,
) -> RegressionResult:
    """Compare current scan against the latest entry in scan history.

    Loads the most recent scan from history_dir and compares it
    against current_data.

    Args:
        current_data: Current scan result dict.
        history_dir: Directory containing scan-*.json history files.

    Returns:
        RegressionResult. If no history exists, returns empty result
        with current_source set to 'no_history'.
    """
    if not history_dir.exists():
        result = RegressionResult()
        result.current_source = "no_history"
        return result

    history_files = sorted(history_dir.glob("scan-*.json"))
    if not history_files:
        result = RegressionResult()
        result.current_source = "no_history"
        return result

    latest_file = history_files[-1]
    try:
        entry = json.loads(latest_file.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        result = RegressionResult()
        result.current_source = "history_parse_error"
        return result

    baseline_data = entry.get("scan", entry)
    result = detect_regressions(baseline_data, current_data)
    result.baseline_source = str(latest_file)
    result.current_source = "current_scan"
    return result


# ---------------------------------------------------------------------------
# Formatting: Text
# ---------------------------------------------------------------------------

def format_regression_text(result: RegressionResult) -> str:
    """Format regression result as human-readable text."""
    lines: list[str] = []
    lines.append(f"\n{'=' * 60}")
    lines.append("  mcpaudit — Regression Detection")
    lines.append(f"{'=' * 60}")

    if result.baseline_source:
        lines.append(f"  Baseline: {result.baseline_source}")
    if result.current_source:
        lines.append(f"  Current:  {result.current_source}")

    lines.append("")
    status = "REGRESSIONS DETECTED" if result.has_regressions else "NO REGRESSIONS"
    lines.append(f"  Status: {status}")
    lines.append(f"  New findings:       +{result.total_new}")
    lines.append(f"  Resolved findings:  -{result.total_resolved}")
    lines.append(f"  Severity changed:    {result.total_changed}")
    lines.append(f"  Unchanged:           {result.total_unchanged}")
    lines.append(f"  Net delta:           {_sign(result.net_delta)}{abs(result.net_delta)}")

    # Score delta
    sd = result.score_delta
    if sd.baseline_score or sd.current_score:
        delta_str = f"{_sign(sd.delta)}{abs(sd.delta)}"
        lines.append(f"\n  Trust score: {sd.baseline_score} -> {sd.current_score} ({delta_str})")
        if sd.baseline_grade or sd.current_grade:
            lines.append(f"  Grade:       {sd.baseline_grade} -> {sd.current_grade}")

    # Severity delta
    sev_parts = []
    for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
        d = result.severity_delta.get(sev, 0)
        if d != 0:
            sev_parts.append(f"{sev}: {_sign(d)}{abs(d)}")
    if sev_parts:
        lines.append(f"  Severity delta: {', '.join(sev_parts)}")

    lines.append(f"\n{'=' * 60}")

    # New findings (regressions)
    if result.new_findings:
        lines.append(f"\n  [+] NEW FINDINGS — REGRESSIONS ({result.total_new}):")
        lines.append(f"  {'-' * 50}")
        for rf in result.new_findings:
            _append_finding_line(lines, rf.finding, "+")

    # Resolved findings
    if result.resolved_findings:
        lines.append(f"\n  [-] RESOLVED FINDINGS ({result.total_resolved}):")
        lines.append(f"  {'-' * 50}")
        for rf in result.resolved_findings:
            _append_finding_line(lines, rf.finding, "-")

    # Severity changed
    if result.severity_changed:
        lines.append(f"\n  [~] SEVERITY CHANGED ({result.total_changed}):")
        lines.append(f"  {'-' * 50}")
        for rf in result.severity_changed:
            f = rf.finding
            rule = f.get("rule_id", "?")
            title = f.get("title", f.get("description", ""))
            lines.append(f"  ~ [{rule}] {title}: {rf.old_severity} -> {rf.new_severity}")

    lines.append("")
    if result.has_regressions:
        lines.append(f"  Exit code: {EXIT_CODE_REGRESSION} (regressions detected)")
    else:
        lines.append("  Exit code: 0 (no regressions)")
    lines.append("")
    return "\n".join(lines)


def _sign(n: int) -> str:
    """Return '+' for positive, '-' for negative, '' for zero."""
    if n > 0:
        return "+"
    if n < 0:
        return "-"
    return ""


def _append_finding_line(
    lines: list[str],
    finding: dict[str, Any],
    prefix: str,
) -> None:
    """Append a formatted finding line to output."""
    sev = finding.get("severity", "?")
    rule = finding.get("rule_id", "?")
    title = finding.get("title", finding.get("description", ""))
    file_name = finding.get("file", "")
    line_no = finding.get("line", 0)
    lines.append(f"  {prefix} [{sev}] {rule}: {title}")
    if file_name:
        lines.append(f"    File: {file_name}:{line_no}")


# ---------------------------------------------------------------------------
# Formatting: JSON
# ---------------------------------------------------------------------------

def format_regression_json(result: RegressionResult) -> str:
    """Format regression result as JSON."""
    return json.dumps(result.to_dict(), indent=2, ensure_ascii=False)


# Alias for API compatibility
format_regression_report = format_regression_text


# ---------------------------------------------------------------------------
# Formatting: HTML Dashboard
# ---------------------------------------------------------------------------

def _html_esc(text: str) -> str:
    """Escape HTML special characters."""
    return (
        text
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&#39;")
    )


def export_regression_html(result: RegressionResult) -> str:
    """Export regression result as an HTML dashboard.

    Features:
    - Summary cards with trend, grade, and delta
    - Color-coded severity indicators
    - Tables for new, resolved, and severity-changed findings
    - Responsive dark theme matching mcpaudit style

    Args:
        result: RegressionResult to export.

    Returns:
        Complete HTML document string.
    """
    if result.has_regressions:
        trend_label = "REGRESSIONS"
        trend_color = "#ef4444"
    elif result.has_improvements and not result.has_regressions:
        trend_label = "IMPROVING"
        trend_color = "#22c55e"
    elif result.net_delta == 0:
        trend_label = "STABLE"
        trend_color = "#3b82f6"
    else:
        trend_label = "CHANGED"
        trend_color = "#eab308"

    sd = result.score_delta
    grade_colors = {
        "A+": "#22c55e", "A": "#22c55e", "B": "#eab308",
        "C": "#f97316", "D": "#ef4444", "F": "#dc2626",
    }
    current_gc = grade_colors.get(sd.current_grade, "#6b7280")
    delta_color = (
        "#ef4444" if sd.delta < 0
        else "#22c55e" if sd.delta > 0
        else "#3b82f6"
    )
    delta_str = f"+{sd.delta}" if sd.delta > 0 else str(sd.delta)

    sev_colors = {
        "CRITICAL": "#ef4444", "HIGH": "#f97316",
        "MEDIUM": "#eab308", "LOW": "#22c55e", "INFO": "#6b7280",
    }

    # New findings table
    new_rows = ""
    for rf in result.new_findings[:30]:
        f = rf.finding
        sev = f.get("severity", "INFO")
        sc = sev_colors.get(sev, "#6b7280")
        new_rows += (
            f"<tr><td>{_html_esc(f.get('rule_id', ''))}</td>"
            f"<td style='color:{sc}'>{sev}</td>"
            f"<td>{_html_esc(f.get('file', ''))}</td>"
            f"<td>{_html_esc(f.get('title', f.get('description', ''))[:60])}</td></tr>"
        )

    new_section = ""
    if result.new_findings:
        new_section = (
            f'<h3 style="color:#ef4444;margin:24px 0 12px">'
            f"New Findings ({result.total_new})</h3>"
            f"<table><thead><tr><th>Rule</th><th>Severity</th>"
            f"<th>File</th><th>Title</th></tr></thead>"
            f"<tbody>{new_rows}</tbody></table>"
        )

    # Resolved findings table
    resolved_rows = ""
    for rf in result.resolved_findings[:20]:
        f = rf.finding
        resolved_rows += (
            f"<tr><td>{_html_esc(f.get('rule_id', ''))}</td>"
            f"<td>{f.get('severity', '')}</td>"
            f"<td>{_html_esc(f.get('file', ''))}</td>"
            f"<td>{_html_esc(f.get('title', f.get('description', ''))[:60])}</td></tr>"
        )

    resolved_section = ""
    if result.resolved_findings:
        resolved_section = (
            f'<h3 style="color:#22c55e;margin:24px 0 12px">'
            f"Resolved ({result.total_resolved})</h3>"
            f"<table><thead><tr><th>Rule</th><th>Severity</th>"
            f"<th>File</th><th>Title</th></tr></thead>"
            f"<tbody>{resolved_rows}</tbody></table>"
        )

    # Severity changed table
    changed_section = ""
    if result.severity_changed:
        changed_rows = ""
        for rf in result.severity_changed:
            f = rf.finding
            changed_rows += (
                f"<tr><td>{_html_esc(f.get('rule_id', ''))}</td>"
                f"<td>{rf.old_severity}</td>"
                f"<td>{rf.new_severity}</td>"
                f"<td>{_html_esc(f.get('file', ''))}</td></tr>"
            )
        changed_section = (
            f'<h3 style="color:#eab308;margin:24px 0 12px">'
            f"Severity Changed ({result.total_changed})</h3>"
            f"<table><thead><tr><th>Rule</th><th>Old</th><th>New</th>"
            f"<th>File</th></tr></thead>"
            f"<tbody>{changed_rows}</tbody></table>"
        )

    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mcpaudit Regression Report</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
       background: #0f172a; color: #e2e8f0; padding: 24px; max-width: 1200px; margin: 0 auto; }}
h1 {{ font-size: 1.5rem; margin-bottom: 8px; }}
.subtitle {{ color: #94a3b8; margin-bottom: 24px; }}
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
          gap: 16px; margin-bottom: 32px; }}
.card {{ background: #1e293b; border-radius: 12px; padding: 20px; }}
.card-label {{ font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; }}
.card-value {{ font-size: 2rem; font-weight: 700; margin-top: 4px; }}
table {{ width: 100%; border-collapse: collapse; background: #1e293b;
         border-radius: 12px; overflow: hidden; }}
th {{ text-align: left; padding: 12px 16px; background: #334155;
      font-size: 0.8rem; text-transform: uppercase; color: #94a3b8; }}
td {{ padding: 10px 16px; border-bottom: 1px solid #334155; font-size: 0.9rem; }}
tr:hover {{ background: #1e3a5f; }}
.footer {{ text-align: center; color: #475569; margin-top: 32px; font-size: 0.8rem; }}
</style>
</head>
<body>
<h1>mcpaudit Regression Report</h1>
<p class="subtitle">Security trend analysis and regression detection</p>
<div class="cards">
    <div class="card">
        <div class="card-label">Status</div>
        <div class="card-value" style="color:{trend_color}">{trend_label}</div>
    </div>
    <div class="card">
        <div class="card-label">Current Grade</div>
        <div class="card-value" style="color:{current_gc}">{sd.current_grade or '?'}</div>
    </div>
    <div class="card">
        <div class="card-label">Score Delta</div>
        <div class="card-value" style="color:{delta_color}">{delta_str}</div>
    </div>
    <div class="card">
        <div class="card-label">New</div>
        <div class="card-value" style="color:#ef4444">{result.total_new}</div>
    </div>
    <div class="card">
        <div class="card-label">Resolved</div>
        <div class="card-value" style="color:#22c55e">{result.total_resolved}</div>
    </div>
    <div class="card">
        <div class="card-label">Net Delta</div>
        <div class="card-value">{_sign(result.net_delta)}{abs(result.net_delta)}</div>
    </div>
</div>
{new_section}
{resolved_section}
{changed_section}
<p class="footer">Generated by mcpaudit</p>
</body>
</html>"""
