"""File scanner — discovers and hashes Python files for indexing.

Uses pathspec for .gitignore-compatible pattern matching and SHA-256 for
content hashing. Binary files are detected and skipped automatically.
"""

import hashlib
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

try:
    import pathspec
    HAS_PATHSPEC = True
except ImportError:
    pathspec = None
    HAS_PATHSPEC = False

from .config import ALWAYS_EXCLUDE, load_gitignore_patterns, load_ignore_patterns


def _is_within(child: Path, parent: Path) -> bool:
    """Check if child path is within parent. Requires Python 3.9+."""
    return child.is_relative_to(parent)


def _sha256_file(path: Path) -> str:
    """Compute SHA-256 hex digest of a file."""
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def _is_binary(path: Path, sample_size: int = 8192) -> bool:
    """Heuristic: file is binary if it contains null bytes in the first sample."""
    try:
        with open(path, "rb") as f:
            chunk = f.read(sample_size)
        return b"\x00" in chunk
    except OSError:
        return True


def _build_spec(project_root: Path) -> Optional["pathspec.PathSpec"]:
    """Build a pathspec matcher from .gitignore + .rubberduck/ignore + defaults."""
    if not HAS_PATHSPEC:
        return None

    patterns = []
    # Default excludes
    for excl in ALWAYS_EXCLUDE:
        patterns.append(excl)
    # .gitignore
    patterns.extend(load_gitignore_patterns(project_root))
    # .rubberduck/ignore
    patterns.extend(load_ignore_patterns(project_root))

    return pathspec.PathSpec.from_lines("gitwildmatch", patterns)


def _matches_include(rel_path: str, include_patterns: list) -> bool:
    """Check if a relative path matches any include pattern.

    Handles '**/' prefix by also checking the basename-only pattern.
    For example, '**/*.py' matches both 'src/app.py' and 'app.py'.
    """
    import fnmatch
    for pattern in include_patterns:
        if fnmatch.fnmatch(rel_path, pattern):
            return True
        # '**/' means any directory depth including root
        if pattern.startswith("**/"):
            suffix = pattern[3:]  # e.g., '*.py' from '**/*.py'
            if fnmatch.fnmatch(rel_path, suffix):
                return True
    return False


def _should_skip_simple(rel_path: str) -> bool:
    """Fallback filter when pathspec is not installed."""
    parts = rel_path.split("/")
    for part in parts:
        if part in ALWAYS_EXCLUDE or part.startswith("."):
            return True
        if part.endswith(".pyc") or part.endswith(".egg-info"):
            return True
    return False


def scan_project(
    project_root: Path,
    include_patterns: Optional[List[str]] = None,
    max_file_size: int = 5_000_000,
) -> Dict[str, str]:
    """Scan a project directory and return {relative_path: sha256_hash}.

    Args:
        project_root: Root directory to scan.
        include_patterns: Glob patterns to include (default: ["**/*.py"]).
        max_file_size: Skip files larger than this (bytes).

    Returns:
        Dict mapping relative file paths to their SHA-256 hex digests.
    """
    if include_patterns is None:
        include_patterns = ["**/*.py"]

    spec = _build_spec(project_root)
    result: Dict[str, str] = {}
    resolved_root = project_root.resolve()

    seen: set = set()
    for pattern in include_patterns:
        for file_path in project_root.glob(pattern):
            if not file_path.is_file():
                continue

            # Symlink protection: ensure resolved path stays within project root
            try:
                resolved = file_path.resolve()
                if not _is_within(resolved, resolved_root):
                    continue
            except OSError:
                continue

            try:
                rel = str(file_path.relative_to(project_root))
            except ValueError:
                continue

            # Normalize path separators
            rel = rel.replace("\\", "/")

            # Skip duplicates from overlapping patterns
            if rel in seen:
                continue
            seen.add(rel)

            # Skip ignored files
            if spec is not None:
                if spec.match_file(rel):
                    continue
            else:
                if _should_skip_simple(rel):
                    continue

            # Skip hidden files
            if any(p.startswith(".") for p in rel.split("/")):
                continue

            # Skip large files
            try:
                size = file_path.stat().st_size
                if size > max_file_size:
                    logger.info(
                        "Skipping oversized file: %s (%d bytes > %d limit)",
                        rel, size, max_file_size,
                    )
                    continue
                if size == 0:
                    continue
            except OSError:
                continue

            # Skip binary files
            if _is_binary(file_path):
                continue

            # Hash the file
            try:
                file_hash = _sha256_file(file_path)
                result[rel] = file_hash
            except OSError:
                continue

    return result


def read_files(project_root: Path, paths: List[str]) -> Dict[str, bytes]:
    """Read file contents for upload. Returns {relative_path: bytes}.

    Validates that each resolved path stays within the project root
    to prevent path traversal attacks (e.g., '../../.ssh/id_rsa').
    """
    result: Dict[str, bytes] = {}
    resolved_root = project_root.resolve()
    for rel_path in paths:
        file_path = project_root / rel_path
        # Path traversal protection: verify resolved path stays within root
        try:
            resolved = file_path.resolve()
            if not _is_within(resolved, resolved_root):
                continue
        except OSError:
            continue
        try:
            result[rel_path] = file_path.read_bytes()
        except OSError:
            continue
    return result


