"""Filesystem watcher — continuous monitoring with automatic attestation.

Uses the ``watchdog`` library (optional dependency) to receive filesystem
events and process them in batches: hash changed files, attest new hashes
via the CertiSigma API, and persist results in the SQLite manifest.

Architecture: producer-consumer pattern validated against Wazuh/Tripwire
FIM architectures. The Observer thread collects events into a thread-safe
pending dict; the main thread drains, hashes, attests, and saves.

Install: ``pip install certisigma-census[watch]``
"""

from __future__ import annotations

import enum
import fnmatch
import logging
import signal
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING, Sequence

if TYPE_CHECKING:
    from .manifest import Manifest

from certisigma import hash_file

from .manifest import ManifestEntry
from .retry import retry_api_call

logger = logging.getLogger(__name__)

PENDING_WARN_THRESHOLD = 10_000


class EventType(enum.Enum):
    CREATED = "created"
    MODIFIED = "modified"
    MOVED = "moved"
    DELETED = "deleted"


def _check_watchdog() -> None:
    """Raise a clear error if watchdog is not installed."""
    try:
        import watchdog  # noqa: F401
    except ImportError:
        raise SystemExit(
            "watchdog is required for watch mode.\n"
            "Install it with: pip install certisigma-census[watch]"
        )


class CensusEventHandler:
    """Collect filesystem events into a thread-safe pending dict.

    Filters out hidden files, skip directories, and applies glob/size
    filters consistent with ``scanner.walk_files()``.

    Parameters
    ----------
    root : Path
        Resolved root directory being watched. Events outside this
        directory (symlink escapes) are rejected.
    pending : dict
        Shared ``dict[Path, EventType]`` — written by this handler,
        drained by the main thread.
    lock : threading.Lock
        Protects concurrent access to *pending*.
    """

    def __init__(
        self,
        root: Path,
        pending: dict[Path, EventType],
        lock: threading.Lock,
        *,
        skip_hidden: bool = True,
        include_globs: Sequence[str] = (),
        exclude_globs: Sequence[str] = (),
        min_file_size: int = 0,
        max_file_size: int = 0,
    ):
        from .scanner import DEFAULT_MAX_FILE_SIZE, DEFAULT_SKIP_DIRS

        self.root = root.resolve()
        self.pending = pending
        self.lock = lock
        self.skip_hidden = skip_hidden
        self.skip_dirs = DEFAULT_SKIP_DIRS
        self.include_globs = include_globs
        self.exclude_globs = exclude_globs
        self.min_file_size = min_file_size
        self.max_file_size = max_file_size or DEFAULT_MAX_FILE_SIZE

    def dispatch(self, event) -> None:
        """Called by watchdog Observer for every filesystem event."""
        if event.is_directory:
            return

        src_path = Path(event.src_path)

        if hasattr(event, "event_type"):
            etype = event.event_type
        else:
            etype = "modified"

        if etype == "moved":
            target = Path(event.dest_path) if hasattr(event, "dest_path") else src_path
        elif etype == "deleted":
            target = src_path
        else:
            target = src_path

        if not self._accept(target, is_delete=(etype == "deleted")):
            return

        event_type = {
            "created": EventType.CREATED,
            "modified": EventType.MODIFIED,
            "moved": EventType.MOVED,
            "deleted": EventType.DELETED,
        }.get(etype, EventType.MODIFIED)

        with self.lock:
            self.pending[target] = event_type
            if len(self.pending) > PENDING_WARN_THRESHOLD:
                logger.warning(
                    "Pending queue at %d items — possible event burst or misconfiguration",
                    len(self.pending),
                )

    def _accept(self, path: Path, *, is_delete: bool = False) -> bool:
        """Return True if *path* passes all filters."""
        try:
            resolved = path.resolve()
        except OSError:
            return False

        try:
            rel_parts = resolved.relative_to(self.root).parts
        except ValueError:
            logger.debug("Rejected (outside root): %s", path)
            return False

        name = resolved.name
        if self.skip_hidden and name.startswith("."):
            return False

        for part in rel_parts[:-1]:
            if part in self.skip_dirs or (self.skip_hidden and part.startswith(".")):
                return False

        if self.include_globs and not any(
            fnmatch.fnmatch(name, pat) for pat in self.include_globs
        ):
            return False
        if self.exclude_globs and any(
            fnmatch.fnmatch(name, pat) for pat in self.exclude_globs
        ):
            return False

        if is_delete:
            return True

        try:
            size = path.stat().st_size
        except OSError:
            return True
        if size < self.min_file_size or size > self.max_file_size:
            return False

        return True


