"""RBSP Git Repository Integration.

Provides automatic indexing and search for Git repositories.  Uses ``git
ls-files`` (via subprocess) as the primary method for listing tracked files
and falls back to a manual directory walk filtered by ``.gitignore`` rules
when Git is not available.

Includes a full ``.gitignore`` parser supporting:
    - Glob patterns (``*``, ``**``, ``?``)
    - Negation (``!pattern``)
    - Comments (``# ...``)
    - Directory-only patterns (trailing ``/``)
    - Rooted patterns (leading ``/``)

No external dependencies beyond the Python standard library and a Git CLI
installation (for the primary ``git ls-files`` path).
"""

from __future__ import annotations

import fnmatch
import logging
import os
import re
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

from rbsp.runtime.engine import RBSPEngine, Result

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# .gitignore parser
# ---------------------------------------------------------------------------

class _GitignoreRule:
    """A single compiled .gitignore rule."""

    __slots__ = ("pattern", "regex", "negated", "dir_only", "anchored")

    def __init__(
        self,
        pattern: str,
        regex: re.Pattern[str],
        negated: bool,
        dir_only: bool,
        anchored: bool,
    ) -> None:
        self.pattern = pattern
        self.regex = regex
        self.negated = negated
        self.dir_only = dir_only
        self.anchored = anchored

    def matches(self, rel_path: str, is_dir: bool) -> bool:
        """Test whether *rel_path* (forward-slash separated) matches this rule."""
        if self.dir_only and not is_dir:
            return False
        return self.regex.search(rel_path) is not None

    def __repr__(self) -> str:
        neg = "!" if self.negated else ""
        return f"_GitignoreRule({neg}{self.pattern!r})"


def _gitignore_pattern_to_regex(pattern: str) -> str:
    """Convert a .gitignore glob pattern to a regex string.

    Handles:
        - ``*`` matches everything except ``/``
        - ``**`` matches everything including ``/``
        - ``?`` matches any single character except ``/``
        - Character classes ``[abc]``
        - Escaping with ``\\``
    """
    i = 0
    n = len(pattern)
    result: List[str] = []

    while i < n:
        c = pattern[i]

        if c == "\\" and i + 1 < n:
            # Escaped character
            result.append(re.escape(pattern[i + 1]))
            i += 2
        elif c == "*":
            if i + 1 < n and pattern[i + 1] == "*":
                # ** - match everything including path separators
                if i + 2 < n and pattern[i + 2] == "/":
                    # **/ - match zero or more directories
                    result.append("(?:.+/)?")
                    i += 3
                else:
                    # ** at end or before non-slash
                    result.append(".*")
                    i += 2
            else:
                # * - match everything except /
                result.append("[^/]*")
                i += 1
        elif c == "?":
            result.append("[^/]")
            i += 1
        elif c == "[":
            # Character class - find closing ]
            j = i + 1
            if j < n and pattern[j] == "!":
                j += 1
            if j < n and pattern[j] == "]":
                j += 1
            while j < n and pattern[j] != "]":
                j += 1
            if j < n:
                cls_body = pattern[i + 1 : j]
                # Replace leading ! with ^ for regex negation
                if cls_body.startswith("!"):
                    cls_body = "^" + cls_body[1:]
                result.append(f"[{cls_body}]")
                i = j + 1
            else:
                result.append(re.escape(c))
                i += 1
        else:
            result.append(re.escape(c))
            i += 1

    return "".join(result)


def parse_gitignore(content: str) -> List[_GitignoreRule]:
    """Parse the content of a ``.gitignore`` file into a list of rules.

    Args:
        content: Raw text content of the .gitignore file.

    Returns:
        Ordered list of rules (last matching rule wins).
    """
    rules: List[_GitignoreRule] = []

    for raw_line in content.splitlines():
        line = raw_line.rstrip()

        # Skip blank lines and comments
        if not line or line.startswith("#"):
            continue

        # Handle negation
        negated = False
        if line.startswith("!"):
            negated = True
            line = line[1:]

        # Strip trailing spaces unless escaped
        if not line.endswith("\\ "):
            line = line.rstrip()

        if not line:
            continue

        # Directory-only pattern (trailing /)
        dir_only = line.endswith("/")
        if dir_only:
            line = line.rstrip("/")

        # Anchored if the pattern contains a / (other than trailing)
        # or starts with /
        anchored = False
        if line.startswith("/"):
            anchored = True
            line = line.lstrip("/")
        elif "/" in line:
            anchored = True

        # Convert to regex
        regex_str = _gitignore_pattern_to_regex(line)

        if anchored:
            # Must match from the start of the relative path
            regex_str = "^" + regex_str
        else:
            # Can match anywhere in the path (basename matching)
            regex_str = "(?:^|/)" + regex_str

        # Pattern should match the full remaining path
        if not dir_only:
            regex_str += "(?:$|/)"
        else:
            regex_str += "(?:$|/)"

        try:
            compiled = re.compile(regex_str)
        except re.error:
            logger.debug("Invalid gitignore pattern: %r", raw_line)
            continue

        rules.append(_GitignoreRule(
            pattern=raw_line.strip(),
            regex=compiled,
            negated=negated,
            dir_only=dir_only,
            anchored=anchored,
        ))

    return rules


