"""Scan Diff Reporter — Detailed diff reports between consecutive scans.

Generates structured diff reports comparing scan results across time,
with summary statistics, improvement scoring, and multi-format export.

Features:
- ScanDiffEntry: individual finding diff with status tracking
- DiffSummary: aggregated counts, net delta, improvement score
- DiffReporter: compare two scan result dicts
- Time-series diff: compare across multiple timestamps
- Export as Markdown table and JSON

Zero external dependencies — pure Python stdlib.

Usage:
    from mcpaudit.scan_diff_reporter import DiffReporter
    reporter = DiffReporter()
    summary = reporter.compare(scan_before, scan_after)
    print(reporter.format_markdown(summary))
    print(reporter.format_json(summary))

CLI hint:
    mcpaudit diff-report scan1.json scan2.json --format markdown
"""

from __future__ import annotations

import hashlib
import json
import math
from dataclasses import dataclass, field
from typing import Any


# ---------- Constants ----------

class DiffStatus:
    """Status constants for diff entries."""
    NEW = "NEW"
    FIXED = "FIXED"
    UNCHANGED = "UNCHANGED"
    MODIFIED = "MODIFIED"

    ALL = ("NEW", "FIXED", "UNCHANGED", "MODIFIED")


class Severity:
    """Severity level constants."""
    CRITICAL = "CRITICAL"
    HIGH = "HIGH"
    MEDIUM = "MEDIUM"
    LOW = "LOW"
    INFO = "INFO"

    ALL = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO")


_SEVERITY_WEIGHT: dict[str, int] = {
    Severity.CRITICAL: 10,
    Severity.HIGH: 5,
    Severity.MEDIUM: 2,
    Severity.LOW: 1,
    Severity.INFO: 0,
}


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

@dataclass
class ScanDiffEntry:
    """A single diff entry representing a finding's change between scans."""

    finding_id: str
    status: str  # DiffStatus.NEW / FIXED / UNCHANGED / MODIFIED
    severity: str = Severity.INFO
    file: str = ""
    line: int = 0
    title: str = ""
    rule_id: str = ""
    previous_severity: str = ""

    def to_dict(self) -> dict[str, Any]:
        d: dict[str, Any] = {
            "finding_id": self.finding_id,
            "status": self.status,
            "severity": self.severity,
            "file": self.file,
            "line": self.line,
            "title": self.title,
            "rule_id": self.rule_id,
        }
        if self.previous_severity:
            d["previous_severity"] = self.previous_severity
        return d


@dataclass
class DiffSummary:
    """Aggregated summary of differences between two scans."""

    entries: list[ScanDiffEntry] = field(default_factory=list)
    scan_before_id: str = ""
    scan_after_id: str = ""
    timestamp_before: str = ""
    timestamp_after: str = ""

    @property
    def new_count(self) -> int:
        return sum(1 for e in self.entries if e.status == DiffStatus.NEW)

    @property
    def fixed_count(self) -> int:
        return sum(1 for e in self.entries if e.status == DiffStatus.FIXED)

    @property
    def unchanged_count(self) -> int:
        return sum(1 for e in self.entries if e.status == DiffStatus.UNCHANGED)

    @property
    def modified_count(self) -> int:
        return sum(1 for e in self.entries if e.status == DiffStatus.MODIFIED)

    @property
    def net_delta(self) -> int:
        """Net change in findings count (positive = more findings = worse)."""
        return self.new_count - self.fixed_count

    @property
    def improvement_score(self) -> float:
        """Score from -100 (much worse) to +100 (much better).

        Based on weighted severity of fixed vs new findings.
        """
        fixed_weight = 0
        new_weight = 0
        for e in self.entries:
            w = _SEVERITY_WEIGHT.get(e.severity, 0)
            if e.status == DiffStatus.FIXED:
                fixed_weight += w
            elif e.status == DiffStatus.NEW:
                new_weight += w

        total = fixed_weight + new_weight
        if total == 0:
            return 0.0

        raw = (fixed_weight - new_weight) / total * 100
        return round(raw, 2)

    @property
    def severity_breakdown(self) -> dict[str, dict[str, int]]:
        """Breakdown of new/fixed counts by severity."""
        breakdown: dict[str, dict[str, int]] = {}
        for sev in Severity.ALL:
            breakdown[sev] = {"new": 0, "fixed": 0}

        for e in self.entries:
            if e.severity in breakdown:
                if e.status == DiffStatus.NEW:
                    breakdown[e.severity]["new"] += 1
                elif e.status == DiffStatus.FIXED:
                    breakdown[e.severity]["fixed"] += 1
        return breakdown

    def to_dict(self) -> dict[str, Any]:
        return {
            "scan_before_id": self.scan_before_id,
            "scan_after_id": self.scan_after_id,
            "timestamp_before": self.timestamp_before,
            "timestamp_after": self.timestamp_after,
            "counts": {
                "new": self.new_count,
                "fixed": self.fixed_count,
                "unchanged": self.unchanged_count,
                "modified": self.modified_count,
            },
            "net_delta": self.net_delta,
            "improvement_score": self.improvement_score,
            "severity_breakdown": self.severity_breakdown,
            "entries": [e.to_dict() for e in self.entries],
        }


