"""Tests for fs_watcher.watcher — FsWatcher wrapping watchdog.Observer.

These tests exercise the real filesystem via tmp_path fixtures. They are
slower than pure-unit tests but catch real debounce, exclusion, and
observer-wiring bugs.
"""

from __future__ import annotations

import time
from pathlib import Path

import pytest

from recoil.pipeline._lib.fs_watcher.events import FsEvent, FsEventType
from recoil.pipeline._lib.fs_watcher.pubsub import EventBroker
from recoil.pipeline._lib.fs_watcher.transports.callback import CallbackTransport
from recoil.pipeline._lib.fs_watcher.watcher import (
    DEBOUNCE_MS,
    EXCLUDED_PATH_PARTS,
    FsWatcher,
)


def _wait_for_event(received: list[FsEvent], count: int, timeout: float = 3.0) -> None:
    """Poll for at least `count` events, up to timeout seconds."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        if len(received) >= count:
            return
        time.sleep(0.05)


def _wait_for_path(received: list[FsEvent], needle: str, timeout: float = 3.0) -> None:
    """Poll until at least one received event's path contains `needle`.

    Used when parent-directory MODIFIED events may land before the
    specific event we care about. Waiting for `count >= 1` is not
    enough because the first event may be an unrelated parent-dir
    refresh from FSEvents.
    """
    deadline = time.time() + timeout
    while time.time() < deadline:
        if any(needle in ev.path for ev in received):
            return
        time.sleep(0.05)


def test_excluded_path_parts_constant_has_expected_values():
    """The _meta/ exclusion is load-bearing — must be present."""
    assert "_meta" in EXCLUDED_PATH_PARTS
    assert ".DS_Store" in EXCLUDED_PATH_PARTS
    assert "__pycache__" in EXCLUDED_PATH_PARTS
    assert ".git" in EXCLUDED_PATH_PARTS
    assert "_thumbs" in EXCLUDED_PATH_PARTS
    assert "_exploration" in EXCLUDED_PATH_PARTS


def test_debounce_ms_is_200():
    """Spec locks debounce at 200ms."""
    assert DEBOUNCE_MS == 200


def test_file_create_emits_event(tmp_path, monkeypatch):
    """Creating a file under a watched path emits a CREATED event."""
    import recoil.core.paths
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")

    watched_dir = tmp_path / "projects" / "testproj" / "output" / "refs" / "_canonical" / "characters" / "sadie"
    watched_dir.mkdir(parents=True)

    broker = EventBroker()
    received: list[FsEvent] = []
    transport = CallbackTransport(broker, callback=received.append)
    transport.start()

    watcher = FsWatcher(roots=[tmp_path], broker=broker)
    watcher.start()

    try:
        # Wait for watchdog Observer to be ready
        time.sleep(0.2)

        # Drop a file
        new_file = watched_dir / "hero.jpg"
        new_file.write_bytes(b"test content")

        _wait_for_path(received, "hero.jpg", timeout=3.0)

        # At least one event should reference the hero.jpg path
        matching = [ev for ev in received if "hero.jpg" in ev.path]
        assert len(matching) >= 1
        assert matching[0].event_type in (FsEventType.CREATED, FsEventType.MODIFIED)
    finally:
        watcher.stop()
        transport.stop()


def test_excluded_path_is_filtered(tmp_path, monkeypatch):
    """Events under _meta/ are never emitted."""
    import recoil.core.paths
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    meta_dir = tmp_path / "projects" / "testproj" / "state" / "visual" / "_meta"
    meta_dir.mkdir(parents=True)

    broker = EventBroker()
    received: list[FsEvent] = []
    transport = CallbackTransport(broker, callback=received.append)
    transport.start()

    watcher = FsWatcher(roots=[tmp_path], broker=broker)
    watcher.start()

    try:
        time.sleep(0.2)

        (meta_dir / "some_sidecar.json").write_text("{}")
        time.sleep(0.5)  # give the watcher a chance

        # No events from _meta/
        meta_events = [ev for ev in received if "_meta" in ev.path]
        assert meta_events == []
    finally:
        watcher.stop()
        transport.stop()


def test_debounce_collapses_rapid_writes(tmp_path, monkeypatch):
    """Multiple rapid MODIFIED events on the same path are collapsed into one."""
    import recoil.core.paths
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    watched_dir = tmp_path / "projects" / "testproj" / "output"
    watched_dir.mkdir(parents=True)

    broker = EventBroker()
    received: list[FsEvent] = []
    transport = CallbackTransport(broker, callback=received.append)
    transport.start()

    watcher = FsWatcher(roots=[tmp_path], broker=broker)
    watcher.start()

    try:
        time.sleep(0.2)

        target = watched_dir / "file.txt"
        # Rapid writes within the 200ms debounce window
        for i in range(10):
            target.write_text(f"content {i}")

        time.sleep(0.6)  # wait past debounce window

        # Count MODIFIED events for this path — should be <= 2 (well under 10)
        matching = [ev for ev in received if "file.txt" in ev.path]
        assert len(matching) <= 3, f"expected <=3 debounced events, got {len(matching)}"
    finally:
        watcher.stop()
        transport.stop()


def test_stop_joins_observer_thread():
    """stop() cleanly joins the observer thread — no zombies."""
    broker = EventBroker()
    watcher = FsWatcher(roots=[Path("/tmp")], broker=broker)
    watcher.start()
    time.sleep(0.1)
    watcher.stop()  # Should not hang
    # If we get here, the test passes


def test_modified_event_for_pre_existing_file_is_not_dropped(tmp_path, monkeypatch):
    """Regression for R1 fix: MODIFIED events on files that existed at startup must publish.

    Before the fix, the FSEvents backlog filter dropped ALL events (both CREATED and
    MODIFIED) whose path was in the startup snapshot — permanently. This meant that
    editing a pre-existing sadie/hero.jpg never updated the console. After the fix,
    only CREATED events for snapshotted paths are dropped; MODIFIED events always
    publish.
    """
    import recoil.core.paths
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")

    watched_dir = tmp_path / "projects" / "testproj" / "output" / "refs" / "_canonical" / "characters" / "sadie"
    watched_dir.mkdir(parents=True)
    pre_existing = watched_dir / "hero.jpg"
    pre_existing.write_bytes(b"original content")  # Create BEFORE watcher starts

    broker = EventBroker()
    received: list[FsEvent] = []
    transport = CallbackTransport(broker, callback=received.append)
    transport.start()

    watcher = FsWatcher(roots=[tmp_path], broker=broker)
    watcher.start()

    try:
        time.sleep(0.3)  # let observer settle and backlog drain

        # Clear any backlog events that snuck through despite the filter
        received.clear()

        # Modify the pre-existing file
        pre_existing.write_bytes(b"new content - user edited it")

        _wait_for_path(received, "hero.jpg", timeout=3.0)

        # At least one event for the modified file should come through
        matching = [ev for ev in received if "hero.jpg" in ev.path]
        assert len(matching) >= 1, (
            f"expected a MODIFIED event for pre-existing hero.jpg, got: "
            f"{[(e.event_type, e.path) for e in received]}"
        )
        assert matching[0].event_type in (FsEventType.MODIFIED, FsEventType.CREATED), (
            f"expected MODIFIED, got {matching[0].event_type}"
        )
    finally:
        watcher.stop()
        transport.stop()


def test_deleted_then_recreated_file_emits_created_event(tmp_path, monkeypatch):
    """Regression: delete-then-recreate of a pre-existing file must emit CREATED.

    The startup snapshot is pruned on DELETED events so a subsequent CREATED
    event for the same path passes the backlog filter. Without this, tools
    that write files via atomic rename (temp + mv over target) would make
    the target appear permanently deleted.
    """
    import recoil.core.paths
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")

    watched_dir = tmp_path / "projects" / "testproj" / "output" / "refs" / "_canonical" / "props" / "knife"
    watched_dir.mkdir(parents=True)
    target = watched_dir / "hero.jpg"
    target.write_bytes(b"original")  # Create BEFORE watcher starts

    broker = EventBroker()
    received: list[FsEvent] = []
    transport = CallbackTransport(broker, callback=received.append)
    transport.start()

    watcher = FsWatcher(roots=[tmp_path], broker=broker)
    watcher.start()

    try:
        time.sleep(0.3)  # let observer settle and backlog drain
        received.clear()

        # Delete the pre-existing file
        target.unlink()
        time.sleep(0.3)  # wait for deleted event to propagate

        # Now re-create it — this CREATED event must NOT be filtered
        target.write_bytes(b"recreated")

        _wait_for_path(received, "hero.jpg", timeout=3.0)

        # At least one CREATED or MODIFIED event for hero.jpg after recreation
        post_delete_events = [
            ev for ev in received if "hero.jpg" in ev.path
        ]
        assert len(post_delete_events) >= 1, (
            f"expected a CREATED event after delete+recreate, got: "
            f"{[(e.event_type, e.path) for e in received]}"
        )
    finally:
        watcher.stop()
        transport.stop()
