"""Filesystem scanner — walks directories and computes SHA-256 hashes.

Uses the CertiSigma SDK's hash_file() which streams files in 8 KB
chunks (constant memory regardless of file size).

When ``--workers N`` is set (N > 1), hashing is parallelized across
multiple processes via ``concurrent.futures.ProcessPoolExecutor``.
Each worker calls ``hash_file()`` independently — no shared mutable
state, no GIL contention (SHA-256 is CPU-bound).
"""

from __future__ import annotations

import fnmatch
import logging
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Iterator, Sequence

import click
from certisigma import hash_file

from .manifest import Manifest, ManifestEntry

logger = logging.getLogger(__name__)

MAX_WORKERS = 8

try:
    import grp as _grp
    import pwd as _pwd
except ImportError:
    _pwd = None  # type: ignore[assignment]
    _grp = None  # type: ignore[assignment]


def _file_meta(stat_result: os.stat_result) -> dict[str, str | int]:
    """Extract owner, group, and mode from a stat result.

    Falls back to numeric UID/GID on platforms without pwd/grp (Windows).
    """
    uid = stat_result.st_uid
    gid = stat_result.st_gid
    mode = stat_result.st_mode

    owner: str
    group: str
    if _pwd is not None:
        try:
            owner = _pwd.getpwuid(uid).pw_name
        except KeyError:
            owner = str(uid)
    else:
        owner = str(uid)

    if _grp is not None:
        try:
            group = _grp.getgrgid(gid).gr_name
        except KeyError:
            group = str(gid)
    else:
        group = str(gid)

    return {"owner": owner, "file_group": group, "file_mode": mode}

DEFAULT_SKIP_DIRS = frozenset({
    ".git", ".svn", ".hg",
    "__pycache__", "node_modules",
    ".venv", "venv", ".env",
    ".DS_Store", "Thumbs.db",
})

DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024  # 5 GB


def walk_files(
    root: Path,
    *,
    skip_dirs: frozenset[str] = DEFAULT_SKIP_DIRS,
    skip_hidden: bool = True,
    max_file_size: int = DEFAULT_MAX_FILE_SIZE,
    min_file_size: int = 0,
    include_globs: Sequence[str] = (),
    exclude_globs: Sequence[str] = (),
) -> Iterator[Path]:
    """Yield all regular files under *root*, respecting skip/filter rules.

    Parameters
    ----------
    include_globs:
        If non-empty, a file must match **at least one** pattern to be yielded.
        Patterns are matched against the filename only (e.g. ``*.pdf``).
    exclude_globs:
        A file matching **any** pattern is skipped.  Evaluated after include.
    min_file_size:
        Files strictly smaller than this (bytes) are skipped.
    """
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [
            d for d in dirnames
            if d not in skip_dirs and not (skip_hidden and d.startswith("."))
        ]
        for fname in filenames:
            if skip_hidden and fname.startswith("."):
                continue

            if include_globs and not any(
                fnmatch.fnmatch(fname, pat) for pat in include_globs
            ):
                continue

            if exclude_globs and any(
                fnmatch.fnmatch(fname, pat) for pat in exclude_globs
            ):
                continue

            fpath = Path(dirpath) / fname
            if not fpath.is_file():
                continue
            try:
                size = fpath.stat().st_size
            except OSError:
                logger.warning("Cannot stat: %s", fpath)
                continue

            if size > max_file_size:
                logger.warning("Skipping (too large): %s", fpath)
                continue
            if size < min_file_size:
                logger.debug("Skipping (too small): %s", fpath)
                continue

            yield fpath


def _default_workers() -> int:
    """Sensible default: min(cpu_count, MAX_WORKERS), at least 1."""
    try:
        return min(os.cpu_count() or 1, MAX_WORKERS)
    except (TypeError, NotImplementedError):
        return 1


