"""Tests for the filesystem watcher module.

All tests mock the Observer and hash_file to avoid real filesystem
watching and real API calls.
"""

from __future__ import annotations

import logging
import os
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

import pytest

from certisigma_census.manifest import Manifest, ManifestEntry
from certisigma_census.watcher import (
    PENDING_WARN_THRESHOLD,
    CensusEventHandler,
    EventType,
    _PidFile,
    _check_watchdog,
    _load_or_create_watch_manifest,
    _process_events,
    _run_hook,
    _validate_hook_cmd,
    watch_directory,
)


def _make_event(event_type: str, src_path: str, is_directory: bool = False, dest_path: str | None = None):
    """Create a synthetic watchdog-like event."""
    ev = SimpleNamespace(
        event_type=event_type,
        src_path=src_path,
        is_directory=is_directory,
    )
    if dest_path:
        ev.dest_path = dest_path
    return ev


def _handler(root: Path, **kwargs):
    """Create a handler with a fresh pending dict and lock."""
    pending: dict[Path, EventType] = {}
    lock = threading.Lock()
    h = CensusEventHandler(root, pending, lock, **kwargs)
    return h, pending, lock


# ─── Event filtering ─────────────────────────────────────────────────────


class TestEventFiltering:
    def test_directory_events_ignored(self, tmp_path):
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("created", str(tmp_path / "subdir"), is_directory=True))
        assert len(pending) == 0

    def test_hidden_file_ignored(self, tmp_path):
        (tmp_path / ".secret").write_bytes(b"x")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("created", str(tmp_path / ".secret")))
        assert len(pending) == 0

    def test_skip_dir_ignored(self, tmp_path):
        git_dir = tmp_path / ".git"
        git_dir.mkdir()
        (git_dir / "config").write_bytes(b"x")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("created", str(git_dir / "config")))
        assert len(pending) == 0

    def test_include_glob_filters(self, tmp_path):
        (tmp_path / "doc.pdf").write_bytes(b"pdf")
        (tmp_path / "log.txt").write_bytes(b"log")
        handler, pending, _ = _handler(tmp_path, include_globs=["*.pdf"])
        handler.dispatch(_make_event("created", str(tmp_path / "doc.pdf")))
        handler.dispatch(_make_event("created", str(tmp_path / "log.txt")))
        assert len(pending) == 1
        assert tmp_path / "doc.pdf" in pending

    def test_exclude_glob_filters(self, tmp_path):
        (tmp_path / "data.csv").write_bytes(b"csv")
        (tmp_path / "debug.log").write_bytes(b"log")
        handler, pending, _ = _handler(tmp_path, exclude_globs=["*.log"])
        handler.dispatch(_make_event("created", str(tmp_path / "data.csv")))
        handler.dispatch(_make_event("created", str(tmp_path / "debug.log")))
        assert len(pending) == 1
        assert tmp_path / "data.csv" in pending

    def test_size_filter(self, tmp_path):
        (tmp_path / "tiny.txt").write_bytes(b"x")
        (tmp_path / "medium.txt").write_bytes(b"x" * 500)
        handler, pending, _ = _handler(tmp_path, min_file_size=100, max_file_size=1000)
        handler.dispatch(_make_event("created", str(tmp_path / "tiny.txt")))
        handler.dispatch(_make_event("created", str(tmp_path / "medium.txt")))
        assert len(pending) == 1
        assert tmp_path / "medium.txt" in pending

    def test_symlink_escape_rejected(self, tmp_path):
        outside = tmp_path / "outside"
        outside.mkdir()
        (outside / "secret.txt").write_bytes(b"secret")
        watched = tmp_path / "watched"
        watched.mkdir()
        link = watched / "escape"
        try:
            link.symlink_to(outside / "secret.txt")
        except OSError:
            pytest.skip("Cannot create symlinks")

        handler, pending, _ = _handler(watched)
        handler.dispatch(_make_event("created", str(link)))
        assert len(pending) == 0

    def test_prefix_overlap_directory_rejected(self, tmp_path):
        """Ensure /opt/watched doesn't match /opt/watched_malicious."""
        watched = tmp_path / "watched"
        watched.mkdir()
        malicious = tmp_path / "watched_malicious"
        malicious.mkdir()
        (malicious / "leak.txt").write_bytes(b"stolen")

        handler, pending, _ = _handler(watched)
        handler.dispatch(_make_event("created", str(malicious / "leak.txt")))
        assert len(pending) == 0

    def test_symlink_into_skip_dir_filtered(self, tmp_path):
        """A symlink pointing into .git must still be filtered by skip_dirs."""
        watched = tmp_path / "watched"
        watched.mkdir()
        git_dir = watched / ".git"
        git_dir.mkdir()
        (git_dir / "config").write_bytes(b"gitconfig")

        alt_entry = tmp_path / "alt"
        try:
            alt_entry.symlink_to(watched)
        except OSError:
            pytest.skip("Cannot create symlinks")

        handler, pending, _ = _handler(watched)
        handler.dispatch(_make_event("created", str(watched / ".git" / "config")))
        assert len(pending) == 0, "File inside .git/ must be filtered even via resolved path"


