"""Codebase scanner: file walker, AST symbol extractor, and summary builder.

This module provides three layers of codebase analysis:

1. **File walker** (``walk_project_files``): Recursively walks a project
   directory and returns sorted relative paths of source files, respecting
   ``.gitignore`` rules and hardcoded exclusion patterns for common
   non-source directories (e.g. ``.git``, ``node_modules``, ``__pycache__``).

2. **Symbol extractor** (``extract_symbols``): Parses Python source files
   using the ``ast`` module and extracts top-level classes, functions
   (including methods and async defs), and import statements.

3. **Codebase summary** (``scan_codebase``, ``CodebaseSummary``): Combines
   the file walker and symbol extractor to produce a ``CodebaseSummary``
   Pydantic model.  This summary can be serialised into a prompt-friendly
   string (with importance-based ranking and token budgeting) for injection
   into the decomposer's LLM context.

The main entry point for external callers is ``scan_codebase(root)``, which
returns a ``CodebaseSummary`` ready for use by the decomposer and validator.

Limitations:
    - Only reads the root-level .gitignore file.  Nested .gitignore files
      in subdirectories are not currently parsed.
"""

from __future__ import annotations

import importlib
import logging
import subprocess
import sys
from pathlib import Path

logger = logging.getLogger(__name__)

# ── Hardcoded exclusions ─────────────────────────────────────────────

EXCLUDED_DIRS: frozenset[str] = frozenset(
    {
        ".git",
        "node_modules",
        "__pycache__",
        ".venv",
        "venv",
        ".env",
        "dist",
        "build",
        ".mypy_cache",
        ".pytest_cache",
        ".ruff_cache",
        "coverage",
        ".tox",
        "htmlcov",
        "*.egg-info",
    }
)

EXCLUDED_SUFFIXES: frozenset[str] = frozenset(
    {
        ".pyc",
        ".pyo",
        ".pyd",
        ".so",
        ".dylib",
        ".dll",
    }
)

# ── pathspec bootstrap ───────────────────────────────────────────────

try:
    import pathspec  # type: ignore[import-untyped]
