"""File discovery using git ls-files with fallback to os.walk."""

from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path

from roam.observability import log_swallowed

# 53 extensions the discovery walker skips as non-source: binaries, lockfiles,
# minified bundles, source-maps, archives, media, fonts, compiled artefacts, and
# document formats. Single source of truth — cmd_secrets._BINARY_EXTENSIONS is
# an alias of this set (W39.4 collapse; previously a 48-entry binary-only subset
# kept distinct from this superset, but the 5 extras .lock/.map/.min.js/.min.css/
# .sct turned out to be low-value secrets-scan targets — .lock files are also
# filtered by SKIP_NAMES below, and .map/.min.* leaks surface in unminified
# source). Re-counted via len(); keep this comment in sync if entries change.
SKIP_EXTENSIONS = frozenset(
    {
        ".lock",
        ".min.js",
        ".min.css",
        ".map",
        ".png",
        ".jpg",
        ".jpeg",
        ".gif",
        ".bmp",
        ".ico",
        ".svg",
        ".webp",
        ".woff",
        ".woff2",
        ".ttf",
        ".eot",
        ".otf",
        ".zip",
        ".tar",
        ".gz",
        ".bz2",
        ".xz",
        ".7z",
        ".rar",
        ".exe",
        ".dll",
        ".so",
        ".dylib",
        ".o",
        ".a",
        ".lib",
        ".pyc",
        ".pyo",
        ".class",
        ".jar",
        ".db",
        ".sqlite",
        ".sqlite3",
        ".pdf",
        ".doc",
        ".docx",
        ".xls",
        ".xlsx",
        ".mp3",
        ".mp4",
        ".wav",
        ".avi",
        ".mov",
        ".bin",
        ".dat",
        ".pak",
        ".wasm",
        # Visual FoxPro companion files (read as part of .scx processing)
        ".sct",
    }
)

# Filenames to always skip
SKIP_NAMES = frozenset(
    {
        "package-lock.json",
        "yarn.lock",
        "pnpm-lock.yaml",
        "Cargo.lock",
        "poetry.lock",
        "composer.lock",
        "Gemfile.lock",
        "Pipfile.lock",
    }
)

# Directories to skip during os.walk fallback
SKIP_DIRS = frozenset(
    {
        ".git",
        ".hg",
        ".svn",
        "node_modules",
        "__pycache__",
        ".tox",
        ".mypy_cache",
        ".pytest_cache",
        ".ruff_cache",
        "venv",
        ".venv",
        "env",
        ".env",
        "dist",
        "build",
        ".eggs",
        "*.egg-info",
        ".next",
        ".nuxt",
        ".output",
        "target",
        "bin",
        "obj",
        ".roam",
    }
)

MAX_FILE_SIZE = 1_000_000  # 1MB

# Built-in patterns for common auto-generated files
BUILTIN_GENERATED_PATTERNS = [
    # Protobuf
    "*_pb.js",
    "*_pb.d.ts",
    "*_pb2.py",
    "*_pb2_grpc.py",
    # gRPC
    "*_grpc_pb.js",
    "*_grpc_pb.d.ts",
    # Code generators
    "*.generated.*",
    "*.auto.*",
    "*.g.dart",
]

# Markers in first 3 lines that indicate generated code
_GENERATED_MARKERS = [
    "// Code generated",
    "# Generated by",
]


def _load_roamignore(root: Path) -> list[str]:
    """Load ignore patterns from .roamignore file (gitignore-style globs)."""
    ignore_file = root / ".roamignore"
    if not ignore_file.is_file():
        return []
    patterns = []
    for line in ignore_file.read_text(encoding="utf-8", errors="replace").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        patterns.append(line)
    return patterns


def _load_config_excludes(root: Path) -> list[str]:
    """Load exclude patterns from .roam/config.json 'exclude' key."""
    config_path = root / ".roam" / "config.json"
    if not config_path.is_file():
        return []
    try:
        data = json.loads(config_path.read_text(encoding="utf-8"))
        excludes = data.get("exclude", [])
        if isinstance(excludes, list):
            return [str(p) for p in excludes if isinstance(p, str) and p.strip()]
        return []
    except (json.JSONDecodeError, OSError, TypeError):
        return []


def load_exclude_patterns(project_root: Path) -> list[str]:
    """Load all exclude patterns from built-in + config.json + .roamignore.

    Sources (lowest to highest priority for negation):
    1. Built-in patterns for common generated files (protobuf, gRPC, etc.)
    2. "exclude" array in .roam/config.json (local project config)
    3. .roamignore file in project root (user patterns — highest priority)

    Returns a deduplicated list of glob patterns.  Because the last matching
    pattern wins (gitignore semantics), user .roamignore patterns can negate
    built-in or config patterns with ``!pattern``.
    """
    project_root = Path(project_root).resolve()
    patterns: list[str] = []
    patterns.extend(BUILTIN_GENERATED_PATTERNS)
    patterns.extend(_load_config_excludes(project_root))
    patterns.extend(_load_roamignore(project_root))
    # Deduplicate while preserving order (skip negation patterns in dedup)
    seen: set[str] = set()
    unique: list[str] = []
    for p in patterns:
        if p.startswith("!"):
            # Always keep negation patterns (even if the positive form was seen)
            unique.append(p)
        elif p not in seen:
            seen.add(p)
            unique.append(p)
    return unique