# ─── Event type tracking ─────────────────────────────────────────────────


class TestEventTypes:
    def test_created_event(self, tmp_path):
        (tmp_path / "new.txt").write_bytes(b"new")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("created", str(tmp_path / "new.txt")))
        assert pending[tmp_path / "new.txt"] == EventType.CREATED

    def test_modified_event(self, tmp_path):
        (tmp_path / "mod.txt").write_bytes(b"modified")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("modified", str(tmp_path / "mod.txt")))
        assert pending[tmp_path / "mod.txt"] == EventType.MODIFIED

    def test_moved_event_tracks_dest(self, tmp_path):
        (tmp_path / "new_name.txt").write_bytes(b"moved")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event(
            "moved", str(tmp_path / "old_name.txt"),
            dest_path=str(tmp_path / "new_name.txt"),
        ))
        assert tmp_path / "new_name.txt" in pending
        assert pending[tmp_path / "new_name.txt"] == EventType.MOVED

    def test_deleted_event(self, tmp_path):
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("deleted", str(tmp_path / "gone.txt")))
        assert pending[tmp_path / "gone.txt"] == EventType.DELETED

    def test_deduplication(self, tmp_path):
        (tmp_path / "file.txt").write_bytes(b"content")
        handler, pending, _ = _handler(tmp_path)
        handler.dispatch(_make_event("created", str(tmp_path / "file.txt")))
        handler.dispatch(_make_event("modified", str(tmp_path / "file.txt")))
        assert len(pending) == 1
        assert pending[tmp_path / "file.txt"] == EventType.MODIFIED


# ─── _process_events ─────────────────────────────────────────────────────


