"""Auto-fix engine for MCP Security Audit findings.

Generates unified diffs and optionally applies fixes for findings
where the originating rule implements suggest_fix(). Zero external deps.
"""

from __future__ import annotations

import difflib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from .models import Finding
from .rules import Rule


@dataclass
class FixSuggestion:
    """A single auto-fix suggestion tied to a finding."""

    finding: Finding
    original_line: str
    fixed_line: str
    line_number: int

    @property
    def diff(self) -> str:
        """Unified diff snippet for this single-line fix."""
        return _unified_diff_snippet(
            self.finding.file,
            self.original_line,
            self.fixed_line,
            self.line_number,
        )


@dataclass
class FixResult:
    """Aggregate result of an auto-fix pass over a file."""

    file: str
    suggestions: list[FixSuggestion] = field(default_factory=list)
    applied: bool = False
    error: str = ""

    @property
    def fixable_count(self) -> int:
        return len(self.suggestions)

    @property
    def diff(self) -> str:
        """Full unified diff for all suggestions in this file."""
        if not self.suggestions:
            return ""
        lines = _read_lines(self.file)
        if lines is None:
            return ""
        fixed_lines = list(lines)
        for s in sorted(self.suggestions, key=lambda x: x.line_number):
            idx = s.line_number - 1
            if 0 <= idx < len(fixed_lines):
                fixed_lines[idx] = s.fixed_line
        return _full_unified_diff(self.file, lines, fixed_lines)


def _read_lines(filepath: str) -> Optional[list[str]]:
    """Read file lines, returning None on error."""
    try:
        return Path(filepath).read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
    except (OSError, IOError):
        return None


def _unified_diff_snippet(
    filepath: str,
    original: str,
    fixed: str,
    lineno: int,
) -> str:
    """Generate a unified diff for a single-line change."""
    orig = original if original.endswith("\n") else original + "\n"
    fix = fixed if fixed.endswith("\n") else fixed + "\n"
    diff_lines = difflib.unified_diff(
        [orig],
        [fix],
        fromfile=f"a/{filepath}",
        tofile=f"b/{filepath}",
        lineterm="\n",
    )
    return "".join(diff_lines)


def _full_unified_diff(
    filepath: str,
    original_lines: list[str],
    fixed_lines: list[str],
) -> str:
    """Generate a full unified diff between original and fixed content."""
    diff_lines = difflib.unified_diff(
        original_lines,
        fixed_lines,
        fromfile=f"a/{filepath}",
        tofile=f"b/{filepath}",
    )
    return "".join(diff_lines)


def build_rule_map(rules: list[Rule]) -> dict[str, Rule]:
    """Build a lookup from rule_id to rule instance."""
    return {r.rule_id: r for r in rules}


def suggest_fixes(
    filepath: str,
    findings: list[Finding],
    rule_map: dict[str, Rule],
) -> FixResult:
    """Generate fix suggestions for findings in a single file.

    Only produces suggestions for rules that implement suggest_fix().

    Args:
        filepath: Path to the source file.
        findings: Findings detected in this file.
        rule_map: Mapping from rule_id to Rule instance.

    Returns:
        FixResult with all available suggestions.
    """
    result = FixResult(file=filepath)
    lines = _read_lines(filepath)
    if lines is None:
        result.error = f"Cannot read file: {filepath}"
        return result

    for finding in findings:
        rule = rule_map.get(finding.rule_id)
        if rule is None:
            continue
        if not hasattr(rule, "suggest_fix"):
            continue

        source = "".join(lines)
        fixed_line = rule.suggest_fix(source, finding)
        if fixed_line is None:
            continue

        idx = finding.line - 1
        if idx < 0 or idx >= len(lines):
            continue

        original_line = lines[idx].rstrip("\n")
        result.suggestions.append(FixSuggestion(
            finding=finding,
            original_line=original_line,
            fixed_line=fixed_line,
            line_number=finding.line,
        ))

    return result


def apply_fixes(fix_result: FixResult) -> bool:
    """Apply all fix suggestions to the file on disk.

    Writes the modified content back to the file. Suggestions are
    applied in line-number order, bottom-to-top to preserve indices.

    Args:
        fix_result: FixResult with suggestions to apply.

    Returns:
        True if fixes were written successfully.
    """
    if not fix_result.suggestions:
        return False

    lines = _read_lines(fix_result.file)
    if lines is None:
        fix_result.error = f"Cannot read file: {fix_result.file}"
        return False

    for s in sorted(fix_result.suggestions, key=lambda x: x.line_number, reverse=True):
        idx = s.line_number - 1
        if 0 <= idx < len(lines):
            trailing = "\n" if lines[idx].endswith("\n") else ""
            lines[idx] = s.fixed_line + trailing

    try:
        Path(fix_result.file).write_text("".join(lines), encoding="utf-8")
        fix_result.applied = True
        return True
    except (OSError, IOError) as e:
        fix_result.error = str(e)
        return False


def format_fix_dry_run(fix_results: list[FixResult]) -> str:
    """Format all fix suggestions as a human-readable dry-run report.

    Args:
        fix_results: List of FixResult from suggest_fixes().

    Returns:
        Formatted string with diffs and summary.
    """
    output: list[str] = []
    total_fixes = sum(r.fixable_count for r in fix_results)

    output.append(f"\n{'=' * 60}")
    output.append("  MCP Security Audit — Auto-Fix Preview (dry run)")
    output.append(f"{'=' * 60}")
    output.append(f"  Fixable findings: {total_fixes}")
    output.append(f"{'=' * 60}\n")

    for fr in fix_results:
        if not fr.suggestions:
            continue
        output.append(f"  [FILE] {fr.file} ({fr.fixable_count} fixes)\n")
        for s in fr.suggestions:
            output.append(f"    [{s.finding.rule_id}] Line {s.line_number}: {s.finding.title}")
            output.append(f"    - {s.original_line.strip()}")
            output.append(f"    + {s.fixed_line.strip()}")
            output.append("")

    if total_fixes == 0:
        output.append("  No auto-fixable findings detected.")
        output.append("  (Only rules with suggest_fix() support auto-fix.)\n")

    output.append(f"{'=' * 60}")
    return "\n".join(output)


def format_fix_applied(fix_results: list[FixResult]) -> str:
    """Format a report of applied fixes.

    Args:
        fix_results: List of FixResult after apply_fixes().

    Returns:
        Formatted summary string.
    """
    output: list[str] = []
    total = sum(r.fixable_count for r in fix_results if r.applied)
    errors = [r for r in fix_results if r.error]

    output.append(f"\n{'=' * 60}")
    output.append("  MCP Security Audit — Auto-Fix Applied")
    output.append(f"{'=' * 60}")
    output.append(f"  Fixes applied: {total}")
    output.append(f"{'=' * 60}\n")

    for fr in fix_results:
        if fr.applied and fr.suggestions:
            output.append(f"  [FIXED] {fr.file} ({fr.fixable_count} fixes)")
            for s in fr.suggestions:
                output.append(f"    [{s.finding.rule_id}] Line {s.line_number}: {s.finding.title}")

    for fr in errors:
        output.append(f"  [ERROR] {fr.file}: {fr.error}")

    output.append(f"\n{'=' * 60}")
    return "\n".join(output)
