"""Core abstractions for the specialist code review system."""

from __future__ import annotations

import ast
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------

@dataclass
class ExceptHandlerInfo:
    """Parsed except handler with context."""
    line: int
    type_name: Optional[str]       # None = bare except, "Exception", etc.
    body_type: str                  # "pass", "return_none", "raise", "log", "other"
    enclosing_function: Optional[str]
    try_body_source: str            # source text of the try block body


@dataclass
class Finding:
    """A single code review finding."""
    line: int
    severity: str       # HIGH, MEDIUM, LOW
    message: str
    specialist: str     # which specialist found it
    confidence: str     # DEFINITE, LIKELY
    file_path: str = "" # relative path (set by external tools for codebase scans)

    def to_tuple(self) -> tuple[int, str, str]:
        return (self.line, self.severity, self.message)


@dataclass
class Verdict:
    """LLM micro-call response."""
    flag: bool          # True = report finding, False = skip
    reason: str


# ---------------------------------------------------------------------------
# ASTData — pre-computed once, shared by all specialists
# ---------------------------------------------------------------------------

@dataclass
class ASTData:
    """Pre-computed AST analysis shared by all specialists."""

    tree: ast.Module
    source: str
    source_lines: list[str]

    # Feature flags for routing
    has_async: bool = False
    has_subprocess: bool = False
    has_sql: bool = False
    has_file_ops: bool = False
    has_auth_patterns: bool = False

    # Enriched data
    except_handlers: list[ExceptHandlerInfo] = field(default_factory=list)
    call_sites: dict[str, list[int]] = field(default_factory=dict)
    defined_functions: dict[str, int] = field(default_factory=dict)
    function_ranges: dict[str, tuple[int, int]] = field(default_factory=dict)
    async_functions: set[str] = field(default_factory=set)
    decorated_functions: dict[str, list[str]] = field(default_factory=dict)
    class_methods: set[str] = field(default_factory=set)  # methods inside class bodies
    import_names: set[str] = field(default_factory=set)

    @classmethod
    def from_source(cls, source: str) -> "ASTData":
        """Build ASTData from source code. Single parse, one walk."""
        tree = ast.parse(source)
        lines = source.splitlines()

        data = cls(tree=tree, source=source, source_lines=lines)

        # Track current function scope during walk
        # We need parent info for except handlers — build a parent map
        _parent_map: dict[int, ast.AST] = {}
        for node in ast.walk(tree):
            for child in ast.iter_child_nodes(node):
                _parent_map[id(child)] = node

        # Single walk extracts everything
        for node in ast.walk(tree):
            # --- Function definitions ---
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                name = node.name
                data.defined_functions[name] = node.lineno
                data.function_ranges[name] = (node.lineno, node.end_lineno or node.lineno)
                if isinstance(node, ast.AsyncFunctionDef):
                    data.async_functions.add(name)
                    data.has_async = True
                # Track class methods
                parent = _parent_map.get(id(node))
                if isinstance(parent, ast.ClassDef):
                    data.class_methods.add(name)
                # Decorators
                dec_names = []
                for dec in node.decorator_list:
                    dec_names.append(_unparse_decorator(dec))
                if dec_names:
                    data.decorated_functions[name] = dec_names

            # --- Call sites ---
            elif isinstance(node, ast.Call):
                call_name = _extract_call_name(node)
                if call_name:
                    data.call_sites.setdefault(call_name, []).append(
                        getattr(node, "lineno", 0)
                    )

            # --- Except handlers ---
            elif isinstance(node, ast.ExceptHandler):
                type_name = None
                if node.type is not None:
                    try:
                        type_name = ast.unparse(node.type)
                    except Exception:
                        type_name = "?"

                body_type = _classify_except_body(node.body)
                enclosing = _find_enclosing_function(node, _parent_map)

                # Get try body source
                try_body_src = ""
                parent = _parent_map.get(id(node))
                if isinstance(parent, ast.Try):
                    try:
                        start = parent.body[0].lineno - 1
                        end = parent.body[-1].end_lineno or (start + 1)
                        try_body_src = "\n".join(lines[start:end])
                    except Exception:
                        pass

                data.except_handlers.append(ExceptHandlerInfo(
                    line=node.lineno,
                    type_name=type_name,
                    body_type=body_type,
                    enclosing_function=enclosing,
                    try_body_source=try_body_src,
                ))

            # --- Imports (for feature flags) ---
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    data.import_names.add(alias.name)
            elif isinstance(node, ast.ImportFrom):
                if node.module:
                    data.import_names.add(node.module)
                for alias in node.names:
                    data.import_names.add(alias.name)

        # Set feature flags from imports and patterns
        data.has_subprocess = any("subprocess" in n for n in data.import_names)
        data.has_sql = any(
            kw in n for n in data.import_names
            for kw in ("sqlite", "psycopg", "sqlalchemy", "pymysql", "cursor")
        )
        data.has_file_ops = (
            "open" in data.call_sites
            or any("os.path" in n or "pathlib" in n for n in data.import_names)
        )
        data.has_auth_patterns = any(
            kw in source.lower()
            for kw in ("password", "auth", "token", "jwt", "credential")
        )

        return data