class TestProcessEvents:
    @patch("certisigma_census.watcher.hash_file", return_value="aa" * 32)
    def test_create_adds_to_manifest(self, _mock, tmp_path):
        (tmp_path / "new.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            events = {tmp_path / "new.txt": EventType.CREATED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["created"] == 1
            assert manifest.total == 1
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.hash_file", return_value="bb" * 32)
    def test_modify_updates_manifest(self, _mock, tmp_path):
        (tmp_path / "doc.txt").write_bytes(b"updated content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            manifest.add(ManifestEntry(hash_hex="aa" * 32, path="doc.txt", size=10, mtime=1.0))
            events = {tmp_path / "doc.txt": EventType.MODIFIED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["modified"] == 1
            entry = manifest.has_path("doc.txt")
            assert entry is not None
            assert entry.hash_hex == "bb" * 32
        finally:
            manifest.close()

    def test_delete_ignore_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            manifest.add(ManifestEntry(hash_hex="aa" * 32, path="gone.txt", size=10, mtime=1.0))
            events = {tmp_path / "gone.txt": EventType.DELETED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["deleted"] == 0
            assert manifest.total == 1
        finally:
            manifest.close()

    def test_delete_remove_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            manifest.add(ManifestEntry(hash_hex="aa" * 32, path="gone.txt", size=10, mtime=1.0))
            events = {tmp_path / "gone.txt": EventType.DELETED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "remove")
            assert counts["deleted"] == 1
            assert manifest.total == 0
        finally:
            manifest.close()

    def test_delete_mark_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            manifest.add(ManifestEntry(hash_hex="aa" * 32, path="gone.txt", size=10, mtime=1.0))
            events = {tmp_path / "gone.txt": EventType.DELETED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "mark")
            assert counts["deleted"] == 1
            assert manifest.total == 1
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.hash_file", side_effect=FileNotFoundError("gone"))
    def test_toctou_handled_gracefully(self, _mock, tmp_path):
        events = {tmp_path / "vanished.txt": EventType.CREATED}
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["errors"] == 1
            assert manifest.total == 0
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.hash_file", return_value="aa" * 32)
    def test_dry_run_no_api_calls(self, _mock, tmp_path):
        (tmp_path / "file.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            client = MagicMock()
            events = {tmp_path / "file.txt": EventType.CREATED}
            counts = _process_events(events, manifest, tmp_path, client, "test", True, "ignore")
            assert counts["created"] == 1
            client.batch_attest.assert_not_called()
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.retry_api_call")
    @patch("certisigma_census.watcher.hash_file", return_value="aa" * 32)
    def test_attestation_on_process(self, _mock_hash, mock_retry, tmp_path):
        (tmp_path / "file.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            mock_retry.return_value = MagicMock(attestations=[{
                "id": "att_1", "hash_hex": "aa" * 32, "timestamp": "2026-01-01",
            }])
            client = MagicMock()
            events = {tmp_path / "file.txt": EventType.CREATED}
            counts = _process_events(events, manifest, tmp_path, client, "test", False, "ignore")
            assert counts["attested"] == 1
            assert manifest.get("aa" * 32).attested is True
        finally:
            manifest.close()


# ─── Queue overflow warning ──────────────────────────────────────────────


class TestQueueOverflow:
    def test_overflow_warning_logged(self, tmp_path, caplog):
        pending: dict[Path, EventType] = {}
        lock = threading.Lock()
        handler = CensusEventHandler(tmp_path, pending, lock)

        for i in range(PENDING_WARN_THRESHOLD + 1):
            f = tmp_path / f"file_{i}.txt"
            f.write_bytes(b"x")
            pending[f] = EventType.CREATED

        (tmp_path / "trigger.txt").write_bytes(b"x")
        import logging
        with caplog.at_level(logging.WARNING):
            handler.dispatch(_make_event("created", str(tmp_path / "trigger.txt")))
        assert any("Pending queue" in r.message for r in caplog.records)


# ─── Manifest bootstrap (save-then-load, no :memory: leak) ────────────────


class TestLoadOrCreateWatchManifest:
    def test_loads_existing(self, tmp_path):
        mp = tmp_path / "inv.db"
        m0 = Manifest(root_dir="/data")
        m0.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=1, mtime=1.0))
        m0.save(mp)
        m0.close()
        m = _load_or_create_watch_manifest(mp, tmp_path)
        try:
            assert m.total == 1
        finally:
            m.close()

    def test_creates_new_sqlite_on_disk(self, tmp_path):
        mp = tmp_path / "new.db"
        root = tmp_path / "root"
        root.mkdir()
        assert not mp.exists()
        m = _load_or_create_watch_manifest(mp, root)
        try:
            assert mp.exists()
            assert m.root_dir == str(root.resolve())
            assert m.total == 0
        finally:
            m.close()

    def test_corrupt_manifest_raises_runtime_error(self, tmp_path):
        mp = tmp_path / "corrupt.db"
        mp.write_bytes(b"not a sqlite database at all")
        with pytest.raises(RuntimeError, match="Cannot load watch manifest"):
            _load_or_create_watch_manifest(mp, tmp_path)


# ─── _check_watchdog ──────────────────────────────────────────────────────


class TestCheckWatchdog:
    def test_passes_when_installed(self):
        _check_watchdog()

    def test_raises_system_exit_when_missing(self):
        with patch.dict("sys.modules", {"watchdog": None}):
            with patch("builtins.__import__", side_effect=ImportError("no watchdog")):
                with pytest.raises(SystemExit, match="watchdog is required"):
                    _check_watchdog()


# ─── Event edge cases ─────────────────────────────────────────────────────


class TestEventEdgeCases:
    def test_event_without_event_type_defaults_to_modified(self, tmp_path):
        (tmp_path / "file.txt").write_bytes(b"content")
        handler, pending, _ = _handler(tmp_path)
        ev = SimpleNamespace(src_path=str(tmp_path / "file.txt"), is_directory=False)
        handler.dispatch(ev)
        assert pending[tmp_path / "file.txt"] == EventType.MODIFIED

    def test_accept_returns_false_on_resolve_oserror(self, tmp_path):
        handler, _, _ = _handler(tmp_path)
        bad_path = Path("/nonexistent/\x00broken")
        with patch.object(Path, "resolve", side_effect=OSError("bad")):
            result = handler._accept(bad_path)
        assert result is False

    def test_accept_returns_true_on_stat_oserror(self, tmp_path):
        """When stat() fails for a non-delete event, accept returns True (TOCTOU-safe)."""
        (tmp_path / "vanishing.txt").write_bytes(b"data")
        handler, _, _ = _handler(tmp_path)
        with patch.object(Path, "stat", side_effect=OSError("gone")):
            result = handler._accept(tmp_path / "vanishing.txt")
        assert result is True


# ─── _process_events edge cases ──────────────────────────────────────────


class TestProcessEventsEdgeCases:
    @patch("certisigma_census.watcher.hash_file", return_value="cc" * 32)
    def test_moved_event_counted(self, _mock, tmp_path):
        (tmp_path / "renamed.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            events = {tmp_path / "renamed.txt": EventType.MOVED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["moved"] == 1
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.hash_file", return_value="dd" * 32)
    def test_modified_event_counted(self, _mock, tmp_path):
        (tmp_path / "changed.txt").write_bytes(b"updated")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            events = {tmp_path / "changed.txt": EventType.MODIFIED}
            counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
            assert counts["modified"] == 1
        finally:
            manifest.close()

    def test_event_outside_root_skipped(self, tmp_path, caplog):
        outside = tmp_path / "outside"
        outside.mkdir()
        root = tmp_path / "root"
        root.mkdir()
        manifest = Manifest(root_dir=str(root))
        try:
            events = {outside / "leak.txt": EventType.CREATED}
            with caplog.at_level(logging.DEBUG):
                counts = _process_events(events, manifest, root, None, None, True, "ignore")
            assert counts["created"] == 0
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.retry_api_call")
    @patch("certisigma_census.watcher.hash_file", return_value="ee" * 32)
    def test_attestation_id_key_variant(self, _mock_hash, mock_retry, tmp_path):
        """Test attestation with 'attestation_id' key (vs 'id')."""
        (tmp_path / "file.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            mock_retry.return_value = MagicMock(attestations=[{
                "attestation_id": "att_abc", "hash_hex": "ee" * 32, "timestamp": "2026-01-01",
            }])
            client = MagicMock()
            events = {tmp_path / "file.txt": EventType.CREATED}
            counts = _process_events(events, manifest, tmp_path, client, "test", False, "ignore")
            assert counts["attested"] == 1
        finally:
            manifest.close()

    @patch("certisigma_census.watcher.retry_api_call", side_effect=Exception("API down"))
    @patch("certisigma_census.watcher.hash_file", return_value="ff" * 32)
    def test_batch_attest_failure_logged(self, _mock_hash, _mock_retry, tmp_path, caplog):
        (tmp_path / "file.txt").write_bytes(b"content")
        manifest = Manifest(root_dir=str(tmp_path))
        try:
            client = MagicMock()
            events = {tmp_path / "file.txt": EventType.CREATED}
            with caplog.at_level(logging.ERROR):
                counts = _process_events(events, manifest, tmp_path, client, "src", False, "ignore")
            assert counts["errors"] == 1
            assert any("Batch attest failed" in r.message for r in caplog.records)
        finally:
            manifest.close()