def _process_events(
    events: dict[Path, EventType],
    manifest,
    root: Path,
    client,
    source: str | None,
    dry_run: bool,
    on_delete: str,
    batch_size: int = 100,
) -> dict[str, int]:
    """Process accumulated events: hash, attest, update manifest.

    Returns a dict of counts: {created, modified, moved, deleted, attested, errors}.
    """

    counts = {"created": 0, "modified": 0, "moved": 0, "deleted": 0, "attested": 0, "errors": 0}
    new_hashes: list[str] = []

    for path, etype in events.items():
        try:
            rel = str(path.relative_to(root))
        except ValueError:
            logger.debug("Skipping event outside root: %s", path)
            continue

        if etype == EventType.DELETED:
            if on_delete == "remove":
                manifest.remove_by_path(rel)
                counts["deleted"] += 1
            elif on_delete == "mark":
                logger.info("File deleted: %s", rel)
                counts["deleted"] += 1
            continue

        try:
            stat = path.stat()
            h = hash_file(str(path))
        except Exception as exc:
            logger.warning("Cannot process %s: %s", rel, exc)
            counts["errors"] += 1
            continue

        manifest.remove_by_path(rel)
        entry = ManifestEntry(
            hash_hex=h, path=rel, size=stat.st_size, mtime=stat.st_mtime,
        )
        manifest.add(entry)
        new_hashes.append(h)

        if etype == EventType.CREATED:
            counts["created"] += 1
        elif etype == EventType.MOVED:
            counts["moved"] += 1
        else:
            counts["modified"] += 1

    if dry_run or not new_hashes or client is None:
        return counts

    batches = [new_hashes[i:i + batch_size] for i in range(0, len(new_hashes), batch_size)]
    for batch in batches:
        try:
            result = retry_api_call(client.batch_attest, batch, source=source)
            for att in result.attestations:
                att_id = att.get("attestation_id") or att.get("id")
                h = att.get("hash_hex", "")
                ts = att.get("timestamp", att.get("registered_at", ""))
                if att_id and h:
                    manifest.mark_attested(h, str(att_id), ts)
                    counts["attested"] += 1
        except Exception as exc:
            logger.error("Batch attest failed: %s", exc)
            counts["errors"] += len(batch)

    return counts


def _load_or_create_watch_manifest(manifest_path: Path, root: Path) -> Manifest:
    """Load an existing SQLite manifest or create an empty one on disk.

    The in-memory manifest used only for the initial ``save()`` is always
    closed before opening the on-disk database, so the ``:memory:``
    connection is not leaked.

    Raises ``RuntimeError`` on corrupt or unreadable manifest.
    """
    from .manifest import Manifest

    if manifest_path.exists():
        try:
            return Manifest.load(manifest_path)
        except Exception as exc:
            raise RuntimeError(f"Cannot load watch manifest {manifest_path}: {exc}") from exc
    fresh = Manifest(root_dir=str(root))
    try:
        fresh.save(manifest_path)
    finally:
        fresh.close()
    return Manifest.load(manifest_path)


