"""Index repository for fast symbol lookups and context building.

Scans all Python files, builds symbol tables, and creates
searchable indexes for types, functions, classes, and imports.
"""

from __future__ import annotations

import ast
import hashlib
import os
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional, Set

if TYPE_CHECKING:
    from autodebug.analysis.call_graph import CallGraph


class SymbolType(str, Enum):
    """Type of symbol in the symbol table."""

    FUNCTION = "function"
    CLASS = "class"
    METHOD = "method"
    VARIABLE = "variable"
    IMPORT = "import"
    CONSTANT = "constant"


@dataclass
class SymbolInfo:
    """Information about a symbol in the repository."""

    name: str
    symbol_type: SymbolType
    file_path: str
    start_line: int
    end_line: int
    docstring: Optional[str] = None
    parent: Optional[str] = None  # For methods, the class name
    signature: Optional[str] = None  # For functions/methods
    decorators: List[str] = field(default_factory=list)
    annotations: Dict[str, str] = field(default_factory=dict)  # Type annotations
    imports: List[str] = field(default_factory=list)  # What this symbol imports

    @property
    def full_name(self) -> str:
        """Get fully qualified name."""
        if self.parent:
            return f"{self.file_path}:{self.parent}.{self.name}"
        return f"{self.file_path}:{self.name}"

    def __str__(self) -> str:
        type_str = self.symbol_type.value
        return f"<{type_str}> {self.name} at {self.file_path}:{self.start_line}"


@dataclass
class SymbolTable:
    """Table of all symbols in the repository."""

    symbols: Dict[str, SymbolInfo] = field(default_factory=dict)
    by_name: Dict[str, List[str]] = field(default_factory=dict)  # name -> [full_names]
    by_file: Dict[str, List[str]] = field(default_factory=dict)  # file -> [full_names]
    by_type: Dict[SymbolType, List[str]] = field(default_factory=dict)
    imports_graph: Dict[str, Set[str]] = field(default_factory=dict)  # file -> imports

    def add(self, symbol: SymbolInfo) -> None:
        """Add a symbol to the table."""
        full_name = symbol.full_name
        self.symbols[full_name] = symbol

        # Index by name
        if symbol.name not in self.by_name:
            self.by_name[symbol.name] = []
        self.by_name[symbol.name].append(full_name)

        # Index by file
        if symbol.file_path not in self.by_file:
            self.by_file[symbol.file_path] = []
        self.by_file[symbol.file_path].append(full_name)

        # Index by type
        if symbol.symbol_type not in self.by_type:
            self.by_type[symbol.symbol_type] = []
        self.by_type[symbol.symbol_type].append(full_name)

    def remove(self, full_name: str) -> None:
        """Remove a symbol and clean up all secondary indexes."""
        symbol = self.symbols.pop(full_name, None)
        if symbol is None:
            return

        # Clean by_name index
        name_list = self.by_name.get(symbol.name)
        if name_list and full_name in name_list:
            name_list.remove(full_name)
            if not name_list:
                del self.by_name[symbol.name]

        # Clean by_file index
        file_list = self.by_file.get(symbol.file_path)
        if file_list and full_name in file_list:
            file_list.remove(full_name)
            if not file_list:
                del self.by_file[symbol.file_path]

        # Clean by_type index
        type_list = self.by_type.get(symbol.symbol_type)
        if type_list and full_name in type_list:
            type_list.remove(full_name)
            if not type_list:
                del self.by_type[symbol.symbol_type]

    def get(self, full_name: str) -> Optional[SymbolInfo]:
        """Get symbol by full name."""
        return self.symbols.get(full_name)

    def find_by_name(self, name: str) -> List[SymbolInfo]:
        """Find all symbols with a given name."""
        full_names = self.by_name.get(name, [])
        return [self.symbols[fn] for fn in full_names if fn in self.symbols]

    def find_in_file(self, file_path: str) -> List[SymbolInfo]:
        """Find all symbols in a file."""
        full_names = self.by_file.get(file_path, [])
        return [self.symbols[fn] for fn in full_names if fn in self.symbols]

    def find_by_type(self, symbol_type: SymbolType) -> List[SymbolInfo]:
        """Find all symbols of a type."""
        full_names = self.by_type.get(symbol_type, [])
        return [self.symbols[fn] for fn in full_names if fn in self.symbols]

    def find_at_line(self, file_path: str, line: int) -> Optional[SymbolInfo]:
        """Find the symbol containing a specific line."""
        candidates = []
        for symbol in self.find_in_file(file_path):
            if symbol.start_line <= line <= symbol.end_line:
                candidates.append(symbol)

        if not candidates:
            return None

        # Return innermost symbol (smallest range)
        return min(candidates, key=lambda s: s.end_line - s.start_line)