def _matches_exclude(rel_path: str, patterns: list[str]) -> bool:
    """Check if a relative path matches exclude patterns (gitignore semantics).

    Supports ``*`` (single segment), ``**`` (recursive), ``?``, ``[abc]``,
    ``[!abc]``, leading ``/`` (root anchor), trailing ``/`` (directory),
    implicit anchoring for patterns containing ``/``, and ``!pattern``
    negation (last matching pattern wins).
    """
    from roam.index.gitignore import matches_exclude_patterns

    return matches_exclude_patterns(rel_path, patterns)


def _is_generated_content(full_path: Path) -> bool:
    """Check if a file contains generated-code markers in its first 3 lines."""
    try:
        with open(full_path, "rb") as f:
            head = f.read(1024)
        text = head.decode("utf-8", errors="replace")
        lines = text.splitlines()[:3]
        for line in lines:
            for marker in _GENERATED_MARKERS:
                if marker in line:
                    return True
    except OSError as exc:
        # Loud-fallback per CLAUDE.md §"Make fallback chains loud" — an
        # unreadable file silently classifies as "not generated", so a
        # generated file may be indexed as hand-written. Surface the lineage
        # so the misclassification has a discoverable cause.
        log_swallowed(f"index.discovery:is_generated:read:{full_path}", exc)
    return False


def _is_skippable(rel_path: str) -> bool:
    """Check whether a relative path should be skipped."""
    # Skip .roam/ directory (index storage)
    parts = rel_path.replace("\\", "/").split("/")
    if ".roam" in parts:
        return True
    name = os.path.basename(rel_path)
    if name in SKIP_NAMES:
        return True
    _, ext = os.path.splitext(name)
    if ext.lower() in SKIP_EXTENSIONS:
        return True
    return False


def _git_ls_files(root: Path) -> list[str] | None:
    """Try to list files using git ls-files. Returns None if git unavailable.

    Uses ``worktree_git_env`` so parallel agents in sibling worktrees don't
    contend on ``.git/index.lock``.
    """
    from roam.git_utils import worktree_git_env

    try:
        result = subprocess.run(
            ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
            cwd=str(root),
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=30,
            env=worktree_git_env(root),
        )
        if result.returncode != 0:
            return None
        paths = [p.strip() for p in result.stdout.splitlines() if p.strip()]
        return paths
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return None


def _walk_files(root: Path) -> list[str]:
    """Fallback file discovery using os.walk, respecting common ignore dirs."""
    result = []
    for dirpath, dirnames, filenames in os.walk(root):
        # Filter out skippable directories in place
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
        for fname in filenames:
            full = os.path.join(dirpath, fname)
            try:
                rel = os.path.relpath(full, root).replace("\\", "/")
            except (ValueError, OSError):
                # Skip paths that can't be made relative (e.g. Windows device names like NUL)
                continue
            result.append(rel)
    return result


def _filter_files(
    paths: list[str],
    root: Path,
    exclude_patterns: list[str] | None = None,
    include_excluded: bool = False,
) -> list[str]:
    """Filter out binary, oversized, non-code, and excluded files.

    Args:
        paths: List of relative file paths.
        root: Project root directory.
        exclude_patterns: Glob patterns to exclude (from .roamignore, config, built-in).
        include_excluded: If True, skip exclusion filtering (for debugging).
    """
    kept = []
    for rel_path in paths:
        if _is_skippable(rel_path):
            continue
        if not include_excluded and exclude_patterns:
            if _matches_exclude(rel_path, exclude_patterns):
                continue
        full_path = root / rel_path
        try:
            if full_path.stat().st_size > MAX_FILE_SIZE:
                continue
        except OSError:
            continue
        # Content-based generated-file detection (only when not including excluded)
        if not include_excluded and _is_generated_content(full_path):
            continue
        kept.append(rel_path)
    return kept


def discover_files(root: Path, include_excluded: bool = False) -> list[str]:
    """Discover source files in a project directory.

    Uses git ls-files when available, falls back to os.walk.
    Filters out binary, oversized, non-code, and excluded files.
    Returns a sorted list of relative paths using forward slashes.

    Args:
        root: Project root directory.
        include_excluded: If True, skip .roamignore / config / built-in
            exclusion filtering (useful for debugging).
    """
    root = Path(root).resolve()
    raw = _git_ls_files(root)
    if raw is None:
        raw = _walk_files(root)

    # Normalise path separators
    raw = [p.replace("\\", "/") for p in raw]

    exclude_patterns = load_exclude_patterns(root)
    filtered = _filter_files(
        raw,
        root,
        exclude_patterns=exclude_patterns,
        include_excluded=include_excluded,
    )
    filtered.sort()
    return filtered