# ---------------------------------------------------------------------------
# AST helpers
# ---------------------------------------------------------------------------

def _unparse_decorator(node: ast.expr) -> str:
    """Get a readable name for a decorator node."""
    try:
        if isinstance(node, ast.Name):
            return node.id
        elif isinstance(node, ast.Attribute):
            return ast.unparse(node)
        elif isinstance(node, ast.Call):
            return _unparse_decorator(node.func)
        return ast.unparse(node)
    except Exception:
        return "?"


def _extract_call_name(node: ast.Call) -> Optional[str]:
    """Extract the function name from a Call node."""
    if isinstance(node.func, ast.Name):
        return node.func.id
    elif isinstance(node.func, ast.Attribute):
        return node.func.attr
    return None


def _classify_except_body(body: list[ast.stmt]) -> str:
    """Classify what an except handler does."""
    if len(body) == 1:
        stmt = body[0]
        if isinstance(stmt, ast.Pass):
            return "pass"
        if isinstance(stmt, ast.Return):
            if stmt.value is None or (isinstance(stmt.value, ast.Constant) and stmt.value.value is None):
                return "return_none"
            return "other"
        if isinstance(stmt, ast.Raise):
            return "raise"
        if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
            call_name = _extract_call_name(stmt.value)
            if call_name and any(kw in call_name.lower() for kw in ("log", "warn", "debug", "info", "error")):
                return "log"
    # Multi-statement: check if it ends with raise
    if body and isinstance(body[-1], ast.Raise):
        return "raise"
    # Check if any statement is a logging call
    for stmt in body:
        if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
            call_name = _extract_call_name(stmt.value)
            if call_name and any(kw in call_name.lower() for kw in ("log", "warn", "debug", "info", "error")):
                return "log"
    return "other"


def _find_enclosing_function(node: ast.AST, parent_map: dict[int, ast.AST]) -> Optional[str]:
    """Walk up the parent map to find the enclosing function name."""
    current = node
    while True:
        parent = parent_map.get(id(current))
        if parent is None:
            return None
        if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)):
            return parent.name
        current = parent


# ---------------------------------------------------------------------------
# Specialist ABC
# ---------------------------------------------------------------------------

class Specialist(ABC):
    """Base class for all code review specialists."""

    name: str = ""
    trigger_features: list[str] = []

    def should_run(self, ast_data: ASTData, focus: str) -> bool:
        """Check if this specialist should run on this file.

        Default: returns True if any trigger feature flag is True in ast_data.
        Override for custom logic.
        """
        if not self.trigger_features:
            return True
        return any(getattr(ast_data, f, False) for f in self.trigger_features)

    @abstractmethod
    async def run(self, source: str, ast_data: ASTData) -> list[Finding]:
        """Analyze source code and return findings."""
        ...

    async def llm_ask(
        self,
        question: str,
        context: str,
        examples: Optional[list[tuple[str, str]]] = None,
    ) -> Verdict:
        """Make a targeted LLM micro-call for an ambiguous case.

        Args:
            question: Specific yes/no question about the code.
            context: 10-20 lines of code around the issue.
            examples: [(code_snippet, "FLAG: reason"/"SKIP: reason"), ...]

        Returns:
            Verdict with flag=True/False and reason.
            On failure, returns Verdict(flag=False) — safe default.
        """
        try:
            from autodebug.agent.core import _build_model, WORKER_MODEL
            from pydantic_ai import Agent as _Agent

            model = _build_model(WORKER_MODEL)
            system = (
                "You are a code review specialist. Answer with exactly ONE line:\n"
                "FLAG: <reason> — if the code has a real bug that causes incorrect runtime behavior\n"
                "SKIP: <reason> — if the pattern is intentional or harmless\n"
                "Be concise. One sentence."
            )

            parts: list[str] = []
            if examples:
                parts.append("Examples:")
                for code, verdict in examples:
                    parts.append(f"```\n{code}\n```\n→ {verdict}")
                parts.append("")

            parts.append(f"Code:\n```\n{context}\n```")
            parts.append(f"\n{question}")

            agent = _Agent(model, system_prompt=system)
            result = await asyncio.wait_for(agent.run("\n".join(parts)), timeout=2.0)

            text = result.output.strip()
            if text.upper().startswith("FLAG"):
                reason = text.split(":", 1)[1].strip() if ":" in text else text
                return Verdict(flag=True, reason=reason)
            else:
                reason = text.split(":", 1)[1].strip() if ":" in text else text
                return Verdict(flag=False, reason=reason)

        except Exception:
            return Verdict(flag=False, reason="LLM unavailable")