class RepoIndexer:
    """Index a repository for fast symbol lookups.

    Scans Python files, builds symbol tables, and creates
    call graphs for understanding code relationships.
    """

    def __init__(
        self,
        root_path: str,
        exclude_patterns: Optional[List[str]] = None,
        include_tests: bool = True,
    ):
        """Initialize the repository indexer.

        Args:
            root_path: Root directory of the repository
            exclude_patterns: Glob patterns to exclude (e.g., ["**/node_modules/**"])
            include_tests: Whether to include test files
        """
        self.root_path = Path(root_path).resolve()
        self.exclude_patterns = exclude_patterns or [
            "**/.git/**",
            "**/__pycache__/**",
            "**/venv/**",
            "**/.venv/**",
            "**/node_modules/**",
            "**/*.egg-info/**",
        ]
        self.include_tests = include_tests
        self.symbol_table = SymbolTable()
        self._call_graph: Optional[CallGraph] = None
        self._file_hashes: Dict[str, str] = {}  # For incremental indexing
        self._indexed = False

    def index(self, incremental: bool = True) -> None:
        """Index the repository.

        Args:
            incremental: If True, only re-index changed files
        """
        python_files = self._find_python_files()

        for file_path in python_files:
            rel_path = str(file_path.relative_to(self.root_path))

            # Check if file needs re-indexing
            if incremental:
                current_hash = self._hash_file(file_path)
                if self._file_hashes.get(rel_path) == current_hash:
                    continue
                self._file_hashes[rel_path] = current_hash

            # Index the file
            try:
                source = file_path.read_text(encoding="utf-8")
                self._index_file(rel_path, source)
            except (SyntaxError, UnicodeDecodeError):
                continue

        self._indexed = True

    def _find_python_files(self) -> List[Path]:
        """Find all Python files in the repository."""
        import fnmatch

        python_files = []

        for path in self.root_path.rglob("*.py"):
            rel_path = str(path.relative_to(self.root_path))
            # Normalize path separators
            rel_path_normalized = rel_path.replace(os.sep, "/")

            # Check exclusion patterns
            excluded = False
            for pattern in self.exclude_patterns:
                # Handle ** glob patterns properly
                if "**" in pattern:
                    # Extract directory names from pattern like **/venv/**
                    # Split the path and check if any excluded directory is in the path
                    pattern_clean = pattern.strip("*/")
                    path_parts = rel_path_normalized.split("/")

                    # Check if the excluded directory name is in the path
                    if pattern_clean in path_parts:
                        excluded = True
                        break

                    # Also try fnmatch for more complex patterns
                    if fnmatch.fnmatch(rel_path_normalized, pattern.replace("**", "*")):
                        excluded = True
                        break
                elif fnmatch.fnmatch(rel_path_normalized, pattern):
                    excluded = True
                    break

            # Check test inclusion
            if not self.include_tests and (
                "test" in rel_path.lower()
                or "tests" in rel_path.lower()
                or rel_path.startswith("test_")
            ):
                excluded = True

            if not excluded:
                python_files.append(path)

        return python_files

    def _hash_file(self, path: Path) -> str:
        """Calculate hash of a file for change detection."""
        content = path.read_bytes()
        return hashlib.md5(content).hexdigest()

    def _index_file(self, file_path: str, source: str) -> None:
        """Index a single Python file.

        Args:
            file_path: Relative path to the file
            source: Source code of the file
        """
        try:
            tree = ast.parse(source)
        except SyntaxError:
            return

        # Clear existing symbols for this file (all secondary indexes)
        existing = list(self.symbol_table.by_file.get(file_path, []))
        for full_name in existing:
            self.symbol_table.remove(full_name)

        # Clear imports for this file
        self.symbol_table.imports_graph.pop(file_path, None)

        # Index new symbols
        indexer = _FileIndexer(self.symbol_table, file_path, source)
        indexer.visit(tree)

    @property
    def call_graph(self) -> CallGraph:
        """Get or build the call graph."""
        if self._call_graph is None:
            self._build_call_graph()
        return self._call_graph

    def _build_call_graph(self) -> None:
        """Build call graph from indexed files."""
        from autodebug.analysis.call_graph import CallGraph

        # Collect all source files
        source_files: Dict[str, str] = {}
        for file_path in self.symbol_table.by_file:
            full_path = self.root_path / file_path
            if full_path.exists():
                try:
                    source_files[file_path] = full_path.read_text(encoding="utf-8")
                except (OSError, UnicodeDecodeError):
                    continue

        self._call_graph = CallGraph(source_files)
        self._call_graph.build()

    def get_symbol(self, name: str) -> Optional[SymbolInfo]:
        """Get a symbol by name.

        Args:
            name: Symbol name (can be simple or full name)

        Returns:
            SymbolInfo or None
        """
        # Try as full name first
        if name in self.symbol_table.symbols:
            return self.symbol_table.symbols[name]

        # Try as simple name
        symbols = self.symbol_table.find_by_name(name)
        return symbols[0] if symbols else None

    def get_symbol_at(self, file_path: str, line: int) -> Optional[SymbolInfo]:
        """Get the symbol at a specific location.

        Args:
            file_path: Relative file path
            line: Line number

        Returns:
            SymbolInfo or None
        """
        return self.symbol_table.find_at_line(file_path, line)

    def get_type_definition(self, type_name: str) -> Optional[SymbolInfo]:
        """Find a type/class definition by name.

        Args:
            type_name: Name of the type/class

        Returns:
            SymbolInfo or None
        """
        symbols = self.symbol_table.find_by_name(type_name)
        for symbol in symbols:
            if symbol.symbol_type == SymbolType.CLASS:
                return symbol
        return None

    def get_callers(self, function_name: str) -> List[SymbolInfo]:
        """Get functions that call a given function.

        Args:
            function_name: Name of the function

        Returns:
            List of calling functions
        """
        # Find the function in the call graph
        for full_name, func_info in self.call_graph.functions.items():
            if func_info.name == function_name:
                callers = self.call_graph.get_callers(full_name)
                result = []
                for caller in callers:
                    symbol = self.symbol_table.get(caller)
                    if symbol:
                        result.append(symbol)
                return result
        return []

    def get_callees(self, function_name: str) -> List[SymbolInfo]:
        """Get functions called by a given function.

        Args:
            function_name: Name of the function

        Returns:
            List of called functions
        """
        # Find the function in the call graph
        for full_name, func_info in self.call_graph.functions.items():
            if func_info.name == function_name:
                callees = self.call_graph.get_callees(full_name)
                result = []
                for callee in callees:
                    symbol = self.symbol_table.get(callee)
                    if symbol:
                        result.append(symbol)
                return result
        return []

    def get_imports(self, file_path: str) -> List[str]:
        """Get all imports for a file.

        Args:
            file_path: Relative file path

        Returns:
            List of imported modules
        """
        return list(self.symbol_table.imports_graph.get(file_path, set()))

    def get_related_tests(self, file_path: str) -> List[str]:
        """Find test files related to a source file.

        Args:
            file_path: Relative path to source file

        Returns:
            List of related test file paths
        """
        # Extract module name from path
        path = Path(file_path)
        module_name = path.stem

        related = []
        for test_file in self.symbol_table.by_file:
            test_path = Path(test_file)

            # Check common patterns
            if test_path.stem == f"test_{module_name}":
                related.append(test_file)
            elif test_path.stem == f"{module_name}_test":
                related.append(test_file)
            elif "test" in test_path.stem.lower() and module_name in test_path.stem.lower():
                related.append(test_file)

        return related

    def search(
        self,
        query: str,
        symbol_types: Optional[List[SymbolType]] = None,
        limit: int = 20,
    ) -> List[SymbolInfo]:
        """Search for symbols by name pattern.

        Args:
            query: Search query (substring match)
            symbol_types: Filter to specific symbol types
            limit: Maximum results to return

        Returns:
            List of matching symbols
        """
        results = []
        query_lower = query.lower()

        for symbol in self.symbol_table.symbols.values():
            # Filter by type
            if symbol_types and symbol.symbol_type not in symbol_types:
                continue

            # Match by name
            if query_lower in symbol.name.lower():
                results.append(symbol)
                if len(results) >= limit:
                    break

        return results


