from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Iterable

import pathspec

from ..schema import FileRecord, Language
from ..utils import is_probably_binary, is_textual_extension


DEFAULT_FORCE_IGNORE_DIRS = {
    ".git",
    "node_modules",
    "venv",
    ".venv",
    "__pycache__",
    ".mypy_cache",
    ".pytest_cache",
    ".ruff_cache",
    ".tox",
    "dist",
    "build",
    ".next",
    ".nuxt",
    ".idea",
    ".vscode",
}


def _detect_language(path: str) -> Language:
    name = os.path.basename(path).lower()
    if name == "dockerfile":
        return "text"
    _, ext = os.path.splitext(name)
    if ext == ".py":
        return "python"
    if ext in {".js", ".jsx"}:
        return "javascript"
    if ext in {".ts", ".tsx"}:
        return "typescript"
    if ext in {".json"}:
        return "json"
    if ext in {".yml", ".yaml"}:
        return "yaml"
    if ext in {".md"}:
        return "markdown"
    if ext in {".txt", ".ini", ".cfg", ".toml"}:
        return "text"
    return "unknown"


def _rewrite_gitignore_pattern(line: str, base_rel_posix: str) -> str | None:
    """
    Rewrite a gitignore pattern from a directory-local `.gitignore` into
    a repo-root-relative gitwildmatch pattern.

    This is not a perfect reimplementation of gitignore, but it handles:
    - nested .gitignore precedence (by ordering of appended patterns)
    - leading '/' anchored to the `.gitignore` location
    - basename-only patterns matching anywhere under the `.gitignore` directory
    - negation patterns starting with '!'
    - escaping leading '\#' and '\!'
    """
    raw = line.rstrip("\n")
    if not raw or raw.lstrip().startswith("#"):
        return None

    # Handle escaped leading markers
    if raw.startswith(r"\#"):
        raw = "#" + raw[2:]
    if raw.startswith(r"\!"):
        raw = "!" + raw[2:]

    neg = False
    if raw.startswith("!"):
        neg = True
        raw = raw[1:]
        if not raw:
            return None

    pat = raw.strip()
    if not pat:
        return None

    base = base_rel_posix.strip("/")
    base_prefix = f"{base}/" if base else ""

    if pat.startswith("/"):
        rewritten = base_prefix + pat.lstrip("/")
    else:
        if "/" in pat:
            rewritten = base_prefix + pat
        else:
            rewritten = base_prefix + "**/" + pat

    return ("!" + rewritten) if neg else rewritten


def _load_gitignore_spec(root: str) -> pathspec.GitIgnoreSpec:
    """
    Load a combined PathSpec from all `.gitignore` files under root,
    approximating git's behavior for common cases.

    Notes:
    - True gitignore precedence is per-directory. This merged spec is a pragmatic
      approximation that works well for most repos.
    """
    patterns: list[str] = []
    for dirpath, dirnames, filenames in os.walk(root, topdown=True):
        # avoid deep walking into force-ignored dirs while collecting patterns
        dirnames[:] = [d for d in dirnames if d not in DEFAULT_FORCE_IGNORE_DIRS]
        if ".gitignore" not in filenames:
            continue
        p = os.path.join(dirpath, ".gitignore")
        try:
            rel = os.path.relpath(dirpath, root).replace(os.sep, "/")
            base_rel = "" if rel in {".", ""} else rel
            with open(p, "r", encoding="utf-8", errors="replace") as f:
                for line in f:
                    rewritten = _rewrite_gitignore_pattern(line, base_rel)
                    if rewritten:
                        patterns.append(rewritten)
        except OSError:
            continue
    return pathspec.GitIgnoreSpec.from_lines(patterns)


@dataclass
class FileWalker:
    root: str
    use_gitignore: bool = True
    force_ignore_dirs: set[str] | None = None
    max_file_size_bytes: int = 20 * 1024 * 1024  # skip pathological huge files

    def walk(self) -> list[FileRecord]:
        root = os.path.abspath(self.root)
        force_ignores = self.force_ignore_dirs or set(DEFAULT_FORCE_IGNORE_DIRS)
        spec = _load_gitignore_spec(root) if self.use_gitignore else None

        out: list[FileRecord] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # prune dirs
            kept: list[str] = []
            for d in dirnames:
                if d in force_ignores:
                    continue
                if spec is not None:
                    absd = os.path.join(dirpath, d)
                    rel = os.path.relpath(absd, root).replace(os.sep, "/").rstrip("/") + "/"
                    if spec.match_file(rel):
                        continue
                kept.append(d)
            dirnames[:] = kept

            for fn in filenames:
                abspath = os.path.join(dirpath, fn)
                relpath = os.path.relpath(abspath, root)
                relpath_posix = relpath.replace(os.sep, "/")

                if spec is not None and spec.match_file(relpath_posix):
                    continue

                # Filter likely non-text quickly by extension, then by binary sniff.
                if not is_textual_extension(relpath_posix):
                    continue
                try:
                    st = os.stat(abspath)
                except OSError:
                    continue
                if st.st_size > self.max_file_size_bytes:
                    # Keep record but don't hash huge binaries; still allow parsing by chunk later if needed.
                    # For now, drop very large files to avoid pathological runs.
                    continue
                if is_probably_binary(abspath):
                    continue

                lang = _detect_language(relpath_posix)
                out.append(
                    FileRecord(
                        path=relpath_posix,
                        language=lang,
                        size_bytes=st.st_size,
                    )
                )

        out.sort(key=lambda r: r.path)
        return out

    def iter_paths(self) -> Iterable[str]:
        for rec in self.walk():
            yield rec.path