@dataclass
class TimeSeriesPoint:
    """A single point in a time-series diff analysis."""

    timestamp: str
    total_findings: int = 0
    new_count: int = 0
    fixed_count: int = 0
    net_delta: int = 0
    improvement_score: float = 0.0

    def to_dict(self) -> dict[str, Any]:
        return {
            "timestamp": self.timestamp,
            "total_findings": self.total_findings,
            "new_count": self.new_count,
            "fixed_count": self.fixed_count,
            "net_delta": self.net_delta,
            "improvement_score": self.improvement_score,
        }


@dataclass
class TimeSeriesReport:
    """Report spanning multiple timestamps."""

    points: list[TimeSeriesPoint] = field(default_factory=list)
    overall_trend: str = ""  # "improving", "degrading", "stable"
    total_new_across_all: int = 0
    total_fixed_across_all: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "points": [p.to_dict() for p in self.points],
            "overall_trend": self.overall_trend,
            "total_new_across_all": self.total_new_across_all,
            "total_fixed_across_all": self.total_fixed_across_all,
        }


# ---------- Helper Functions ----------

def _finding_id(finding: dict[str, Any]) -> str:
    """Generate stable ID for a finding dict."""
    rule_id = finding.get("rule_id", "")
    file_path = finding.get("file", "")
    title = finding.get("title", finding.get("description", ""))
    raw = f"{rule_id}|{file_path}|{title}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]


def _extract_findings(data: dict[str, Any]) -> list[dict[str, Any]]:
    """Extract findings from various scan output formats."""
    if "results" in data:
        findings: list[dict[str, Any]] = []
        for r in data["results"]:
            if isinstance(r, dict) and "findings" in r:
                findings.extend(r["findings"])
        return findings

    if "findings" in data and isinstance(data["findings"], list):
        return data["findings"]

    return []


def _extract_timestamp(data: dict[str, Any]) -> str:
    """Extract timestamp from scan data."""
    if "timestamp" in data:
        return str(data["timestamp"])
    summary = data.get("summary", {})
    if isinstance(summary, dict) and "timestamp" in summary:
        return str(summary["timestamp"])
    return ""


def _extract_scan_id(data: dict[str, Any]) -> str:
    """Extract or generate scan ID."""
    if "scan_id" in data:
        return str(data["scan_id"])
    raw = json.dumps(data, sort_keys=True, default=str)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]


# ---------- DiffReporter ----------

