"""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).
"""

from __future__ import annotations

import fnmatch
import logging
import os
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__)

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 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,
) -> 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 *show_progress* is True, displays a click progress bar on stderr
    during the hashing phase (requires collecting file paths first).
    """
    root = root.resolve()
    manifest = Manifest(root_dir=str(root))

    walk_kwargs = dict(
        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 show_progress:
        file_list = list(walk_files(root, **walk_kwargs))
        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 walk_files(root, **walk_kwargs):
            _hash_and_add(manifest, root, fpath, existing_manifest)

    return manifest


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))
        entry = ManifestEntry(
            hash_hex=h,
            path=rel,
            size=stat.st_size,
            mtime=stat.st_mtime,
        )
        manifest.add(entry)
        logger.debug("Hashed: %s → %s", rel, h)
    except OSError as exc:
        logger.warning("Cannot read %s: %s", fpath, exc)