def watch_directory(
    root: Path,
    *,
    manifest_path: Path,
    client=None,
    source: str | None = None,
    debounce_sec: float = 2.0,
    batch_interval: float = 30.0,
    scan_on_start: bool = True,
    on_delete: str = "ignore",
    polling: bool = False,
    poll_interval: float = 5.0,
    dry_run: bool = False,
    include_globs: Sequence[str] = (),
    exclude_globs: Sequence[str] = (),
    min_file_size: int = 0,
    max_file_size: int = 0,
    show_progress: bool = False,
    quiet: bool = False,
    _observer_class=None,
) -> None:
    """Watch a directory for changes, hash new/modified files, and attest.

    This function blocks until SIGINT or SIGTERM is received.

    Parameters
    ----------
    _observer_class
        Override the Observer class (for testing). If None, uses
        ``watchdog.observers.Observer`` or ``PollingObserver``.
    """
    _check_watchdog()
    import click
    from .scanner import scan_directory

    def _info(msg: str) -> None:
        if not quiet:
            click.echo(msg)

    root = root.resolve()

    manifest = _load_or_create_watch_manifest(manifest_path, root)

    if scan_on_start:
        _info(f"Baseline scan of {root} ...")
        baseline = scan_directory(
            root,
            show_progress=show_progress,
            include_globs=include_globs,
            exclude_globs=exclude_globs,
            min_file_size=min_file_size,
            max_file_size=max_file_size,
            existing_manifest=manifest,
        )
        try:
            manifest.merge(baseline)
        finally:
            baseline.close()

        if not dry_run and client is not None:
            hashes_to_attest = manifest.unattested_hashes()
            batch_size = 100
            batches = [hashes_to_attest[i:i + batch_size] for i in range(0, len(hashes_to_attest), batch_size)]
            for batch in batches:
                try:
                    result = retry_api_call(client.batch_attest, batch, source=source)
                    for att in result.attestations:
                        att_id = att.get("attestation_id") or att.get("id")
                        h = att.get("hash_hex", "")
                        ts = att.get("timestamp", att.get("registered_at", ""))
                        if att_id and h:
                            manifest.mark_attested(h, str(att_id), ts)
                except Exception as exc:
                    logger.error("Baseline attest failed: %s", exc)

        _info(f"Baseline: {manifest.total} files indexed.")

    pending: dict[Path, EventType] = {}
    lock = threading.Lock()

    handler = CensusEventHandler(
        root, pending, lock,
        include_globs=include_globs,
        exclude_globs=exclude_globs,
        min_file_size=min_file_size,
        max_file_size=max_file_size,
    )

    if _observer_class is not None:
        ObserverCls = _observer_class
    elif polling:
        from watchdog.observers.polling import PollingObserver
        ObserverCls = PollingObserver
    else:
        from watchdog.observers import Observer
        ObserverCls = Observer

    shutdown = threading.Event()
    prev_sigint = signal.getsignal(signal.SIGINT)
    prev_sigterm = signal.getsignal(signal.SIGTERM)

    def _shutdown_handler(signum, frame):
        shutdown.set()

    signal.signal(signal.SIGINT, _shutdown_handler)
    signal.signal(signal.SIGTERM, _shutdown_handler)

    try:
        observer = ObserverCls(timeout=poll_interval) if polling else ObserverCls()
        observer.schedule(handler, str(root), recursive=True)

        try:
            observer.start()
        except OSError as exc:
            if "inotify" in str(exc).lower():
                logger.error(
                    "inotify watch limit reached. Increase it with:\n"
                    "  sudo sysctl -w fs.inotify.max_user_watches=524288\n"
                    "  echo 'fs.inotify.max_user_watches=524288' | sudo tee -a /etc/sysctl.conf"
                )
            raise

        import click
        mode = "polling" if polling else "native"
        click.echo(
            f"Watching {root} ({mode}, debounce: {debounce_sec}s) — Ctrl+C to stop"
        )

        last_activity = time.monotonic()
        last_batch = time.monotonic()

        while not shutdown.is_set():
            shutdown.wait(0.5)

            with lock:
                has_pending = len(pending) > 0

            if has_pending:
                last_activity = time.monotonic()

            elapsed_since_activity = time.monotonic() - last_activity
            elapsed_since_batch = time.monotonic() - last_batch
            should_process = (
                has_pending
                and (elapsed_since_activity >= debounce_sec or elapsed_since_batch >= batch_interval)
            )

            if should_process or (shutdown.is_set() and has_pending):
                with lock:
                    batch = dict(pending)
                    pending.clear()

                if batch:
                    counts = _process_events(
                        batch, manifest, root, client, source, dry_run, on_delete,
                    )
                    try:
                        manifest.save(manifest_path)
                    except Exception as exc:
                        logger.error("Cannot save manifest: %s", exc)
                    last_batch = time.monotonic()

                    parts = []
                    for k in ("created", "modified", "moved", "deleted"):
                        if counts[k]:
                            parts.append(f"{counts[k]} {k}")
                    summary = ", ".join(parts) if parts else "0 changes"
                    att_str = f", attested {counts['attested']}" if counts["attested"] else ""
                    ts = time.strftime("%H:%M:%S")
                    _info(f"[{ts}] Processed {len(batch)} files ({summary}{att_str})")

        observer.stop()
        observer.join(timeout=5)

    finally:
        signal.signal(signal.SIGINT, prev_sigint)
        signal.signal(signal.SIGTERM, prev_sigterm)

        try:
            manifest.save(manifest_path)
            total = manifest.total
        finally:
            manifest.close()
        _info(f"Manifest ({total} entries) saved to {manifest_path}")