class DiffReporter:
    """Generates detailed diff reports between consecutive scans.

    Compares two scan result dicts, categorizes each finding as
    NEW, FIXED, UNCHANGED, or MODIFIED, and produces a DiffSummary
    with aggregated statistics and an improvement score.
    """

    def compare(self, before: dict[str, Any], after: dict[str, Any]) -> DiffSummary:
        """Compare two scan results and produce a DiffSummary.

        Args:
            before: Scan data from the earlier scan.
            after: Scan data from the later scan.

        Returns:
            DiffSummary with categorized entries and stats.
        """
        summary = DiffSummary(
            scan_before_id=_extract_scan_id(before),
            scan_after_id=_extract_scan_id(after),
            timestamp_before=_extract_timestamp(before),
            timestamp_after=_extract_timestamp(after),
        )

        before_findings = _extract_findings(before)
        after_findings = _extract_findings(after)

        before_by_id: dict[str, dict[str, Any]] = {}
        for f in before_findings:
            fid = _finding_id(f)
            before_by_id[fid] = f

        after_by_id: dict[str, dict[str, Any]] = {}
        for f in after_findings:
            fid = _finding_id(f)
            after_by_id[fid] = f

        before_ids = set(before_by_id.keys())
        after_ids = set(after_by_id.keys())

        # NEW: in after but not before
        for fid in sorted(after_ids - before_ids):
            f = after_by_id[fid]
            summary.entries.append(ScanDiffEntry(
                finding_id=fid,
                status=DiffStatus.NEW,
                severity=f.get("severity", Severity.INFO),
                file=f.get("file", ""),
                line=f.get("line", 0),
                title=f.get("title", f.get("description", "")),
                rule_id=f.get("rule_id", ""),
            ))

        # FIXED: in before but not after
        for fid in sorted(before_ids - after_ids):
            f = before_by_id[fid]
            summary.entries.append(ScanDiffEntry(
                finding_id=fid,
                status=DiffStatus.FIXED,
                severity=f.get("severity", Severity.INFO),
                file=f.get("file", ""),
                line=f.get("line", 0),
                title=f.get("title", f.get("description", "")),
                rule_id=f.get("rule_id", ""),
            ))

        # MODIFIED or UNCHANGED: in both
        for fid in sorted(before_ids & after_ids):
            bf = before_by_id[fid]
            af = after_by_id[fid]
            before_sev = bf.get("severity", Severity.INFO)
            after_sev = af.get("severity", Severity.INFO)

            if before_sev != after_sev:
                summary.entries.append(ScanDiffEntry(
                    finding_id=fid,
                    status=DiffStatus.MODIFIED,
                    severity=after_sev,
                    file=af.get("file", ""),
                    line=af.get("line", 0),
                    title=af.get("title", af.get("description", "")),
                    rule_id=af.get("rule_id", ""),
                    previous_severity=before_sev,
                ))
            else:
                summary.entries.append(ScanDiffEntry(
                    finding_id=fid,
                    status=DiffStatus.UNCHANGED,
                    severity=after_sev,
                    file=af.get("file", ""),
                    line=af.get("line", 0),
                    title=af.get("title", af.get("description", "")),
                    rule_id=af.get("rule_id", ""),
                ))

        return summary

    def compare_time_series(
        self, scans: list[dict[str, Any]]
    ) -> TimeSeriesReport:
        """Compare across multiple scan results in chronological order.

        Args:
            scans: List of scan data dicts, ordered by time (oldest first).

        Returns:
            TimeSeriesReport with per-interval stats and overall trend.
        """
        report = TimeSeriesReport()

        if len(scans) < 2:
            return report

        total_new = 0
        total_fixed = 0

        for i in range(1, len(scans)):
            summary = self.compare(scans[i - 1], scans[i])
            ts = summary.timestamp_after or f"scan_{i}"

            after_findings = _extract_findings(scans[i])

            point = TimeSeriesPoint(
                timestamp=ts,
                total_findings=len(after_findings),
                new_count=summary.new_count,
                fixed_count=summary.fixed_count,
                net_delta=summary.net_delta,
                improvement_score=summary.improvement_score,
            )
            report.points.append(point)
            total_new += summary.new_count
            total_fixed += summary.fixed_count

        report.total_new_across_all = total_new
        report.total_fixed_across_all = total_fixed

        # Determine overall trend
        if not report.points:
            report.overall_trend = "stable"
        else:
            avg_score = sum(p.improvement_score for p in report.points) / len(report.points)
            if avg_score > 10:
                report.overall_trend = "improving"
            elif avg_score < -10:
                report.overall_trend = "degrading"
            else:
                report.overall_trend = "stable"

        return report

    def filter_entries(
        self, summary: DiffSummary, *, status: str | None = None,
        severity: str | None = None, file_pattern: str | None = None,
    ) -> list[ScanDiffEntry]:
        """Filter summary entries by status, severity, or file pattern.

        Args:
            summary: The DiffSummary to filter.
            status: Filter by DiffStatus (NEW, FIXED, etc.).
            severity: Filter by severity level.
            file_pattern: Substring match on file path.

        Returns:
            Filtered list of ScanDiffEntry.
        """
        result: list[ScanDiffEntry] = []
        for e in summary.entries:
            if status is not None and e.status != status:
                continue
            if severity is not None and e.severity != severity:
                continue
            if file_pattern is not None and file_pattern not in e.file:
                continue
            result.append(e)
        return result