# ─── watch_directory integration tests ───────────────────────────────────


class _FakeObserver:
    """Minimal mock Observer that signals shutdown after scheduling."""

    def __init__(self, shutdown_after: float = 0.1, **kwargs):
        self._shutdown_after = shutdown_after
        self._handler = None
        self._path = None
        self.started = False
        self.stopped = False
        self.joined = False

    def schedule(self, handler, path, recursive=False):
        self._handler = handler
        self._path = path

    def start(self):
        self.started = True

    def stop(self):
        self.stopped = True

    def join(self, timeout=None):
        self.joined = True


class _FakeObserverWithEvent(_FakeObserver):
    """Observer that injects an event then signals shutdown."""

    def __init__(self, events_to_fire=None, **kwargs):
        super().__init__(**kwargs)
        self._events_to_fire = events_to_fire or []

    def start(self):
        self.started = True
        for ev in self._events_to_fire:
            if self._handler:
                self._handler.dispatch(ev)


class _FakeObserverInotifyError(_FakeObserver):
    def start(self):
        raise OSError("inotify watch limit reached")


class TestWatchDirectory:
    @patch("certisigma_census.scanner.scan_directory")
    def test_basic_startup_shutdown_dry_run(self, mock_scan, tmp_path):
        """Watch starts, does baseline scan, shuts down on SIGINT."""
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        mock_scan.return_value = Manifest(root_dir=str(root))

        shutdown_event = threading.Event()

        class QuickShutdownObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                shutdown_event.set()

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            if shutdown_event.is_set():
                self.set()
                return True
            return original_wait(self, timeout)

        with patch.object(threading.Event, "wait", patched_wait):
            watch_directory(
                root,
                manifest_path=mpath,
                dry_run=True,
                scan_on_start=True,
                debounce_sec=0.01,
                batch_interval=0.01,
                _observer_class=QuickShutdownObserver,
            )

        assert mpath.exists()
        mock_scan.assert_called_once()

    @patch("certisigma_census.scanner.scan_directory")
    def test_no_scan_on_start(self, mock_scan, tmp_path):
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        shutdown_event = threading.Event()

        class QuickShutdownObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                shutdown_event.set()

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            if shutdown_event.is_set():
                self.set()
                return True
            return original_wait(self, timeout)

        with patch.object(threading.Event, "wait", patched_wait):
            watch_directory(
                root,
                manifest_path=mpath,
                dry_run=True,
                scan_on_start=False,
                debounce_sec=0.01,
                _observer_class=QuickShutdownObserver,
            )

        mock_scan.assert_not_called()

    @patch("certisigma_census.scanner.scan_directory")
    def test_quiet_mode_suppresses_output(self, mock_scan, tmp_path, capsys):
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        mock_scan.return_value = Manifest(root_dir=str(root))

        shutdown_event = threading.Event()

        class QuickShutdownObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                shutdown_event.set()

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            if shutdown_event.is_set():
                self.set()
                return True
            return original_wait(self, timeout)

        with patch.object(threading.Event, "wait", patched_wait):
            watch_directory(
                root,
                manifest_path=mpath,
                dry_run=True,
                scan_on_start=False,
                quiet=True,
                debounce_sec=0.01,
                _observer_class=QuickShutdownObserver,
            )

        captured = capsys.readouterr()
        assert "Baseline" not in captured.out
        assert "Manifest" not in captured.out

    @patch("certisigma_census.scanner.scan_directory")
    def test_inotify_error_logged_and_raised(self, mock_scan, tmp_path, caplog):
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        mock_scan.return_value = Manifest(root_dir=str(root))

        with caplog.at_level(logging.ERROR):
            with pytest.raises(OSError, match="inotify"):
                watch_directory(
                    root,
                    manifest_path=mpath,
                    dry_run=True,
                    scan_on_start=False,
                    debounce_sec=0.01,
                    _observer_class=_FakeObserverInotifyError,
                )

        assert any("inotify" in r.message for r in caplog.records)

    @patch("certisigma_census.watcher.retry_api_call")
    @patch("certisigma_census.scanner.scan_directory")
    def test_baseline_attestation_with_client(self, mock_scan, mock_retry, tmp_path):
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        baseline = Manifest(root_dir=str(root))
        baseline.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=10, mtime=1.0))
        mock_scan.return_value = baseline

        mock_retry.return_value = MagicMock(attestations=[{
            "id": "att_1", "hash_hex": "aa" * 32, "timestamp": "2026-01-01",
        }])

        shutdown_event = threading.Event()

        class QuickShutdownObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                shutdown_event.set()

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            if shutdown_event.is_set():
                self.set()
                return True
            return original_wait(self, timeout)

        client = MagicMock()

        with patch.object(threading.Event, "wait", patched_wait):
            watch_directory(
                root,
                manifest_path=mpath,
                client=client,
                source="test",
                scan_on_start=True,
                debounce_sec=0.01,
                _observer_class=QuickShutdownObserver,
            )

        mock_retry.assert_called_once()

    @patch("certisigma_census.watcher.retry_api_call", side_effect=Exception("API fail"))
    @patch("certisigma_census.scanner.scan_directory")
    def test_baseline_attest_failure_logged(self, mock_scan, mock_retry, tmp_path, caplog):
        root = tmp_path / "data"
        root.mkdir()
        mpath = tmp_path / "m.db"

        baseline = Manifest(root_dir=str(root))
        baseline.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=10, mtime=1.0))
        mock_scan.return_value = baseline

        shutdown_event = threading.Event()

        class QuickShutdownObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                shutdown_event.set()

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            if shutdown_event.is_set():
                self.set()
                return True
            return original_wait(self, timeout)

        client = MagicMock()

        with caplog.at_level(logging.ERROR):
            with patch.object(threading.Event, "wait", patched_wait):
                watch_directory(
                    root,
                    manifest_path=mpath,
                    client=client,
                    scan_on_start=True,
                    debounce_sec=0.01,
                    _observer_class=QuickShutdownObserver,
                )

        assert any("Baseline attest failed" in r.message for r in caplog.records)

    @patch("certisigma_census.watcher.hash_file", return_value="bb" * 32)
    @patch("certisigma_census.scanner.scan_directory")
    def test_event_processing_during_watch(self, mock_scan, _mock_hash, tmp_path):
        root = tmp_path / "data"
        root.mkdir()
        (root / "new.txt").write_bytes(b"content")
        mpath = tmp_path / "m.db"

        mock_scan.return_value = Manifest(root_dir=str(root))

        shutdown_event = threading.Event()
        cycle_count = [0]

        class EventInjectingObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                if self_obs._handler:
                    self_obs._handler.dispatch(
                        _make_event("created", str(root / "new.txt"))
                    )

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            cycle_count[0] += 1
            if cycle_count[0] >= 3:
                self.set()
                return True
            return False

        with patch.object(threading.Event, "wait", patched_wait):
            watch_directory(
                root,
                manifest_path=mpath,
                dry_run=True,
                scan_on_start=False,
                debounce_sec=0.0,
                batch_interval=0.0,
                _observer_class=EventInjectingObserver,
            )

        m = Manifest.load(mpath)
        try:
            assert m.total >= 1
        finally:
            m.close()

    @patch("certisigma_census.scanner.scan_directory")
    def test_manifest_save_error_logged(self, mock_scan, tmp_path, caplog):
        root = tmp_path / "data"
        root.mkdir()
        (root / "f.txt").write_bytes(b"x")
        mpath = tmp_path / "m.db"

        mock_scan.return_value = Manifest(root_dir=str(root))

        cycle_count = [0]
        original_save = Manifest.save

        class EventInjectingObserver(_FakeObserver):
            def start(self_obs):
                self_obs.started = True
                if self_obs._handler:
                    self_obs._handler.dispatch(
                        _make_event("created", str(root / "f.txt"))
                    )

        original_wait = threading.Event.wait

        def patched_wait(self, timeout=None):
            cycle_count[0] += 1
            if cycle_count[0] >= 3:
                self.set()
                return True
            return False

        save_call_count = [0]

        def save_that_fails_second(manifest_self, path):
            save_call_count[0] += 1
            if save_call_count[0] == 2:
                raise OSError("disk full")
            return original_save(manifest_self, path)

        with caplog.at_level(logging.ERROR):
            with patch.object(threading.Event, "wait", patched_wait):
                with patch("certisigma_census.watcher.hash_file", return_value="cc" * 32):
                    with patch.object(Manifest, "save", save_that_fails_second):
                        watch_directory(
                            root,
                            manifest_path=mpath,
                            dry_run=True,
                            scan_on_start=False,
                            debounce_sec=0.0,
                            batch_interval=0.0,
                            _observer_class=EventInjectingObserver,
                        )

        assert any("Cannot save manifest" in r.message for r in caplog.records)


