"""Finding Deduplicator for MCP Security Audit.

Identifies and merges duplicate or near-duplicate findings across
scans to reduce noise and improve actionability.

Features:
- Exact dedup by fingerprint (rule_id + file + line)
- Fuzzy dedup by similarity (same rule, nearby lines)
- Cross-file dedup (same pattern in different files)
- Dedup statistics and compression ratio
- Merge strategy: keep highest severity, first occurrence

CLI: mcpaudit dedup --input scan.json --output clean.json

Zero external dependencies. Part of mcpaudit v2.3.0.
"""

from __future__ import annotations

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


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class FindingFingerprint:
    """Unique identifier for a finding."""
    rule_id: str
    file: str
    line: int

    @property
    def hash(self) -> str:
        content = f"{self.rule_id}:{self.file}:{self.line}"
        return hashlib.sha256(content.encode()).hexdigest()[:16]


@dataclass
class DedupGroup:
    """A group of duplicate findings."""
    canonical: dict[str, Any]  # the representative finding
    duplicates: list[dict[str, Any]] = field(default_factory=list)
    fingerprint: str = ""

    @property
    def count(self) -> int:
        return 1 + len(self.duplicates)

    @property
    def files(self) -> list[str]:
        files = [self.canonical.get("file", "")]
        files.extend(d.get("file", "") for d in self.duplicates)
        return sorted(set(f for f in files if f))


@dataclass
class DedupStats:
    """Statistics from deduplication."""
    total_input: int = 0
    unique_output: int = 0
    exact_dupes: int = 0
    fuzzy_dupes: int = 0
    cross_file_dupes: int = 0

    @property
    def compression_ratio(self) -> float:
        if self.total_input == 0:
            return 1.0
        return self.unique_output / self.total_input

    @property
    def dupes_removed(self) -> int:
        return self.total_input - self.unique_output

    def to_dict(self) -> dict[str, Any]:
        return {
            "total_input": self.total_input,
            "unique_output": self.unique_output,
            "exact_dupes": self.exact_dupes,
            "fuzzy_dupes": self.fuzzy_dupes,
            "cross_file_dupes": self.cross_file_dupes,
            "compression_ratio": round(self.compression_ratio, 3),
            "dupes_removed": self.dupes_removed,
        }


# ---------------------------------------------------------------------------
# Severity ordering
# ---------------------------------------------------------------------------

_SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}


def _higher_severity(a: str, b: str) -> str:
    """Return the higher severity between two values."""
    a_rank = _SEVERITY_ORDER.get(a, 5)
    b_rank = _SEVERITY_ORDER.get(b, 5)
    return a if a_rank <= b_rank else b


# ---------------------------------------------------------------------------
# Deduplicator
# ---------------------------------------------------------------------------

class FindingDeduplicator:
    """Deduplicates findings from scan results."""

    def __init__(self, fuzzy_line_threshold: int = 5) -> None:
        self._fuzzy_threshold = fuzzy_line_threshold

    def fingerprint(self, finding: dict[str, Any]) -> str:
        """Generate fingerprint for a finding."""
        fp = FindingFingerprint(
            rule_id=finding.get("rule_id", ""),
            file=finding.get("file", ""),
            line=finding.get("line", 0),
        )
        return fp.hash

    def exact_dedup(self, findings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
        """Remove exact duplicates. Returns (unique, removed_count)."""
        seen: dict[str, dict[str, Any]] = {}
        removed = 0
        for f in findings:
            fp = self.fingerprint(f)
            if fp in seen:
                # Keep higher severity
                existing_sev = seen[fp].get("severity", "INFO")
                new_sev = f.get("severity", "INFO")
                if _SEVERITY_ORDER.get(new_sev, 5) < _SEVERITY_ORDER.get(existing_sev, 5):
                    seen[fp] = f
                removed += 1
            else:
                seen[fp] = f
        return list(seen.values()), removed

    def fuzzy_dedup(self, findings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
        """Remove fuzzy duplicates (same rule, nearby lines in same file)."""
        if not findings:
            return [], 0

        # Group by (rule_id, file)
        groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
        for f in findings:
            key = (f.get("rule_id", ""), f.get("file", ""))
            groups.setdefault(key, []).append(f)

        result = []
        removed = 0

        for _key, group in groups.items():
            # Sort by line number
            sorted_group = sorted(group, key=lambda x: x.get("line", 0))
            kept = [sorted_group[0]]

            for f in sorted_group[1:]:
                last_line = kept[-1].get("line", 0)
                curr_line = f.get("line", 0)
                if abs(curr_line - last_line) <= self._fuzzy_threshold:
                    # Fuzzy duplicate — keep the one with higher severity
                    existing_sev = kept[-1].get("severity", "INFO")
                    new_sev = f.get("severity", "INFO")
                    if _SEVERITY_ORDER.get(new_sev, 5) < _SEVERITY_ORDER.get(existing_sev, 5):
                        kept[-1] = f
                    removed += 1
                else:
                    kept.append(f)

            result.extend(kept)

        return result, removed

    def cross_file_dedup(self, findings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
        """Remove cross-file duplicates (same rule + same code snippet)."""
        seen: dict[tuple[str, str], dict[str, Any]] = {}
        removed = 0

        for f in findings:
            rule_id = f.get("rule_id", "")
            snippet = f.get("code_snippet", "").strip()
            if not snippet:
                # No snippet, keep as-is
                key = (rule_id, f.get("file", "") + str(f.get("line", 0)))
            else:
                key = (rule_id, snippet)

            if key in seen:
                removed += 1
            else:
                seen[key] = f

        return list(seen.values()), removed

    def deduplicate(self, findings: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], DedupStats]:
        """Full deduplication pipeline. Returns (unique_findings, stats)."""
        stats = DedupStats(total_input=len(findings))

        # Step 1: exact
        result, exact = self.exact_dedup(findings)
        stats.exact_dupes = exact

        # Step 2: fuzzy
        result, fuzzy = self.fuzzy_dedup(result)
        stats.fuzzy_dupes = fuzzy

        # Step 3: cross-file
        result, cross = self.cross_file_dedup(result)
        stats.cross_file_dupes = cross

        stats.unique_output = len(result)
        return result, stats

    def group_duplicates(self, findings: list[dict[str, Any]]) -> list[DedupGroup]:
        """Group findings by fingerprint."""
        groups: dict[str, DedupGroup] = {}
        for f in findings:
            fp = self.fingerprint(f)
            if fp in groups:
                groups[fp].duplicates.append(f)
            else:
                groups[fp] = DedupGroup(canonical=f, fingerprint=fp)
        return list(groups.values())

    def to_json(self, findings: list[dict[str, Any]], stats: DedupStats,
                indent: int = 2) -> str:
        """Export dedup results as JSON."""
        return json.dumps({
            "stats": stats.to_dict(),
            "findings": findings,
        }, indent=indent)

    def markdown_report(self, stats: DedupStats) -> str:
        """Generate dedup report."""
        lines = ["# Deduplication Report", ""]
        lines.append(f"- **Input findings**: {stats.total_input}")
        lines.append(f"- **Unique findings**: {stats.unique_output}")
        lines.append(f"- **Duplicates removed**: {stats.dupes_removed}")
        lines.append(f"- **Compression**: {stats.compression_ratio:.1%}")
        lines.append("")
        lines.append("## Breakdown")
        lines.append(f"- Exact duplicates: {stats.exact_dupes}")
        lines.append(f"- Fuzzy duplicates: {stats.fuzzy_dupes}")
        lines.append(f"- Cross-file duplicates: {stats.cross_file_dupes}")
        return "\n".join(lines)