# ---------- Formatters ----------


def format_markdown(summary: DiffSummary) -> str:
    """Format a DiffSummary as a Markdown table report.

    Args:
        summary: The DiffSummary to format.

    Returns:
        Markdown string with summary stats and findings table.
    """
    lines: list[str] = []
    lines.append("# Scan Diff Report")
    lines.append("")

    if summary.timestamp_before or summary.timestamp_after:
        lines.append(f"**Before:** {summary.timestamp_before or summary.scan_before_id}")
        lines.append(f"**After:** {summary.timestamp_after or summary.scan_after_id}")
        lines.append("")

    # Summary stats
    lines.append("## Summary")
    lines.append("")
    lines.append(f"| Metric | Value |")
    lines.append(f"|--------|-------|")
    lines.append(f"| New findings | {summary.new_count} |")
    lines.append(f"| Fixed findings | {summary.fixed_count} |")
    lines.append(f"| Unchanged | {summary.unchanged_count} |")
    lines.append(f"| Modified | {summary.modified_count} |")
    lines.append(f"| Net delta | {summary.net_delta:+d} |")
    lines.append(f"| Improvement score | {summary.improvement_score:+.2f} |")
    lines.append("")

    # Severity breakdown
    breakdown = summary.severity_breakdown
    has_data = any(
        breakdown[s]["new"] > 0 or breakdown[s]["fixed"] > 0
        for s in Severity.ALL
    )
    if has_data:
        lines.append("## Severity Breakdown")
        lines.append("")
        lines.append("| Severity | New | Fixed |")
        lines.append("|----------|-----|-------|")
        for sev in Severity.ALL:
            n = breakdown[sev]["new"]
            f = breakdown[sev]["fixed"]
            if n > 0 or f > 0:
                lines.append(f"| {sev} | {n} | {f} |")
        lines.append("")

    # Entries table
    active_entries = [
        e for e in summary.entries if e.status != DiffStatus.UNCHANGED
    ]
    if active_entries:
        lines.append("## Changes")
        lines.append("")
        lines.append("| Status | Severity | Rule | File | Title |")
        lines.append("|--------|----------|------|------|-------|")
        for e in active_entries:
            sev_str = e.severity
            if e.previous_severity:
                sev_str = f"{e.previous_severity} -> {e.severity}"
            lines.append(
                f"| {e.status} | {sev_str} | {e.rule_id} | "
                f"{e.file}:{e.line} | {e.title} |"
            )
        lines.append("")

    return "\n".join(lines)


def format_json(summary: DiffSummary) -> str:
    """Format a DiffSummary as JSON.

    Args:
        summary: The DiffSummary to format.

    Returns:
        JSON string.
    """
    return json.dumps(summary.to_dict(), indent=2, ensure_ascii=False)


def format_time_series_markdown(report: TimeSeriesReport) -> str:
    """Format a TimeSeriesReport as a Markdown table.

    Args:
        report: The TimeSeriesReport to format.

    Returns:
        Markdown string with time-series data.
    """
    lines: list[str] = []
    lines.append("# Time Series Diff Report")
    lines.append("")
    lines.append(f"**Overall trend:** {report.overall_trend}")
    lines.append(f"**Total new (all intervals):** {report.total_new_across_all}")
    lines.append(f"**Total fixed (all intervals):** {report.total_fixed_across_all}")
    lines.append("")

    if report.points:
        lines.append("| Timestamp | Total | New | Fixed | Net | Score |")
        lines.append("|-----------|-------|-----|-------|-----|-------|")
        for p in report.points:
            lines.append(
                f"| {p.timestamp} | {p.total_findings} | "
                f"{p.new_count} | {p.fixed_count} | "
                f"{p.net_delta:+d} | {p.improvement_score:+.2f} |"
            )
        lines.append("")

    return "\n".join(lines)


def format_time_series_json(report: TimeSeriesReport) -> str:
    """Format a TimeSeriesReport as JSON.

    Args:
        report: The TimeSeriesReport to format.

    Returns:
        JSON string.
    """
    return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
