"""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 json
import logging
import os
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Callable, Sequence

from certisigma import hash_file

from .manifest import Manifest, ManifestEntry
from .retry import retry_api_call

logger = logging.getLogger(__name__)

PENDING_WARN_THRESHOLD = 10_000
MAX_PENDING = 100_000
HEARTBEAT_INTERVAL = 300.0


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


try:
    import fcntl
    _HAS_FCNTL = True
except ImportError:
    _HAS_FCNTL = False


class _PidFile:
    """Exclusive PID file — prevents duplicate watchers on the same directory.

    Uses ``fcntl.flock`` (Unix-only). On platforms without ``fcntl`` the
    lock is a no-op and a warning is logged.
    """

    def __init__(self, path: Path) -> None:
        self.path = path
        self._fd: int | None = None

    def acquire(self) -> None:
        if not _HAS_FCNTL:
            logger.warning("fcntl not available on this platform — PID file locking disabled")
            return
        try:
            self._fd = os.open(str(self.path), os.O_WRONLY | os.O_CREAT, 0o600)
        except OSError as exc:
            raise RuntimeError(f"Cannot acquire PID file {self.path}: {exc}") from exc
        try:
            fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            os.close(self._fd)
            self._fd = None
            raise RuntimeError(
                f"Another census watch process is already monitoring this directory "
                f"(PID file: {self.path})"
            )
        except OSError as exc:
            os.close(self._fd)
            self._fd = None
            raise RuntimeError(f"Cannot acquire PID file {self.path}: {exc}") from exc
        os.ftruncate(self._fd, 0)
        os.write(self._fd, f"{os.getpid()}\n".encode())

    def release(self) -> None:
        if self._fd is not None:
            try:
                if _HAS_FCNTL:
                    fcntl.flock(self._fd, fcntl.LOCK_UN)
                os.close(self._fd)
            except OSError as exc:
                logger.warning("Cannot release PID file lock %s: %s", self.path, exc)
            self._fd = None
            try:
                self.path.unlink()
            except OSError as exc:
                logger.warning("Cannot remove PID file %s: %s", self.path, exc)


def _validate_hook_cmd(cmd: str) -> None:
    """Reject hook commands with null bytes or control characters (defense-in-depth)."""
    if "\x00" in cmd:
        raise ValueError("Hook command must not contain null bytes")
    if any(ord(c) < 0x20 and c not in ("\t", "\n") for c in cmd):
        raise ValueError("Hook command contains invalid control characters")


def _run_hook(cmd: str, payload: dict[str, Any], label: str) -> None:
    """Run an event hook command with JSON payload on stdin (best-effort, 30s timeout)."""
    try:
        proc = subprocess.run(
            cmd, shell=True, input=json.dumps(payload, default=str).encode(),
            timeout=30, capture_output=True,
        )
        if proc.returncode != 0:
            logger.warning("Hook %s exited %d: %s", label, proc.returncode, proc.stderr[:200])
    except subprocess.TimeoutExpired:
        logger.warning("Hook %s timed out after 30s", label)
    except Exception as exc:
        logger.warning("Hook %s failed: %s", label, exc)


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: Any) -> 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:
            if len(self.pending) >= MAX_PENDING:
                logger.warning(
                    "Event queue at capacity (%d) — rejecting new event for %s",
                    MAX_PENDING, target,
                )
                return
            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: Manifest,
    root: Path,
    client: Any,
    source: str | None,
    dry_run: bool,
    on_delete: str,
    batch_size: int = 100,
    on_change_hook: str | None = None,
    on_attest_hook: str | None = None,
) -> 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 on_change_hook:
            _run_hook(on_change_hook, {
                "event": etype.value, "path": rel, "hash": h,
                "size": stat.st_size, "root": str(root),
            }, "on-change")

    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
                    if on_attest_hook:
                        _run_hook(on_attest_hook, {
                            "hash": h, "attestation_id": str(att_id),
                            "timestamp": ts, "source": source or "",
                        }, "on-attest")
        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: Any = 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,
    on_change_hook: str | None = None,
    on_attest_hook: str | None = None,
    _observer_class: Any = None,
) -> None:
    """Watch a directory for changes, hash new/modified files, and attest.

    This function blocks until SIGINT or SIGTERM is received.

    Parameters
    ----------
    on_change_hook
        Shell command to run when a file change is detected. Receives JSON
        on stdin with event, path, hash, size, root.
    on_attest_hook
        Shell command to run after each successful attestation. Receives JSON
        on stdin with hash, attestation_id, timestamp, source.
    _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()

    if on_change_hook:
        _validate_hook_cmd(on_change_hook)
    if on_attest_hook:
        _validate_hook_cmd(on_attest_hook)

    pidfile = _PidFile(manifest_path.parent / ".census-watch.pid")
    pidfile.acquire()

    try:
        _watch_inner(
            root, manifest_path=manifest_path, client=client, source=source,
            debounce_sec=debounce_sec, batch_interval=batch_interval,
            scan_on_start=scan_on_start, on_delete=on_delete, polling=polling,
            poll_interval=poll_interval, dry_run=dry_run,
            include_globs=include_globs, exclude_globs=exclude_globs,
            min_file_size=min_file_size, max_file_size=max_file_size,
            show_progress=show_progress, quiet=quiet,
            on_change_hook=on_change_hook, on_attest_hook=on_attest_hook,
            _observer_class=_observer_class, _info=_info,
        )
    finally:
        pidfile.release()


def _watch_inner(
    root: Path,
    *,
    manifest_path: Path,
    client: Any = 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,
    on_change_hook: str | None = None,
    on_attest_hook: str | None = None,
    _observer_class: Any = None,
    _info: Callable[[str], None] = lambda msg: None,
) -> None:
    from .scanner import scan_directory

    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: int, frame: Any) -> None:
        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() and _observer_class is None:
                logger.warning(
                    "inotify watch limit reached — falling back to polling mode.\n"
                    "To fix permanently: sudo sysctl -w fs.inotify.max_user_watches=524288"
                )
                from watchdog.observers.polling import PollingObserver
                observer = PollingObserver(timeout=poll_interval)
                observer.schedule(handler, str(root), recursive=True)  # type: ignore[arg-type]
                observer.start()
                polling = True
            else:
                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"
                    )
                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()
        last_heartbeat = time.monotonic()

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

            now = time.monotonic()
            if now - last_heartbeat >= HEARTBEAT_INTERVAL:
                logger.info(
                    "Heartbeat: watching %s, manifest %d entries, pending %d",
                    root, manifest.total, len(pending),
                )
                last_heartbeat = now

            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:
                    event_batch = dict(pending)
                    pending.clear()

                if event_batch:
                    counts = _process_events(
                        event_batch, manifest, root, client, source, dry_run, on_delete,
                        on_change_hook=on_change_hook, on_attest_hook=on_attest_hook,
                    )
                    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(event_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}")