def scan_directory(
    root: Path,
    *,
    skip_dirs: frozenset[str] = DEFAULT_SKIP_DIRS,
    skip_hidden: bool = True,
    max_file_size: int = DEFAULT_MAX_FILE_SIZE,
    min_file_size: int = 0,
    include_globs: Sequence[str] = (),
    exclude_globs: Sequence[str] = (),
    show_progress: bool = False,
    existing_manifest: Manifest | None = None,
    workers: int = 1,
) -> Manifest:
    """Scan a directory, compute hashes, return a Manifest.

    When *existing_manifest* is provided (resume mode), files already
    present in the manifest with unchanged size and mtime are skipped.

    When *workers* > 1, hashing is parallelized across multiple
    processes (CPU-bound SHA-256 benefits from multiprocessing).
    """
    workers = max(1, min(workers, MAX_WORKERS))
    root = root.resolve()
    manifest = Manifest(root_dir=str(root))

    file_list = list(walk_files(
        root,
        skip_dirs=skip_dirs,
        skip_hidden=skip_hidden,
        max_file_size=max_file_size,
        min_file_size=min_file_size,
        include_globs=include_globs,
        exclude_globs=exclude_globs,
    ))

    if workers > 1 and len(file_list) > 1:
        _scan_parallel(manifest, root, file_list, existing_manifest,
                       show_progress=show_progress, workers=workers)
    elif show_progress and file_list:
        with click.progressbar(file_list, label="Hashing", show_pos=True) as bar:
            for fpath in bar:
                _hash_and_add(manifest, root, fpath, existing_manifest)
    else:
        for fpath in file_list:
            _hash_and_add(manifest, root, fpath, existing_manifest)

    return manifest


def _scan_parallel(
    manifest: Manifest,
    root: Path,
    file_list: list[Path],
    existing: Manifest | None,
    *,
    show_progress: bool,
    workers: int,
) -> None:
    """Hash files in parallel using ProcessPoolExecutor."""
    to_hash: list[tuple[Path, str, int, float, dict]] = []
    for fpath in file_list:
        try:
            stat = fpath.stat()
            rel = str(fpath.relative_to(root))
            if existing is not None:
                cached = existing.has_path(rel)
                if cached and cached.size == stat.st_size and cached.mtime == stat.st_mtime:
                    manifest.add(cached)
                    continue
            meta = _file_meta(stat)
            to_hash.append((fpath, rel, stat.st_size, stat.st_mtime, meta))
        except (OSError, ValueError) as exc:
            logger.warning("Cannot stat %s: %s", fpath, exc)

    if not to_hash:
        return

    bar_ctx = (
        click.progressbar(length=len(to_hash), label="Hashing", show_pos=True)
        if show_progress
        else None
    )

    with ProcessPoolExecutor(max_workers=workers) as pool:
        futures = {
            pool.submit(hash_file, str(fpath)): (fpath, rel, size, mtime, meta)
            for fpath, rel, size, mtime, meta in to_hash
        }
        try:
            if bar_ctx:
                bar_ctx = bar_ctx.__enter__()
            for future in as_completed(futures):
                fpath, rel, size, mtime, meta = futures[future]
                try:
                    h = future.result()
                    entry = ManifestEntry(
                        hash_hex=h, path=rel,
                        size=size, mtime=mtime,
                        owner=str(meta["owner"]),
                        file_group=str(meta["file_group"]),
                        file_mode=int(meta["file_mode"]),
                    )
                    manifest.add(entry)
                except OSError as exc:
                    logger.warning("Cannot read %s: %s", fpath, exc)
                except Exception as exc:
                    logger.error("Failed to hash %s: %s", fpath, exc)
                if bar_ctx:
                    bar_ctx.update(1)
        finally:
            if bar_ctx:
                bar_ctx.__exit__(None, None, None)


def _hash_and_add(
    manifest: Manifest,
    root: Path,
    fpath: Path,
    existing: Manifest | None = None,
) -> None:
    """Hash a single file and add it to the manifest.

    In resume mode (*existing* is not None), reuse the cached entry if the
    file's size and mtime have not changed.
    """
    try:
        stat = fpath.stat()
        rel = str(fpath.relative_to(root))

        if existing is not None:
            cached = existing.has_path(rel)
            if cached and cached.size == stat.st_size and cached.mtime == stat.st_mtime:
                manifest.add(cached)
                logger.debug("Cached (unchanged): %s", rel)
                return

        h = hash_file(str(fpath))
        meta = _file_meta(stat)
        entry = ManifestEntry(
            hash_hex=h,
            path=rel,
            size=stat.st_size,
            mtime=stat.st_mtime,
            owner=str(meta["owner"]),
            file_group=str(meta["file_group"]),
            file_mode=int(meta["file_mode"]),
        )
        manifest.add(entry)
        logger.debug("Hashed: %s → %s", rel, h)
    except Exception as exc:
        logger.warning("Cannot hash %s: %s", fpath, exc)