except ImportError:

    def _bootstrap_pathspec() -> None:
        """Attempt runtime install of pathspec as a fallback."""
        subprocess.check_call(
            [sys.executable, "-m", "pip", "install", "pathspec"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

    try:
        _bootstrap_pathspec()
        pathspec = importlib.import_module("pathspec")  # type: ignore[no-redef]
    except Exception as exc:
        raise ImportError(
            "The 'pathspec' library is required but could not be installed. "
            "Install it manually with: pip install pathspec"
        ) from exc


# ── Helpers ──────────────────────────────────────────────────────────


def _has_excluded_part(rel_path: Path) -> bool:
    """Check if any component of *rel_path* matches an excluded dir pattern."""
    for part in rel_path.parts:
        if part in EXCLUDED_DIRS:
            return True
        # Handle glob-style patterns like *.egg-info
        for pattern in EXCLUDED_DIRS:
            if pattern.startswith("*") and part.endswith(pattern[1:]):
                return True
    return False


def _load_gitignore(root: Path) -> pathspec.PathSpec | None:
    """Load and parse the root-level .gitignore, or return None."""
    gitignore_path = root / ".gitignore"
    if not gitignore_path.is_file():
        logger.debug(
            "No .gitignore found at %s; using only hardcoded exclusions",
            gitignore_path,
        )
        return None
    try:
        text = gitignore_path.read_text(encoding="utf-8")
    except (OSError, PermissionError) as exc:
        logger.warning("Could not read .gitignore at %s: %s", gitignore_path, exc)
        return None
    # .splitlines() handles both \n and \r\n
    lines = text.splitlines()
    return pathspec.PathSpec.from_lines("gitignore", lines)


# ── Public API ───────────────────────────────────────────────────────


def walk_project_files(root: Path = Path(".")) -> list[Path]:
    """Recursively walk *root* and return sorted relative paths of source files.

    Parameters
    ----------
    root:
        Project root directory to walk.  Defaults to the current directory.

    Returns
    -------
    list[Path]
        Lexicographically sorted list of paths relative to *root*.

    Raises
    ------
    ValueError
        If *root* does not exist or is not a directory.
    """
    root = root.resolve()

    if not root.exists() or not root.is_dir():
        raise ValueError(f"{root} is not a valid directory")

    spec = _load_gitignore(root)
    result: list[Path] = []

    def _walk(directory: Path) -> None:
        """Walk *directory*, pruning excluded dirs early."""
        try:
            entries = sorted(directory.iterdir())
        except PermissionError as exc:
            logger.warning("Permission denied: %s — skipping", exc)
            return

        for entry in entries:
            try:
                rel = entry.relative_to(root)
            except ValueError:
                continue

            # ── Directory handling ───────────────────────────────
            if entry.is_dir() and not entry.is_symlink():
                # Prune hardcoded excluded dirs
                if _has_excluded_part(rel):
                    continue
                # Skip gitignore-matched dirs
                # pathspec matches dirs when the relative string ends with /
                if spec and spec.match_file(str(rel) + "/"):
                    continue
                _walk(entry)
                continue

            # ── Symlink guard ────────────────────────────────────
            if entry.is_symlink():
                try:
                    resolved = entry.resolve(strict=True)
                    if not resolved.is_relative_to(root):
                        logger.debug(
                            "Skipping symlink outside root: %s -> %s", entry, resolved
                        )
                        continue
                except (OSError, ValueError):
                    # Broken symlink or resolution error
                    continue

            # ── File handling ────────────────────────────────────
            if not entry.is_file():
                continue

            # Check excluded parts (a file inside an excluded dir that
            # wasn't pruned because it was reached via symlink or similar)
            if _has_excluded_part(rel):
                continue

            # Excluded suffixes
            if entry.suffix in EXCLUDED_SUFFIXES:
                continue

            # Gitignore check
            if spec and spec.match_file(str(rel)):
                continue

            result.append(rel)

    _walk(root)
    return sorted(result)


# -- AST Symbol Extraction -----------------------------------------------

import ast
from collections.abc import Iterable

_MAX_FILE_SIZE = 1_048_576  # 1 MB


def extract_symbols(
    file_paths: Iterable[str | Path],
) -> dict[str, dict[str, list[str]]]:
    """Extract top-level symbols from Python source files using the AST.

    For each file, returns a dict with three keys:
      - ``classes``:   top-level class names
      - ``functions``: top-level function names **and** methods inside classes
                       (including async defs)
      - ``imports``:   module names from ``import X`` and ``from X import Y``

    Design choices:
      - Relative imports (``from . import foo``) are skipped because they
        don't map to a stable module name.
      - Files larger than 1 MB are skipped to avoid excessive memory use.
      - Encoding errors are replaced (``errors='replace'``) rather than
        raising, so binary-ish files degrade gracefully.

    Parameters
    ----------
    file_paths:
        An iterable of file paths (strings or Path objects) to analyse.

    Returns
    -------
    dict[str, dict[str, list[str]]]
        Mapping of ``filepath -> {classes, functions, imports}`` where each
        value list is sorted and deduplicated.
    """
    results: dict[str, dict[str, list[str]]] = {}

    for fp in file_paths:
        filepath = Path(fp)
        key = str(fp)

        # --- guard: file size ------------------------------------------
        try:
            size = filepath.stat().st_size
        except OSError as exc:
            logger.warning("Cannot stat %s: %s -- skipping", filepath, exc)
            results[key] = {"classes": [], "functions": [], "imports": []}
            continue

        if size > _MAX_FILE_SIZE:
            logger.warning(
                "File %s exceeds 1 MB (%d bytes) -- skipping symbol extraction",
                filepath,
                size,
            )
            results[key] = {"classes": [], "functions": [], "imports": []}
            continue

        # --- read source -----------------------------------------------
        try:
            source = filepath.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            logger.warning("Cannot read %s: %s -- skipping", filepath, exc)
            results[key] = {"classes": [], "functions": [], "imports": []}
            continue

        # --- parse AST -------------------------------------------------
        try:
            tree = ast.parse(source, filename=str(filepath))
        except SyntaxError as exc:
            logger.warning("Syntax error in %s: %s -- skipping", filepath, exc)
            results[key] = {"classes": [], "functions": [], "imports": []}
            continue

        classes: set[str] = set()
        functions: set[str] = set()
        imports: set[str] = set()

        for node in ast.iter_child_nodes(tree):
            # Top-level class
            if isinstance(node, ast.ClassDef):
                classes.add(node.name)
                # Collect methods (sync and async) inside this class
                for item in ast.iter_child_nodes(node):
                    if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        functions.add(item.name)

            # Top-level function (sync or async)
            elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                functions.add(node.name)

            # import X, import X as Y, import X, Y
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    imports.add(alias.name)

            # from X import Y  (skip relative imports where level > 0)
            elif isinstance(node, ast.ImportFrom):
                # Relative imports have level > 0; skip them because they
                # don't resolve to a stable, absolute module name.
                if node.level == 0 and node.module:
                    imports.add(node.module)

        results[key] = {
            "classes": sorted(classes),
            "functions": sorted(functions),
            "imports": sorted(imports),
        }

    return results


# -- Codebase Summary Serializer -----------------------------------------

from pydantic import BaseModel

# Module-level constant (not inside the Pydantic model to avoid
# ModelPrivateAttr interpretation of underscore-prefixed names).
_BOOSTED_NAMES: frozenset[str] = frozenset(
    {"main.py", "cli.py", "app.py", "__main__.py"}
)


class FileSymbols(BaseModel):
    """Symbols extracted from a single source file."""

    path: str
    functions: list[str] = []
    classes: list[str] = []
    imports: list[str] = []


class CodebaseSummary(BaseModel):
    """Aggregated symbol summary for an entire codebase."""

    root: str
    files: list[FileSymbols] = []

    # -- Importance helpers ------------------------------------------------

    @staticmethod
    def _importance(fs: "FileSymbols") -> int:
        """Score a file for prompt inclusion priority.

        Higher is more important.  The formula:
          base  = num_functions * 2 + num_classes * 3
          boost = +10 for main.py / cli.py / app.py / __main__.py
          penalty = -3 for test_ prefixed filenames
        """
        base = len(fs.functions) * 2 + len(fs.classes) * 3
        name = Path(fs.path).name
        if name in _BOOSTED_NAMES:
            base += 10
        if name.startswith("test_"):
            base -= 3
        return base

    # -- Serialisation -----------------------------------------------------

    def to_prompt_str(self, max_tokens: int = 2000) -> str:
        """Render a compact, prompt-friendly summary within a token budget.

        Token estimation: len(text) // 4 (rough chars-per-token ratio).

        Files are sorted by importance (descending).  The formatter
        greedily appends file sections until the budget is exhausted, but
        always includes at least one file even if it exceeds the budget.

        Returns a multi-line Markdown-ish string.
        """
        if not self.files:
            return "(empty codebase)"

        ranked = sorted(self.files, key=self._importance, reverse=True)
        parts: list[str] = []
        used_tokens = 0
        included = 0

        for fs in ranked:
            section = self._format_file(fs)
            section_tokens = len(section) // 4

            # Always include at least one file
            if included > 0 and used_tokens + section_tokens > max_tokens:
                break

            parts.append(section)
            used_tokens += section_tokens
            included += 1

        omitted = len(ranked) - included
        if omitted > 0:
            parts.append(f"## [truncated: {omitted} files omitted]")

        return "\n".join(parts)

    @staticmethod
    def _format_file(fs: "FileSymbols") -> str:
        """Render a single file section."""
        lines = [f"## {fs.path}"]
        if fs.functions:
            lines.append(f"functions: {', '.join(fs.functions)}")
        if fs.classes:
            lines.append(f"classes: {', '.join(fs.classes)}")
        if fs.imports:
            lines.append(f"imports: {', '.join(fs.imports)}")
        return "\n".join(lines)

    # -- Dict round-trip ---------------------------------------------------

    def to_dict(self) -> dict:
        return self.model_dump()

    @classmethod
    def from_dict(cls, data: dict) -> "CodebaseSummary":
        return cls.model_validate(data)


def scan_codebase(root: str | Path) -> CodebaseSummary:
    """Walk root, extract symbols from .py files, and return a summary.

    Steps:
      1. walk_project_files(root) for the file list.
      2. Filter to .py files.
      3. extract_symbols() on the absolute paths.
      4. Build FileSymbols entries keyed by relative path.
      5. Return a CodebaseSummary.
    """
    root_path = Path(root).resolve()
    rel_paths = walk_project_files(root_path)

    py_rels = [p for p in rel_paths if p.suffix == ".py"]

    # extract_symbols needs absolute paths (it reads files)
    abs_paths = [str(root_path / p) for p in py_rels]
    symbols_map = extract_symbols(abs_paths)

    file_symbols: list[FileSymbols] = []
    for rel, absp in zip(py_rels, abs_paths):
        info = symbols_map.get(absp, {"classes": [], "functions": [], "imports": []})
        file_symbols.append(
            FileSymbols(
                path=str(rel),
                functions=info["functions"],
                classes=info["classes"],
                imports=info["imports"],
            )
        )

    return CodebaseSummary(root=str(root_path), files=file_symbols)
