"""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 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,
    _process_events,
)


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

    @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))
        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

    def test_delete_ignore_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        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

    def test_delete_remove_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        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

    def test_delete_mark_policy(self, tmp_path):
        manifest = Manifest(root_dir=str(tmp_path))
        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

    @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))
        counts = _process_events(events, manifest, tmp_path, None, None, True, "ignore")
        assert counts["errors"] == 1
        assert manifest.total == 0

    @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))
        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()

    @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))
        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


# ─── 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)