def _load_gitignore_rules(repo_root: Path) -> List[_GitignoreRule]:
    """Load and parse the .gitignore file at *repo_root*, if it exists."""
    gitignore = repo_root / ".gitignore"
    if not gitignore.is_file():
        return []
    try:
        content = gitignore.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return []
    return parse_gitignore(content)


def is_ignored(
    rel_path: str,
    is_dir: bool,
    rules: List[_GitignoreRule],
) -> bool:
    """Determine whether *rel_path* is ignored by the given rules.

    Uses the standard .gitignore semantics: the last matching rule wins.
    Forward slashes are used regardless of platform.

    Args:
        rel_path: Path relative to the repository root (forward-slash separated).
        is_dir:   Whether the path is a directory.
        rules:    Parsed gitignore rules.

    Returns:
        True if the path should be ignored.
    """
    # Normalise to forward slashes
    rel_path = rel_path.replace("\\", "/")
    if rel_path.startswith("/"):
        rel_path = rel_path[1:]

    result = False
    for rule in rules:
        if rule.matches(rel_path, is_dir):
            result = not rule.negated
    return result


# ---------------------------------------------------------------------------
# Git operations
# ---------------------------------------------------------------------------

def _git_ls_files(repo_path: str) -> Optional[List[str]]:
    """Run ``git ls-files`` and return the list of tracked files.

    Returns ``None`` if Git is not available or the command fails.
    """
    try:
        proc = subprocess.run(
            ["git", "ls-files", "-z"],
            cwd=repo_path,
            capture_output=True,
            timeout=30,
        )
        if proc.returncode != 0:
            return None
        raw = proc.stdout.decode("utf-8", errors="replace")
        files = [f for f in raw.split("\0") if f]
        return files
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return None


def _manual_walk(repo_path: str, rules: List[_GitignoreRule]) -> List[str]:
    """Walk the repository manually, respecting .gitignore rules.

    Fallback when ``git ls-files`` is not available.

    Args:
        repo_path: Absolute path to the repository root.
        rules:     Parsed gitignore rules.

    Returns:
        List of relative file paths (forward-slash separated).
    """
    root = Path(repo_path).resolve()
    files: List[str] = []

    # Always ignore .git directory
    always_ignored = {".git"}

    for dirpath, dirnames, filenames in os.walk(root):
        # Compute relative directory path
        rel_dir = Path(dirpath).relative_to(root).as_posix()
        if rel_dir == ".":
            rel_dir = ""

        # Filter directories in-place (prune ignored dirs)
        dirnames[:] = [
            d for d in dirnames
            if d not in always_ignored
            and not is_ignored(
                f"{rel_dir}/{d}" if rel_dir else d,
                True,
                rules,
            )
        ]

        for fname in filenames:
            rel_file = f"{rel_dir}/{fname}" if rel_dir else fname
            if not is_ignored(rel_file, False, rules):
                files.append(rel_file)

    return files


# ---------------------------------------------------------------------------
# Git Integration
# ---------------------------------------------------------------------------

