"""Static analysis using Trunk.io for unified tool orchestration.

Trunk.io orchestrates multiple linters (mypy, Semgrep, Ruff) with:
- Hermetic installs with strict versioning
- Unified output format across all tools
- Auto-detection of best tools for the repo

Falls back to direct tool invocation when Trunk is not available.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple


@dataclass
class StaticAnalysisResult:
    """Result from static analysis."""

    tool: str  # "mypy", "semgrep", "ruff"
    category: str  # "type_error", "bug_pattern", "code_quality"
    severity: int  # 1-5
    message: str
    file_path: str
    line: int
    column: Optional[int] = None
    code: str = ""
    suggestion: Optional[str] = None
    rule_id: Optional[str] = None

    def __str__(self) -> str:
        """Human-readable representation."""
        loc = f"{self.file_path}:{self.line}"
        if self.column:
            loc += f":{self.column}"
        return f"[{self.tool}] {loc}: {self.message}"


@dataclass
class DiffAnalysis:
    """Analysis of code changes (before vs after)."""

    fixed_issues: List[StaticAnalysisResult] = field(default_factory=list)
    new_issues: List[StaticAnalysisResult] = field(default_factory=list)
    remaining_issues: List[StaticAnalysisResult] = field(default_factory=list)

    @property
    def is_improvement(self) -> bool:
        """Check if diff improved code quality (fixed issues, no new ones)."""
        return len(self.new_issues) == 0 and len(self.fixed_issues) > 0

    @property
    def is_regression(self) -> bool:
        """Check if diff introduced new issues."""
        return len(self.new_issues) > 0

    @property
    def is_neutral(self) -> bool:
        """Check if diff didn't change issue count."""
        return len(self.new_issues) == 0 and len(self.fixed_issues) == 0

    def summary(self) -> str:
        """Human-readable summary."""
        parts = []
        if self.fixed_issues:
            parts.append(f"Fixed {len(self.fixed_issues)} issue(s)")
        if self.new_issues:
            parts.append(f"Introduced {len(self.new_issues)} new issue(s)")
        if self.remaining_issues:
            parts.append(f"{len(self.remaining_issues)} issue(s) remain")
        return ", ".join(parts) if parts else "No issues detected"