# ─── _PidFile ─────────────────────────────────────────────────────────────


class TestPidFile:
    def test_acquire_release_lifecycle(self, tmp_path: Path) -> None:
        pid_path = tmp_path / "test.pid"
        pf = _PidFile(pid_path)
        pf.acquire()
        assert pid_path.exists()
        content = pid_path.read_text()
        assert str(os.getpid()) in content
        pf.release()
        assert not pid_path.exists()

    def test_double_acquire_raises_runtime_error(self, tmp_path: Path) -> None:
        pid_path = tmp_path / "test.pid"
        pf1 = _PidFile(pid_path)
        pf1.acquire()
        try:
            pf2 = _PidFile(pid_path)
            with pytest.raises(RuntimeError, match="Another census watch process"):
                pf2.acquire()
        finally:
            pf1.release()

    def test_release_is_idempotent(self, tmp_path: Path) -> None:
        pid_path = tmp_path / "test.pid"
        pf = _PidFile(pid_path)
        pf.acquire()
        pf.release()
        pf.release()

    def test_acquire_permission_error_distinct_message(self, tmp_path: Path) -> None:
        pid_path = tmp_path / "no_perms" / "test.pid"
        pf = _PidFile(pid_path)
        with pytest.raises(RuntimeError, match="Cannot acquire PID file"):
            pf.acquire()

    def test_release_unlink_failure_logged(self, tmp_path: Path, caplog) -> None:
        pid_path = tmp_path / "test.pid"
        pf = _PidFile(pid_path)
        pf.acquire()
        pid_path.unlink()
        with caplog.at_level(logging.WARNING):
            pf.release()
        assert any("Cannot remove PID file" in r.message for r in caplog.records)


