"""Pure utility functions for repository indexing.

Extracted from :mod:`~cleveragents.application.services.repo_indexing_service`
to keep the service module under the 500-line limit (CONTRIBUTING.md line 396).

All functions in this module are stateless and do not depend on SQLAlchemy,
the DI container, or any other infrastructure.  They are safe to call from
tests and benchmarks without constructing a full service instance.
"""

from __future__ import annotations

import fnmatch
import hashlib
import logging
import os
import stat as stat_mod
import time
from collections import Counter
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath

from cleveragents.domain.models.core.repo_index import FileRecord

logger = logging.getLogger(__name__)

__all__ = [
    "detect_language",
    "detect_primary_language",
    "estimate_tokens",
    "matches_policy",
    "walk_and_index",
]

# ---------------------------------------------------------------------------
# Language detection (extension-based)
# ---------------------------------------------------------------------------

# Spec reference: context.uko.default-analyzers (line 28694)
# ["python", "typescript", "rust", "java", "markdown", "json-schema"]
_EXTENSION_LANGUAGE_MAP: dict[str, str] = {
    ".py": "python",
    ".pyi": "python",
    ".pyx": "python",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".js": "javascript",
    ".jsx": "javascript",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".rs": "rust",
    ".java": "java",
    ".kt": "kotlin",
    ".kts": "kotlin",
    ".go": "go",
    ".c": "c",
    ".h": "c",
    ".cpp": "cpp",
    ".cc": "cpp",
    ".cxx": "cpp",
    ".hpp": "cpp",
    ".cs": "csharp",
    ".rb": "ruby",
    ".php": "php",
    ".swift": "swift",
    ".scala": "scala",
    ".r": "r",
    ".md": "markdown",
    ".mdx": "markdown",
    ".rst": "restructuredtext",
    ".json": "json",
    ".yaml": "yaml",
    ".yml": "yaml",
    ".toml": "toml",
    ".xml": "xml",
    ".html": "html",
    ".htm": "html",
    ".css": "css",
    ".scss": "css",
    ".sql": "sql",
    ".sh": "shell",
    ".bash": "shell",
    ".zsh": "shell",
    ".ps1": "powershell",
    ".dockerfile": "dockerfile",
    ".tf": "terraform",
    ".lua": "lua",
    ".zig": "zig",
    ".nim": "nim",
    ".ex": "elixir",
    ".exs": "elixir",
    ".erl": "erlang",
    ".hs": "haskell",
    ".ml": "ocaml",
    ".mli": "ocaml",
    ".clj": "clojure",
    ".dart": "dart",
    ".v": "v",  # V language; also Verilog — ambiguous by design
    ".jl": "julia",
}

# Token estimation: ~4 characters per token (conservative average)
_CHARS_PER_TOKEN = 4


def detect_language(path: str) -> str:
    """Detect programming language from file extension.

    Args:
        path: File path (only the extension is used).

    Returns:
        Lowercase language name, or ``"unknown"`` if unrecognised.
    """
    ext = Path(path).suffix.lower()
    # Special case: Dockerfile without extension
    basename = Path(path).name.lower()
    if basename == "dockerfile" or basename.startswith("dockerfile."):
        return "dockerfile"
    if basename in ("makefile", "gnumakefile"):
        return "makefile"
    return _EXTENSION_LANGUAGE_MAP.get(ext, "unknown")