class _FileIndexer(ast.NodeVisitor):
    """AST visitor for indexing a single file."""

    def __init__(self, symbol_table: SymbolTable, file_path: str, source: str):
        self.symbol_table = symbol_table
        self.file_path = file_path
        self.source = source
        self.source_lines = source.split("\n")
        self.current_class: Optional[str] = None

    def visit_Import(self, node: ast.Import) -> None:
        """Index import statements."""
        if self.file_path not in self.symbol_table.imports_graph:
            self.symbol_table.imports_graph[self.file_path] = set()

        for alias in node.names:
            self.symbol_table.imports_graph[self.file_path].add(alias.name)

            # Create symbol for import
            symbol = SymbolInfo(
                name=alias.asname or alias.name,
                symbol_type=SymbolType.IMPORT,
                file_path=self.file_path,
                start_line=node.lineno,
                end_line=node.lineno,
                imports=[alias.name],
            )
            self.symbol_table.add(symbol)

        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        """Index from-import statements."""
        if self.file_path not in self.symbol_table.imports_graph:
            self.symbol_table.imports_graph[self.file_path] = set()

        module = node.module or ""
        self.symbol_table.imports_graph[self.file_path].add(module)

        for alias in node.names:
            full_import = f"{module}.{alias.name}" if module else alias.name
            symbol = SymbolInfo(
                name=alias.asname or alias.name,
                symbol_type=SymbolType.IMPORT,
                file_path=self.file_path,
                start_line=node.lineno,
                end_line=node.lineno,
                imports=[full_import],
            )
            self.symbol_table.add(symbol)

        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        """Index class definitions."""
        docstring = ast.get_docstring(node)
        decorators = [self._get_decorator_name(d) for d in node.decorator_list]

        symbol = SymbolInfo(
            name=node.name,
            symbol_type=SymbolType.CLASS,
            file_path=self.file_path,
            start_line=node.lineno,
            end_line=node.end_lineno or node.lineno,
            docstring=docstring,
            decorators=decorators,
        )
        self.symbol_table.add(symbol)

        # Visit methods with class context
        old_class = self.current_class
        self.current_class = node.name
        self.generic_visit(node)
        self.current_class = old_class

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Index function definitions."""
        self._index_function(node, is_async=False)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        """Index async function definitions."""
        self._index_function(node, is_async=True)
        self.generic_visit(node)

    def _index_function(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
        is_async: bool,
    ) -> None:
        """Index a function or method."""
        docstring = ast.get_docstring(node)
        decorators = [self._get_decorator_name(d) for d in node.decorator_list]

        # Build signature
        signature = self._build_signature(node)

        # Extract type annotations
        annotations = self._extract_annotations(node)

        symbol_type = SymbolType.METHOD if self.current_class else SymbolType.FUNCTION

        symbol = SymbolInfo(
            name=node.name,
            symbol_type=symbol_type,
            file_path=self.file_path,
            start_line=node.lineno,
            end_line=node.end_lineno or node.lineno,
            docstring=docstring,
            parent=self.current_class,
            signature=signature,
            decorators=decorators,
            annotations=annotations,
        )
        self.symbol_table.add(symbol)

    def visit_Assign(self, node: ast.Assign) -> None:
        """Index module-level assignments (constants/variables)."""
        # Only index module-level assignments
        if self.current_class is not None:
            self.generic_visit(node)
            return

        for target in node.targets:
            if isinstance(target, ast.Name):
                # Check if it looks like a constant (ALL_CAPS)
                symbol_type = (
                    SymbolType.CONSTANT
                    if target.id.isupper()
                    else SymbolType.VARIABLE
                )

                symbol = SymbolInfo(
                    name=target.id,
                    symbol_type=symbol_type,
                    file_path=self.file_path,
                    start_line=node.lineno,
                    end_line=node.end_lineno or node.lineno,
                )
                self.symbol_table.add(symbol)

        self.generic_visit(node)

    def _get_decorator_name(self, decorator: ast.expr) -> str:
        """Get the name of a decorator."""
        if isinstance(decorator, ast.Name):
            return decorator.id
        elif isinstance(decorator, ast.Attribute):
            return decorator.attr
        elif isinstance(decorator, ast.Call):
            return self._get_decorator_name(decorator.func)
        return "unknown"

    def _build_signature(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> str:
        """Build function signature string."""
        args = []

        # Regular arguments
        for arg in node.args.args:
            arg_str = arg.arg
            if arg.annotation:
                arg_str += f": {ast.unparse(arg.annotation)}"
            args.append(arg_str)

        # *args
        if node.args.vararg:
            args.append(f"*{node.args.vararg.arg}")

        # **kwargs
        if node.args.kwarg:
            args.append(f"**{node.args.kwarg.arg}")

        # Return type
        return_str = ""
        if node.returns:
            return_str = f" -> {ast.unparse(node.returns)}"

        async_prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else ""
        return f"{async_prefix}def {node.name}({', '.join(args)}){return_str}"

    def _extract_annotations(
        self,
        node: ast.FunctionDef | ast.AsyncFunctionDef,
    ) -> Dict[str, str]:
        """Extract type annotations from function."""
        annotations = {}

        for arg in node.args.args:
            if arg.annotation:
                annotations[arg.arg] = ast.unparse(arg.annotation)

        if node.returns:
            annotations["return"] = ast.unparse(node.returns)

        return annotations
