"""Code Explorer using ripgrep for text search and filesystem reads.

Handles single-repo and multi-repo (linked repos) modes in one class.
Linked repos are namespaced with `[name]/path` prefixes.

Tools:
1. grep() - Ripgrep-based search (subprocess, bounded output)
2. read_file() - Read entire file from disk
3. read_lines() - Read specific line range from disk
"""

from __future__ import annotations

import logging
import os
import re
import shutil
import subprocess
from pathlib import Path

from code_reviewer.explorer.schema import SearchMatch, SearchResult

logger = logging.getLogger(__name__)


class CodeExplorer:
    """Code explorer with ripgrep search and filesystem reads.

    Supports linked repos via add_linked(). Files from linked repos
    are namespaced: `[name]/src/main.py`.

    Usage:
        explorer = CodeExplorer(Path("./src"))
        explorer.add_linked("lib", Path("./lib"), display_name="shared-lib")
        result = explorer.grep("BATCH_SIZE")
        content = explorer.read_file("[lib]/src/main.py")
    """

    def __init__(self, root_path: Path):
        """Initialize with a root directory path.

        Args:
            root_path: Root directory of the primary codebase
        """
        self._root_path = root_path
        self._linked: dict[str, tuple[Path, str | None]] = {}  # name -> (root_path, display_name)
        self._rg_cmd = self._find_rg()

    @staticmethod
    def _find_rg() -> list[str]:
        """Find ripgrep binary. Raises if not found."""
        if shutil.which("rg"):
            return ["rg"]
        raise FileNotFoundError(
            "ripgrep (rg) is required but not found. "
            "Install it: apt-get install ripgrep / brew install ripgrep"
        )

    def add_linked(self, name: str, root_path: Path, display_name: str | None = None) -> None:
        """Register a linked repository for cross-repo exploration.

        Args:
            name: Short identifier used in namespaced paths [name]/...
            root_path: Root directory of the linked repo
            display_name: Human-readable name for prompts
        """
        self._linked[name] = (root_path, display_name)
        logger.info(f"Added linked repo '{name}' ({display_name or 'no display name'})")

    def get_linked_repos_info(self) -> list[dict[str, str | None]]:
        """Get information about linked repositories for prompt rendering."""
        return [
            {"name": name, "display_name": display_name}
            for name, (_, display_name) in self._linked.items()
        ]

    def _parse_path(self, path: str) -> tuple[str | None, str]:
        """Parse a namespaced path into (namespace, actual_path).

        '[lib]/src/main.py' -> ('lib', 'src/main.py')
        'src/main.py' -> (None, 'src/main.py')
        """
        match = re.match(r"^\[([^\]]+)\]/(.+)$", path)
        if match:
            return match.group(1), match.group(2)
        return None, path

    def _resolve_root(self, namespace: str | None) -> Path | None:
        """Resolve namespace to root path."""
        if namespace is None:
            return self._root_path
        entry = self._linked.get(namespace)
        return entry[0] if entry else None

    def get_stats(self) -> dict:
        """Get statistics about the codebase (primary + linked)."""
        total_files = 0
        files_by_ext: dict[str, int] = {}

        for root_path in [self._root_path] + [p for p, _ in self._linked.values()]:
            for root, _dirs, files in os.walk(root_path):
                rel = os.path.relpath(root, root_path)
                parts = rel.split(os.sep)
                if any(
                    p in {".git", ".hg", "node_modules", "__pycache__", ".venv", "venv", ".tox"}
                    for p in parts
                ):
                    continue
                for f in files:
                    ext = Path(f).suffix.lower()
                    files_by_ext[ext] = files_by_ext.get(ext, 0) + 1
                    total_files += 1

        return {
            "total_files": total_files,
            "files_by_extension": files_by_ext,
        }

    def read_file(self, file_path: str) -> str:
        """Read entire file from disk. Supports namespaced paths.

        Args:
            file_path: Path relative to project root, or [namespace]/path

        Returns:
            File content with line numbers, or error message
        """
        namespace, actual_path = self._parse_path(file_path)
        root = self._resolve_root(namespace)
        if root is None:
            return f"Linked repository not found: {namespace}"

        full_path = root / actual_path
        if not full_path.is_file():
            return f"File not found: {file_path}"

        try:
            content = full_path.read_text(encoding="utf-8", errors="replace")
        except Exception as e:
            return f"Error reading {file_path}: {e}"

        lines = content.split("\n")
        return "\n".join(f"{i + 1:4d}| {lines[i]}" for i in range(len(lines)))

    def read_lines(self, file_path: str, start_line: int, end_line: int | None = None) -> str:
        """Read a line range from a file. Supports namespaced paths.

        Args:
            file_path: Path relative to project root, or [namespace]/path
            start_line: First line to read (1-indexed)
            end_line: Last line to read (default: start_line + 50)
        """
        namespace, actual_path = self._parse_path(file_path)
        root = self._resolve_root(namespace)
        if root is None:
            return f"Linked repository not found: {namespace}"

        full_path = root / actual_path
        if not full_path.is_file():
            return f"File not found: {file_path}"

        try:
            content = full_path.read_text(encoding="utf-8", errors="replace")
        except Exception as e:
            return f"Error reading {file_path}: {e}"

        lines = content.split("\n")
        if end_line is None:
            end_line = start_line + 50

        start_line = max(1, start_line)
        end_line = min(len(lines), end_line)

        return "\n".join(f"{i + 1:4d}| {lines[i]}" for i in range(start_line - 1, end_line))

    def _grep_one(
        self,
        root_path: Path,
        pattern: str,
        file_glob: str | None,
        max_results: int,
        max_columns: int,
        context_lines: int,
        namespace: str | None = None,
    ) -> list[SearchMatch]:
        """Run ripgrep on a single root directory.

        Args:
            namespace: If set, prefix file paths with [namespace]/
        """
        cmd = [
            *self._rg_cmd,
            "--no-heading",
            "--line-number",
            "--max-count",
            str(max_results),
            "--max-columns",
            str(max_columns),
            "--max-columns-preview",
            "-i",  # case-insensitive
        ]
        if context_lines > 0:
            cmd.extend(["-C", str(context_lines)])
        if file_glob:
            cmd.extend(["--glob", file_glob])

        cmd.append(pattern)

        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=10,
                cwd=str(root_path),
            )
        except subprocess.TimeoutExpired:
            logger.warning("ripgrep timed out")
            return []

        matches: list[SearchMatch] = []
        root_str = str(root_path)

        def _prefix(path: str) -> str:
            if namespace is not None:
                return f"[{namespace}]/{path}"
            return path

        if context_lines > 0:
            groups = result.stdout.split("--\n")
            for group in groups:
                if len(matches) >= max_results:
                    break
                group = group.strip()
                if not group:
                    continue
                match_file = None
                match_line = None
                for gline in group.splitlines():
                    parts = gline.split(":", 2)
                    if len(parts) >= 3:
                        try:
                            int(parts[1])
                            fp = parts[0]
                            if fp.startswith(root_str):
                                fp = fp[len(root_str) :].lstrip("/")
                            match_file = fp
                            match_line = int(parts[1])
                            break
                        except ValueError:
                            continue
                if not match_file or not match_line:
                    continue
                content_lines = []
                for gline in group.splitlines():
                    rest = gline
                    if rest.startswith(root_str):
                        rest = rest[len(root_str) :].lstrip("/")
                    if match_file and rest.startswith(match_file):
                        rest = rest[len(match_file) :]
                        if rest and rest[0] in (":", "-"):
                            rest = rest[1:]
                    content_lines.append(rest)
                matches.append(
                    SearchMatch(
                        file_path=_prefix(match_file),
                        line=match_line,
                        content="\n".join(content_lines),
                    )
                )
        else:
            for line in result.stdout.splitlines():
                if len(matches) >= max_results:
                    break
                parts = line.split(":", 2)
                if len(parts) < 3:
                    continue
                file_path_abs, line_num_str, content = parts
                file_path = file_path_abs
                if file_path.startswith(root_str):
                    file_path = file_path[len(root_str) :].lstrip("/")
                try:
                    line_num = int(line_num_str)
                except ValueError:
                    continue
                matches.append(
                    SearchMatch(
                        file_path=_prefix(file_path),
                        line=line_num,
                        content=content.strip()[:max_columns],
                    )
                )

        return matches

    def grep(
        self,
        pattern: str,
        file_glob: str | None = None,
        max_results: int = 20,
        max_columns: int = 200,
        context_lines: int = 0,
        project: str | None = None,
    ) -> SearchResult:
        """Search using ripgrep. Searches primary repo first, then linked repos.

        Args:
            pattern: Regex pattern to search for
            file_glob: Optional glob to filter files (e.g., "*.py")
            max_results: Maximum number of matches (default: 20)
            max_columns: Maximum characters per line in output (default: 200)
            context_lines: Lines of context around each match (default: 0)
            project: Scope search to a specific linked repo name, or "all" for everything

        Returns:
            SearchResult with matches
        """
        all_matches: list[SearchMatch] = []

        # Determine which repos to search
        search_primary = (
            project is None or project == "" or project == "all" or project == "primary"
        )
        specific_linked = project if project and project in self._linked else None
        search_all_linked = project == "all" or project is None or project == ""

        if specific_linked:
            # Only search specific linked repo
            root_path, _ = self._linked[specific_linked]
            all_matches = self._grep_one(
                root_path,
                pattern,
                file_glob,
                max_results,
                max_columns,
                context_lines,
                namespace=specific_linked,
            )
        else:
            # Search primary
            if search_primary:
                all_matches = self._grep_one(
                    self._root_path,
                    pattern,
                    file_glob,
                    max_results,
                    max_columns,
                    context_lines,
                )

            # Search linked repos
            if search_all_linked and self._linked:
                remaining = max_results - len(all_matches)
                if remaining > 0:
                    for name, (root_path, _) in self._linked.items():
                        if len(all_matches) >= max_results:
                            break
                        linked_matches = self._grep_one(
                            root_path,
                            pattern,
                            file_glob,
                            remaining,
                            max_columns,
                            context_lines,
                            namespace=name,
                        )
                        all_matches.extend(linked_matches)
                        remaining = max_results - len(all_matches)

        query = f"grep:{pattern}"
        if project:
            query += f" project={project}"
        return SearchResult(matches=all_matches[:max_results], query=query)