class GitIntegration:
    """Git repository integration for RBSP.

    Indexes tracked files in a Git repository and provides search
    capabilities via the RBSP engine.  Respects ``.gitignore`` rules.

    Uses ``git ls-files`` as the primary method for listing tracked files
    and falls back to a manual walk with ``.gitignore`` parsing when Git
    is unavailable.

    Args:
        engine: An optional ``RBSPEngine`` instance.  If not provided,
                the global singleton is used.

    Usage::

        git = GitIntegration()
        stats = git.index_repo("/path/to/repo")
        results = git.search_repo("/path/to/repo", "authentication")
    """

    def __init__(self, engine: Optional[RBSPEngine] = None) -> None:
        self._engine = engine or RBSPEngine.get_instance()
        self._indexed_repos: Dict[str, _RepoState] = {}

    def index_repo(
        self,
        repo_path: str,
        *,
        force: bool = False,
        file_extensions: Optional[Set[str]] = None,
    ) -> Dict[str, Any]:
        """Index all tracked files in a Git repository.

        Walks the repository using ``git ls-files`` (preferred) or a manual
        walk with ``.gitignore`` filtering (fallback).  Files are indexed
        via the RBSP engine.

        Args:
            repo_path:        Path to the repository root.
            force:            Re-index all files even if unchanged.
            file_extensions:  If provided, only index files with these
                              extensions (e.g. ``{".py", ".js"}``).

        Returns:
            Dict with indexing statistics: ``files_indexed``,
            ``files_skipped``, ``total_files``, ``method`` (``"git"`` or
            ``"walk"``), ``duration``.
        """
        import time

        start = time.monotonic()
        root = Path(repo_path).resolve()

        if not root.is_dir():
            raise FileNotFoundError(f"Repository path not found: {root}")

        # Initialise engine for this repo if needed
        if not self._engine.is_initialized:
            self._engine.init(str(root))

        # Get file list
        method = "git"
        git_files = _git_ls_files(str(root))
        if git_files is not None:
            rel_files = git_files
        else:
            method = "walk"
            rules = _load_gitignore_rules(root)
            rel_files = _manual_walk(str(root), rules)
            logger.info("Git not available, using manual walk (%d files)", len(rel_files))

        # Filter by extension if requested
        if file_extensions:
            ext_set = {e if e.startswith(".") else f".{e}" for e in file_extensions}
            rel_files = [
                f for f in rel_files
                if any(f.endswith(e) for e in ext_set)
            ]

        # Index via engine
        stats = self._engine.index(str(root), force=force)

        duration = time.monotonic() - start

        # Track repo state
        self._indexed_repos[str(root)] = _RepoState(
            root=str(root),
            file_count=len(rel_files),
            method=method,
        )

        result = {
            "files_indexed": stats.files_indexed,
            "files_skipped": stats.files_skipped,
            "total_files": len(rel_files),
            "method": method,
            "duration": duration,
            "repo_path": str(root),
        }

        logger.info(
            "Indexed repo %s: %d files via %s in %.3fs",
            root, stats.files_indexed, method, duration,
        )
        return result

    def search_repo(
        self,
        repo_path: str,
        query: str,
        **kwargs: Any,
    ) -> List[Result]:
        """Search an indexed Git repository.

        If the repository has not been indexed yet, it is indexed first.

        Args:
            repo_path: Path to the repository root.
            query:     Search query string.
            **kwargs:  Forwarded to :meth:`RBSPEngine.resolve`.

        Returns:
            List of :class:`Result` objects ranked by relevance.
        """
        root = str(Path(repo_path).resolve())

        # Auto-index if not already done
        if root not in self._indexed_repos:
            self.index_repo(repo_path)

        return self._engine.resolve(query, scope=root, **kwargs)

    def get_tracked_files(self, repo_path: str) -> List[str]:
        """Return the list of tracked files in the repository.

        Uses ``git ls-files`` with a manual-walk fallback.

        Args:
            repo_path: Path to the repository root.

        Returns:
            List of relative file paths (forward-slash separated).
        """
        root = Path(repo_path).resolve()
        git_files = _git_ls_files(str(root))
        if git_files is not None:
            return git_files
        rules = _load_gitignore_rules(root)
        return _manual_walk(str(root), rules)

    def is_git_repo(self, path: str) -> bool:
        """Check if *path* is inside a Git repository."""
        try:
            proc = subprocess.run(
                ["git", "rev-parse", "--is-inside-work-tree"],
                cwd=path,
                capture_output=True,
                timeout=5,
            )
            return proc.returncode == 0
        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            return False

    def repo_info(self, repo_path: str) -> Dict[str, Any]:
        """Return metadata about a Git repository.

        Returns branch name, commit count, and remote URL if available.
        """
        root = str(Path(repo_path).resolve())
        info: Dict[str, Any] = {"path": root, "is_git": False}

        try:
            # Current branch
            proc = subprocess.run(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                cwd=root, capture_output=True, timeout=5,
            )
            if proc.returncode == 0:
                info["is_git"] = True
                info["branch"] = proc.stdout.decode().strip()

            # Remote URL
            proc = subprocess.run(
                ["git", "config", "--get", "remote.origin.url"],
                cwd=root, capture_output=True, timeout=5,
            )
            if proc.returncode == 0:
                info["remote"] = proc.stdout.decode().strip()

            # Commit count
            proc = subprocess.run(
                ["git", "rev-list", "--count", "HEAD"],
                cwd=root, capture_output=True, timeout=5,
            )
            if proc.returncode == 0:
                info["commits"] = int(proc.stdout.decode().strip())

        except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
            pass

        return info

    @property
    def indexed_repos(self) -> List[str]:
        """Return paths of all currently indexed repositories."""
        return list(self._indexed_repos.keys())


class _RepoState:
    """Internal tracking state for an indexed repository."""

    __slots__ = ("root", "file_count", "method")

    def __init__(self, root: str, file_count: int, method: str) -> None:
        self.root = root
        self.file_count = file_count
        self.method = method