class TrunkAnalyzer:
    """Static analysis using Trunk.io for unified tool orchestration.

    Uses Trunk CLI when available, falls back to direct tool invocation.
    """

    # Tool categories for classification
    TYPE_TOOLS = {"mypy", "pyright", "ty"}
    PATTERN_TOOLS = {"semgrep"}
    QUALITY_TOOLS = {"ruff", "flake8", "pylint", "black", "isort"}

    def __init__(self, project_root: Optional[str] = None):
        """Initialize the analyzer.

        Args:
            project_root: Path to project root. Defaults to current directory.
        """
        self.project_root = Path(project_root) if project_root else Path.cwd()
        self._trunk_available: Optional[bool] = None
        self._ruff_available: Optional[bool] = None
        self._mypy_available: Optional[bool] = None

    @property
    def trunk_available(self) -> bool:
        """Check if Trunk CLI is available (cached)."""
        if self._trunk_available is None:
            self._trunk_available = self._check_trunk()
        return self._trunk_available

    @property
    def ruff_available(self) -> bool:
        """Check if Ruff is available (cached)."""
        if self._ruff_available is None:
            self._ruff_available = self._check_command("ruff", ["--version"])
        return self._ruff_available

    @property
    def mypy_available(self) -> bool:
        """Check if mypy is available (cached)."""
        if self._mypy_available is None:
            try:
                from mypy import api

                self._mypy_available = True
            except ImportError:
                self._mypy_available = False
        return self._mypy_available

    def _check_trunk(self) -> bool:
        """Check if Trunk CLI is available."""
        return self._check_command("trunk", ["version"])

    def _check_command(self, cmd: str, args: List[str]) -> bool:
        """Check if a command is available."""
        try:
            result = subprocess.run(
                [cmd] + args,
                capture_output=True,
                cwd=self.project_root,
                timeout=10,
            )
            return result.returncode == 0
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return False

    def analyze_file(self, file_path: str) -> List[StaticAnalysisResult]:
        """Run static analysis on a file.

        Args:
            file_path: Path to file to analyze

        Returns:
            List of analysis results
        """
        path = Path(file_path)
        if not path.exists():
            return []

        if self.trunk_available:
            return self._run_trunk_check(str(path))

        return self._fallback_analyze(str(path))

    def analyze_code(
        self, code: str, filename: str = "temp.py"
    ) -> List[StaticAnalysisResult]:
        """Analyze code string.

        Args:
            code: Python code to analyze
            filename: Filename to use for analysis

        Returns:
            List of analysis results
        """
        with tempfile.TemporaryDirectory() as tmpdir:
            file_path = os.path.join(tmpdir, filename)
            with open(file_path, "w") as f:
                f.write(code)

            # Copy trunk config if exists
            trunk_config = self.project_root / ".trunk" / "trunk.yaml"
            if trunk_config.exists() and self.trunk_available:
                trunk_dir = Path(tmpdir) / ".trunk"
                trunk_dir.mkdir()
                shutil.copy(trunk_config, trunk_dir / "trunk.yaml")

                # Copy semgrep config if exists
                semgrep_config = self.project_root / ".semgrep.yaml"
                if semgrep_config.exists():
                    shutil.copy(semgrep_config, Path(tmpdir) / ".semgrep.yaml")

            if self.trunk_available:
                return self._run_trunk_check(file_path, cwd=tmpdir)
            return self._fallback_analyze(file_path, cwd=tmpdir)

    def analyze_diff(
        self, original: str, patched: str, filename: str = "temp.py"
    ) -> DiffAnalysis:
        """Compare analysis before and after a change.

        Args:
            original: Original code
            patched: Patched code
            filename: Filename for analysis

        Returns:
            DiffAnalysis with fixed, new, and remaining issues
        """
        original_issues = self.analyze_code(original, filename)
        patched_issues = self.analyze_code(patched, filename)

        # Create fingerprints for comparison (message + relative position)
        def fingerprint(issue: StaticAnalysisResult) -> Tuple[str, str, str]:
            return (issue.tool, issue.message, issue.rule_id or "")

        original_fps = {fingerprint(i): i for i in original_issues}
        patched_fps = {fingerprint(i): i for i in patched_issues}

        original_set = set(original_fps.keys())
        patched_set = set(patched_fps.keys())

        fixed = [original_fps[fp] for fp in original_set - patched_set]
        new = [patched_fps[fp] for fp in patched_set - original_set]
        remaining = [patched_fps[fp] for fp in patched_set & original_set]

        return DiffAnalysis(
            fixed_issues=fixed,
            new_issues=new,
            remaining_issues=remaining,
        )

    def _run_trunk_check(
        self, file_path: str, cwd: Optional[str] = None
    ) -> List[StaticAnalysisResult]:
        """Run trunk check and parse output.

        Args:
            file_path: Path to file to check
            cwd: Working directory for trunk

        Returns:
            List of analysis results
        """
        try:
            result = subprocess.run(
                ["trunk", "check", "--output=json", file_path],
                capture_output=True,
                text=True,
                timeout=60,
                cwd=cwd or str(self.project_root),
            )

            return self._parse_trunk_output(result.stdout)
        except subprocess.TimeoutExpired:
            return []
        except Exception:
            return []

    def _parse_trunk_output(self, output: str) -> List[StaticAnalysisResult]:
        """Parse Trunk JSON output into unified format.

        Args:
            output: JSON output from trunk check

        Returns:
            List of analysis results
        """
        if not output.strip():
            return []

        results = []
        try:
            data = json.loads(output)
            for issue in data.get("issues", []):
                results.append(
                    StaticAnalysisResult(
                        tool=issue.get("linter", "unknown"),
                        category=self._categorize_issue(issue),
                        severity=self._severity_from_level(issue.get("level", "warning")),
                        message=issue.get("message", ""),
                        file_path=issue.get("file", ""),
                        line=issue.get("line", 0),
                        column=issue.get("column"),
                        code=issue.get("code", ""),
                        suggestion=issue.get("fix"),
                        rule_id=issue.get("code"),
                    )
                )
        except json.JSONDecodeError:
            pass

        return results

    def _categorize_issue(self, issue: Dict[str, Any]) -> str:
        """Categorize issue based on linter.

        Args:
            issue: Issue dict from Trunk output

        Returns:
            Category string
        """
        linter = issue.get("linter", "")
        if linter in self.TYPE_TOOLS:
            return "type_error"
        elif linter in self.PATTERN_TOOLS:
            return "bug_pattern"
        elif linter in self.QUALITY_TOOLS:
            return "code_quality"
        return "unknown"

    def _severity_from_level(self, level: str) -> int:
        """Convert Trunk level to severity.

        Args:
            level: Trunk severity level

        Returns:
            Severity 1-5
        """
        return {"error": 4, "warning": 3, "info": 2, "note": 1}.get(level, 2)

    def _fallback_analyze(
        self, file_path: str, cwd: Optional[str] = None
    ) -> List[StaticAnalysisResult]:
        """Fallback to direct tool invocation if Trunk not available.

        Args:
            file_path: Path to file to analyze
            cwd: Working directory

        Returns:
            List of analysis results
        """
        results: List[StaticAnalysisResult] = []

        # Run mypy
        results.extend(self._run_mypy(file_path))

        # Run ruff
        results.extend(self._run_ruff(file_path, cwd))

        return results

    def _run_mypy(self, file_path: str) -> List[StaticAnalysisResult]:
        """Run mypy directly.

        Args:
            file_path: Path to file to analyze

        Returns:
            List of type error results
        """
        if not self.mypy_available:
            return []

        try:
            from mypy import api

            stdout, stderr, exit_code = api.run(
                [file_path, "--no-error-summary", "--no-color"]
            )

            return self._parse_mypy_output(stdout, file_path)
        except Exception:
            return []

    def _parse_mypy_output(
        self, output: str, default_file: str
    ) -> List[StaticAnalysisResult]:
        """Parse mypy output.

        Args:
            output: Mypy stdout
            default_file: Default file path

        Returns:
            List of analysis results
        """
        results = []
        # Pattern: file.py:line: error: message  [error-code]
        pattern = re.compile(r"^(.+?):(\d+):\s*(error|warning|note):\s*(.+?)(?:\s*\[(.+?)\])?$")

        for line in output.strip().split("\n"):
            match = pattern.match(line)
            if match:
                file_path, line_num, level, message, code = match.groups()
                results.append(
                    StaticAnalysisResult(
                        tool="mypy",
                        category="type_error",
                        severity=self._severity_from_level(level),
                        message=message.strip(),
                        file_path=file_path,
                        line=int(line_num),
                        rule_id=code,
                    )
                )

        return results

    def _run_ruff(
        self, file_path: str, cwd: Optional[str] = None
    ) -> List[StaticAnalysisResult]:
        """Run ruff directly.

        Args:
            file_path: Path to file to analyze
            cwd: Working directory

        Returns:
            List of code quality results
        """
        if not self.ruff_available:
            return []

        try:
            result = subprocess.run(
                ["ruff", "check", "--output-format=json", file_path],
                capture_output=True,
                text=True,
                timeout=30,
                cwd=cwd,
            )

            return self._parse_ruff_output(result.stdout)
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return []

    def _parse_ruff_output(self, output: str) -> List[StaticAnalysisResult]:
        """Parse ruff JSON output.

        Args:
            output: Ruff JSON output

        Returns:
            List of analysis results
        """
        if not output.strip():
            return []

        results = []
        try:
            issues = json.loads(output)
            for issue in issues:
                # Determine severity from code prefix
                code = issue.get("code", "")
                if code.startswith("E") or code.startswith("F"):
                    severity = 4  # Error
                elif code.startswith("W"):
                    severity = 3  # Warning
                else:
                    severity = 2  # Info

                results.append(
                    StaticAnalysisResult(
                        tool="ruff",
                        category="code_quality",
                        severity=severity,
                        message=issue.get("message", ""),
                        file_path=issue.get("filename", ""),
                        line=issue.get("location", {}).get("row", 0),
                        column=issue.get("location", {}).get("column"),
                        rule_id=code,
                        suggestion=issue.get("fix", {}).get("message") if issue.get("fix") else None,
                    )
                )
        except json.JSONDecodeError:
            pass

        return results

    def get_relevant_issues(
        self,
        issues: List[StaticAnalysisResult],
        target_line: int,
        context_lines: int = 10,
    ) -> List[StaticAnalysisResult]:
        """Filter issues to those near a specific line.

        Args:
            issues: All issues
            target_line: Line number to focus on
            context_lines: How many lines of context

        Returns:
            Issues within context of target line
        """
        return [i for i in issues if abs(i.line - target_line) <= context_lines]

    def format_report(
        self, results: List[StaticAnalysisResult], max_issues: int = 20
    ) -> str:
        """Format results as human-readable report.

        Args:
            results: Analysis results
            max_issues: Maximum issues to include

        Returns:
            Formatted report string
        """
        if not results:
            return "No issues found."

        # Sort by severity (highest first)
        sorted_results = sorted(results, key=lambda r: -r.severity)

        lines = [f"Found {len(results)} issue(s):\n"]

        for i, result in enumerate(sorted_results[:max_issues]):
            severity_str = {5: "CRIT", 4: "ERR", 3: "WARN", 2: "INFO", 1: "NOTE"}.get(
                result.severity, "?"
            )
            loc = f"{result.file_path}:{result.line}"
            if result.column:
                loc += f":{result.column}"

            lines.append(f"  [{severity_str}] {result.tool}: {result.message}")
            lines.append(f"         at {loc}")
            if result.rule_id:
                lines.append(f"         rule: {result.rule_id}")
            if result.suggestion:
                lines.append(f"         fix: {result.suggestion}")
            lines.append("")

        if len(results) > max_issues:
            lines.append(f"  ... and {len(results) - max_issues} more issues")

        return "\n".join(lines)


# Convenience functions


def analyze_file(file_path: str, project_root: Optional[str] = None) -> List[StaticAnalysisResult]:
    """Analyze a file for static issues.

    Args:
        file_path: Path to file
        project_root: Project root path

    Returns:
        List of analysis results
    """
    analyzer = TrunkAnalyzer(project_root)
    return analyzer.analyze_file(file_path)


def analyze_code(
    code: str, filename: str = "temp.py", project_root: Optional[str] = None
) -> List[StaticAnalysisResult]:
    """Analyze code string for static issues.

    Args:
        code: Python code
        filename: Filename for analysis
        project_root: Project root path

    Returns:
        List of analysis results
    """
    analyzer = TrunkAnalyzer(project_root)
    return analyzer.analyze_code(code, filename)