def estimate_tokens(content_bytes: int) -> int:
    """Estimate token count from byte size.

    Uses a conservative 4 characters per token ratio.

    Args:
        content_bytes: File size in bytes.

    Returns:
        Estimated token count (non-negative).
    """
    return max(0, content_bytes // _CHARS_PER_TOKEN)


# ---------------------------------------------------------------------------
# File hashing
# ---------------------------------------------------------------------------


def read_and_hash(path: Path) -> tuple[str, int]:
    """Read a file, return ``(sha256_hex, byte_length)``.

    Reads the file exactly once so that hash and size are always
    consistent (no TOCTOU window).
    """
    h = hashlib.sha256()
    length = 0
    with open(path, "rb") as f:
        while True:
            chunk = f.read(65536)
            if not chunk:
                break
            h.update(chunk)
            length += len(chunk)
    return h.hexdigest(), length


# ---------------------------------------------------------------------------
# Glob matching
# ---------------------------------------------------------------------------


def _glob_match(rel_path: str, pattern: str) -> bool:
    """Match *rel_path* against a glob *pattern*.

    Uses :meth:`pathlib.PurePosixPath.match` which supports ``**``
    for recursive directory matching (N5 fix).  Falls back to
    :func:`fnmatch.fnmatch` for simple patterns without ``/`` so that
    ``*.py`` still matches ``foo.py`` without requiring ``**/*.py``.
    """
    if "**" in pattern or "/" in pattern:
        return PurePosixPath(rel_path).match(pattern)
    return fnmatch.fnmatch(rel_path, pattern)


def matches_policy(
    rel_path: str,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
) -> bool:
    """Check if a file path matches the include/exclude policy.

    - If *include_globs* is non-empty, path must match at least one.
    - If *exclude_globs* is non-empty, path must not match any.
    - Exclusions take precedence over inclusions.
    """
    # Exclude takes precedence
    for pattern in exclude_globs:
        if _glob_match(rel_path, pattern):
            return False

    # If no include globs, everything is included
    if not include_globs:
        return True

    # Must match at least one include glob
    return any(_glob_match(rel_path, pat) for pat in include_globs)


# ---------------------------------------------------------------------------
# Primary language detection
# ---------------------------------------------------------------------------


def detect_primary_language(records: list[FileRecord]) -> str:
    """Determine the primary language from file records.

    Languages are weighted by token count so that a few large source
    files outweigh many small config files (G6 fix).  Returns
    ``"unknown"`` if all files are unrecognised or have zero tokens.
    """
    lang_tokens: Counter[str] = Counter()
    for fr in records:
        if fr.language != "unknown":
            lang_tokens[fr.language] += fr.token_count

    if not lang_tokens:
        return "unknown"

    return lang_tokens.most_common(1)[0][0]


# ---------------------------------------------------------------------------
# Filesystem walk
# ---------------------------------------------------------------------------


def walk_and_index(
    root: Path,
    *,
    include_globs: tuple[str, ...],
    exclude_globs: tuple[str, ...],
    max_file_size: int | None,
    max_total_size: int | None,
    max_file_count: int | None = None,
    timeout_seconds: float | None = None,
) -> list[FileRecord]:
    """Walk a directory tree and build file records.

    Applies include/exclude glob filtering and size limits.

    Files are collected and sorted by relative path before applying the
    ``max_total_size`` and ``max_file_count`` cutoffs, ensuring
    deterministic results regardless of filesystem walk order.

    ``max_file_count`` is an internal parameter used only by benchmarks
    and tests — not exposed in the public ``RepoIndexingService`` API.

    If ``timeout_seconds`` is provided, a ``TimeoutError`` is raised when
    the walk+index operation exceeds the configured bound.

    The ``max_total_size`` cutoff uses Phase-1 ``stat`` sizes for the
    budget check; actual byte count (from ``read_and_hash``) may differ
    slightly if files are modified between stat and read.
    """
    # Canonicalize root so that relative_to() always works and
    # "../" traversal cannot escape the intended scope (G5 fix).
    root = root.resolve()

    started_at = time.monotonic()

    def _check_timeout() -> None:
        if timeout_seconds is None:
            return
        elapsed = time.monotonic() - started_at
        if elapsed > timeout_seconds:
            raise TimeoutError(
                "Repository indexing timed out after "
                f"{elapsed:.2f}s (limit: {timeout_seconds:.2f}s)"
            )

    # Phase 1: Collect all candidate files (path, stat) sorted by rel_path
    candidates: list[tuple[str, Path, os.stat_result]] = []

    for dirpath, dirnames, filenames in os.walk(root):
        _check_timeout()
        # Skip hidden directories (startswith(".") covers .git, .venv,
        # .tox, .mypy_cache, etc.) and common non-source directories.
        dirnames[:] = sorted(
            d
            for d in dirnames
            if not d.startswith(".")
            and d not in {"__pycache__", "node_modules", "venv"}
        )

        for filename in sorted(filenames):
            _check_timeout()
            if filename.startswith("."):
                continue

            full_path = Path(dirpath) / filename

            # Skip symlinks to avoid reading content outside the
            # repository root.
            if full_path.is_symlink():
                continue

            try:
                rel_path = str(full_path.relative_to(root))
            except ValueError:
                continue

            # Skip paths that exceed FileRecord.path max_length (1024)
            # to avoid a Pydantic ValidationError that would crash the
            # entire index.
            if len(rel_path) > 1024:
                logger.debug(
                    "Skipping file with path > 1024 chars",
                    extra={"path": rel_path[:200]},
                )
                continue

            # Apply include/exclude globs
            if not matches_policy(rel_path, include_globs, exclude_globs):
                logger.debug("Excluded by glob policy", extra={"path": rel_path})
                continue

            # Size check
            try:
                stat = full_path.stat()
            except OSError:
                continue

            # Only index regular files — skip symlinks, directories,
            # FIFOs, sockets, and other special entries (S3 fix).
            if not stat_mod.S_ISREG(stat.st_mode):
                continue

            size_bytes = stat.st_size

            if max_file_size is not None and size_bytes > max_file_size:
                logger.debug(
                    "Skipping file exceeding max_file_size",
                    extra={
                        "path": rel_path,
                        "size": size_bytes,
                        "limit": max_file_size,
                    },
                )
                continue

            candidates.append((rel_path, full_path, stat))

    # Sort by relative path for deterministic max_total_size truncation
    candidates.sort(key=lambda c: c[0])

    # Phase 2: Build records, applying max_total_size cutoff
    records: list[FileRecord] = []
    total_bytes = 0

    for rel_path, full_path, stat in candidates:
        _check_timeout()
        if max_file_count is not None and len(records) >= max_file_count:
            logger.debug(
                "Reached max_file_count limit, stopping",
                extra={"file_count": len(records), "limit": max_file_count},
            )
            break

        size_bytes = stat.st_size

        if max_total_size is not None and total_bytes + size_bytes > max_total_size:
            logger.debug(
                "Reached max_total_size limit, stopping",
                extra={"total_bytes": total_bytes, "limit": max_total_size},
            )
            break

        # Re-check symlink before reading to narrow TOCTOU window.
        if full_path.is_symlink():
            continue
        # Single read: hash content and measure size atomically so
        # hash and size_bytes are always consistent (N2/N3 fix).
        # Use Phase-1 stat mtime to avoid a second stat() call.
        try:
            content_hash, actual_size = read_and_hash(full_path)
            mtime = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
        except OSError:
            continue

        size_bytes = actual_size
        total_bytes += size_bytes

        lang = detect_language(rel_path)
        tokens = estimate_tokens(size_bytes)

        records.append(
            FileRecord(
                path=rel_path,
                content_hash=content_hash,
                token_count=tokens,
                language=lang,
                size_bytes=size_bytes,
                last_modified=mtime,
            )
        )

    return records