# ─── _validate_hook_cmd ──────────────────────────────────────────────────


class TestValidateHookCmd:
    def test_valid_command_accepted(self) -> None:
        _validate_hook_cmd("echo hello")
        _validate_hook_cmd("curl -X POST https://hooks.example.com/notify")
        _validate_hook_cmd("cat | jq .path")

    def test_null_byte_rejected(self) -> None:
        with pytest.raises(ValueError, match="null bytes"):
            _validate_hook_cmd("echo\x00hello")

    def test_control_char_rejected(self) -> None:
        with pytest.raises(ValueError, match="control characters"):
            _validate_hook_cmd("echo\x01hello")

    def test_tab_and_newline_allowed(self) -> None:
        _validate_hook_cmd("echo\thello")
        _validate_hook_cmd("echo hello\necho world")

    def test_empty_string_accepted(self) -> None:
        _validate_hook_cmd("")


# ─── _run_hook ────────────────────────────────────────────────────────────


class TestRunHook:
    def test_successful_execution(self, caplog) -> None:
        with caplog.at_level(logging.WARNING):
            _run_hook("cat > /dev/null", {"key": "value"}, "test-hook")
        assert not any("test-hook" in r.message for r in caplog.records)

    def test_nonzero_exit_logged(self, caplog) -> None:
        with caplog.at_level(logging.WARNING):
            _run_hook("exit 42", {}, "fail-hook")
        assert any("fail-hook exited 42" in r.message for r in caplog.records)

    def test_timeout_logged(self, caplog) -> None:
        with caplog.at_level(logging.WARNING):
            with patch("certisigma_census.watcher.subprocess.run",
                       side_effect=__import__("subprocess").TimeoutExpired("cmd", 30)):
                _run_hook("sleep 999", {}, "timeout-hook")
        assert any("timed out" in r.message for r in caplog.records)

    def test_payload_passed_as_json_stdin(self) -> None:
        import json as json_mod
        import subprocess
        result = subprocess.run(
            "cat", shell=True,
            input=json_mod.dumps({"event": "created", "path": "test.txt"}).encode(),
            capture_output=True, timeout=5,
        )
        parsed = json_mod.loads(result.stdout)
        assert parsed["event"] == "created"
        assert parsed["path"] == "test.txt"
