"""Tests for GWFrameWatchSource element."""

import logging
import queue
import shutil
import time
from pathlib import Path
from threading import Thread
from unittest.mock import Mock

import gwframe
import numpy as np
import pytest
from sgn.apps import Pipeline
from sgn.sinks import NullSink
from sgn.subprocess import WorkerContext

from sgn_gwframe.sources import GWFrameWatchSource
from sgn_gwframe.sources.framewatch import (
    _drain_and_get_file,
    _GWFrameFileEventHandler,
    _initialize_rates_from_sample,
    _read_and_validate_frame,
)

# =============================================================================
# Fixtures
# =============================================================================


@pytest.fixture
def frame_writer(tmp_path):
    """Fixture that provides a function to write test frames at specified times.

    Returns a function that takes (t0, channels_data) and writes a frame file.
    """

    def write_frame(
        t0: int, channels_data: dict[str, tuple[float, np.ndarray]]
    ) -> Path:
        """Write a frame file with given channels.

        Args:
            t0: GPS start time
            channels_data: dict mapping channel name to (sample_rate, data_array)

        Returns:
            Path to the written frame file
        """
        # Extract arrays and sample rates
        channels = {}
        sample_rates = {}
        duration = None
        for channel, (sample_rate, data) in channels_data.items():
            channels[channel] = data
            sample_rates[channel] = sample_rate
            if duration is None:
                duration = len(data) / sample_rate

        # Write frame using T050017 naming convention
        # Format: {site}-{description}-{t0}-{duration}.gwf
        ifo = next(iter(channels_data.keys())).split(":")[0]
        assert duration is not None, "Duration must be set"
        filename = f"{ifo}-TEST_FRAME-{t0}-{int(duration)}.gwf"
        filepath = tmp_path / filename

        gwframe.write(
            str(filepath),
            channels,
            t0=t0,
            sample_rate=sample_rates,
        )
        return filepath

    return write_frame


@pytest.fixture
def test_channels():
    """Standard test channels with different sample rates."""
    return {
        "L1:TEST_STRAIN": 16384.0,  # High rate strain channel
        "L1:TEST_DQMASK": 1.0,  # Low rate state vector
    }


@pytest.fixture
def frame_data_generator(test_channels):
    """Generate test data arrays for frame channels."""

    def generate(duration: int = 32) -> dict[str, tuple[float, np.ndarray]]:
        """Generate random data for all test channels.

        Args:
            duration: Duration in seconds

        Returns:
            dict mapping channel name to (sample_rate, data_array)
        """
        data = {}
        for channel, rate in test_channels.items():
            n_samples = int(duration * rate)
            # Generate random data scaled appropriately
            array: np.ndarray
            if "STRAIN" in channel:
                # Strain-like data: small values
                array = np.random.randn(n_samples) * 1e-21
            else:
                # State vector: integer values
                array = np.random.randint(0, 256, n_samples, dtype=np.int32)
            data[channel] = (rate, array)
        return data

    return generate


# =============================================================================
# Integration Tests (End-to-End Pipeline Tests)
# =============================================================================


@pytest.mark.freeze_time("2019-04-25 08:17:49", tick=True)
def test_framewatch_continuous_data(
    tmp_path, frame_writer, frame_data_generator, test_channels
):
    """Test GWFrameWatchSource with continuous data arrival."""
    start_gps = 1240215487

    # Write initial frame for rate initialization
    frame_writer(start_gps - 32, frame_data_generator(32))

    # Create pipeline
    src = GWFrameWatchSource(
        name="src",
        channels=list(test_channels.keys()),
        watch_dir=str(tmp_path),
        duration=32,
    )
    sink = NullSink(
        name="sink",
        sink_pad_names=tuple(test_channels.keys()),
    )

    pipeline = Pipeline()
    pipeline.insert(
        src,
        sink,
        link_map={sink.snks[channel]: src.srcs[channel] for channel in test_channels},
    )

    # Write frames in a background thread to simulate live data
    def populate_frames():
        time.sleep(2)
        # Write 3 continuous frames
        for i in range(3):
            t0 = start_gps + i * 32
            frame_writer(t0, frame_data_generator(32))
            time.sleep(1)

    thread = Thread(target=populate_frames)
    thread.start()

    # Run pipeline
    pipeline.run()
    thread.join()


@pytest.mark.freeze_time("2019-04-25 08:17:49", tick=True)
def test_framewatch_with_discontinuity(
    tmp_path, frame_writer, frame_data_generator, test_channels
):
    """Test GWFrameWatchSource handling of data discontinuity."""
    start_gps = 1240215487

    # Write initial frame for rate initialization
    frame_writer(start_gps - 32, frame_data_generator(32))

    # Create pipeline
    src = GWFrameWatchSource(
        name="src",
        channels=list(test_channels.keys()),
        watch_dir=str(tmp_path),
        duration=32,
        discont_wait_time=5,  # Short timeout for test
    )
    sink = NullSink(
        name="sink",
        sink_pad_names=tuple(test_channels.keys()),
    )

    pipeline = Pipeline()
    pipeline.insert(
        src,
        sink,
        link_map={sink.snks[channel]: src.srcs[channel] for channel in test_channels},
    )

    # Write frames with a gap
    def populate_frames():
        time.sleep(2)
        # First frame at start
        frame_writer(start_gps, frame_data_generator(32))
        time.sleep(1)
        # Second frame with gap (64 seconds later instead of 32)
        frame_writer(start_gps + 64, frame_data_generator(32))
        time.sleep(2)
        # Third frame continuous with second
        frame_writer(start_gps + 96, frame_data_generator(32))

    thread = Thread(target=populate_frames)
    thread.start()

    # Run pipeline - should handle gap
    pipeline.run()
    thread.join()


@pytest.mark.freeze_time("2019-04-25 08:17:49", tick=True)
def test_framewatch(tmp_path):
    """Original comprehensive test for backward compatibility."""
    pipeline = Pipeline()

    datadir = Path(__file__).parent / "data"
    test_frame = "L-L1_GWOSC_16KHZ_R1-1240215487-32.gwf"

    # Add frame to watch directory to determine sampling rates
    dest_path = tmp_path / "L-L1_GWOSC_16KHZ_R1-1240215465-32.gwf"
    shutil.copyfile(datadir / test_frame, dest_path)

    # Create pipeline
    hoft = "L1:GWOSC-16KHZ_R1_STRAIN"
    dqmask = "L1:GWOSC-16KHZ_R1_DQMASK"

    src = GWFrameWatchSource(
        name="src",
        channels=[hoft, dqmask],
        watch_dir=str(tmp_path),
        duration=32,
    )
    sink = NullSink(
        name="sink",
        sink_pad_names=("strain", "dqmask"),
    )

    pipeline.insert(
        src,
        sink,
        link_map={
            sink.snks["strain"]: src.srcs[hoft],
            sink.snks["dqmask"]: src.srcs[dqmask],
        },
    )

    # Add frame to simulate live data after a short period of time
    def populate_frame():
        time.sleep(3)
        shutil.copyfile(datadir / test_frame, tmp_path / test_frame)

    thread = Thread(target=populate_frame)
    thread.start()

    # Start pipeline
    pipeline.run()
    thread.join()


# =============================================================================
# Unit Tests (Direct worker_process Testing)
# =============================================================================


def test_timeout_with_rates_sends_gap_unit(tmp_path, monkeypatch):
    """Test that timeout with rates sends GAP, not heartbeat."""
    # Mock gpstime.gpsnow to return time that triggers timeout
    monkeypatch.setattr(
        "gpstime.gpsnow", lambda: 1015
    )  # 15 seconds wait, > 10 second timeout

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        duration=32,
        discont_wait_time=10,
    )

    # Create real WorkerContext
    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    # Initialize state as if we've already processed one frame
    context.state["initialized"] = True
    context.state["file_queue"] = queue.Queue()  # Empty queue - will trigger timeout
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1000
    context.state["last_file_time"] = 1000
    context.state["rates"] = {"L1:TEST": 16384}
    context.state["buffer_duration"] = 32.0
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    # Mock logger
    logger = logging.getLogger("test")

    # Call worker_process - queue is empty, should timeout and send GAP
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=10,
        start=1000,
        logger=logger,
    )

    # Check that GAP was sent
    assert not output_queue.empty(), "Should have sent a buffer"
    _pad, buf = output_queue.get()
    assert buf.data is None, "Should be a gap buffer"
    assert buf.shape[0] == 32 * 16384, "Gap should be 32 seconds"
    print("✓ Timeout with rates sends GAP")


def test_discontinuity_detection_stores_pending_unit(tmp_path, monkeypatch):
    """Test that discontinuity detection stores pending frame."""
    # Mock gpstime
    monkeypatch.setattr("gpstime.gpsnow", lambda: 1032)

    # Write a test frame file
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path = tmp_path / "L1-TEST-1064-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1064, sample_rate=16384.0)

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        duration=32,
    )

    # Create real WorkerContext
    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    # Initialize state as if we've already processed one frame at t=1000
    file_queue: queue.Queue = queue.Queue()
    file_queue.put((str(frame_path), 1064))  # Discontinuous file at t=1064

    context.state["initialized"] = True
    context.state["file_queue"] = file_queue
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1032  # Expected next time after frame at 1000
    context.state["last_file_time"] = 1000  # Last file was at 1000
    context.state["rates"] = {"L1:TEST": 16384}
    context.state["buffer_duration"] = 32.0
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    logger = logging.getLogger("test")

    # Call worker_process
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.5,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Check that discontinuity was detected
    assert context.state["pending_frame"] is not None, (
        "Should have stored pending frame"
    )
    assert context.state["pending_file_time"] == 1064, "Should have stored pending time"

    # Should have sent a GAP buffer
    assert not output_queue.empty(), "Should have sent gap buffer"
    _pad, buf = output_queue.get()
    assert buf.data is None, "Should be gap buffer"
    print("✓ Discontinuity detection stores pending frame")


def test_pending_frame_sent_with_final_gap_unit(tmp_path, monkeypatch):
    """Test that pending frame is sent along with final gap buffer."""
    # Mock gpstime
    monkeypatch.setattr("gpstime.gpsnow", lambda: 1064)

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        duration=32,
    )

    # Create real WorkerContext
    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    # Simulate state where we have a pending frame
    pending_frame_data = {
        "L1:TEST": Mock(t0=1064, sample_rate=16384, array=np.zeros(32 * 16384))
    }

    context.state["initialized"] = True
    context.state["file_queue"] = queue.Queue()  # Empty queue
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1032  # Need to send gap from 1032->1064
    context.state["last_file_time"] = 1000
    context.state["rates"] = {"L1:TEST": 16384}
    context.state["buffer_duration"] = 32.0
    context.state["pending_frame"] = pending_frame_data  # Pending frame at 1064
    context.state["pending_file_time"] = 1064

    logger = logging.getLogger("test")

    # Call worker_process
    # This should:
    # 1. Enter Case 1 (pending_frame is not None)
    # 2. Calculate gap_end = 1032 + 32 = 1064
    # 3. Check gap_end >= pending_file_time (1064 >= 1064)
    # 4. Set frame_data = pending_frame
    # 5. Send both gap and pending data
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Should have sent 2 buffers: gap + pending data
    buffers = []
    while not output_queue.empty():
        buffers.append(output_queue.get())

    assert len(buffers) == 2, f"Should send gap + data, got {len(buffers)}"

    # First should be gap
    gap_buf = buffers[0][1]
    assert gap_buf.data is None, "First buffer should be gap"

    # Second should be data
    data_buf = buffers[1][1]
    assert data_buf.data is not None, "Second buffer should be data"

    # Pending frame should be cleared
    assert context.state["pending_frame"] is None, "Pending frame should be cleared"
    assert context.state["pending_file_time"] is None, "Pending time should be cleared"

    print("✓ Pending frame sent with final gap")


def test_file_error_without_rates_sends_heartbeat_unit(tmp_path, monkeypatch):
    """Test file error without rates initializes from sample, sends heartbeat."""
    # Mock gpstime
    monkeypatch.setattr("gpstime.gpsnow", lambda: 1000)

    # Write a sample file that can be used to initialize rates
    sample_channels = {"L1:TEST": np.random.randn(32 * 16384)}
    sample_path = tmp_path / "L1-SAMPLE-900-32.gwf"
    gwframe.write(str(sample_path), sample_channels, t0=900, sample_rate=16384.0)

    # Write a frame file then we'll delete it to trigger error
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        duration=32,
    )

    # Create real WorkerContext
    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    # Initialize state WITHOUT rates (not yet initialized)
    file_queue: queue.Queue = queue.Queue()
    file_queue.put((str(frame_path), 1000))

    context.state["initialized"] = True
    context.state["file_queue"] = file_queue
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1000
    context.state["last_file_time"] = None  # No previous file
    context.state["rates"] = {}  # No rates yet!
    context.state["buffer_duration"] = None  # No duration yet!
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    logger = logging.getLogger("test")

    # Delete the file so read fails
    frame_path.unlink()

    # Call worker_process - should fail to read file, but no rates yet
    # Should set action = WorkerAction.HEARTBEAT
    # Then initialize rates from sample file and send heartbeat
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Should have initialized rates from sample file
    assert context.state["rates"] == {"L1:TEST": 16384}, "Should have initialized rates"
    assert context.state["buffer_duration"] == 32.0, "Should have initialized duration"

    # Should have sent heartbeat buffer (zero-duration)
    assert not output_queue.empty(), "Should have sent heartbeat buffer"
    _pad, buf = output_queue.get()
    assert buf.data is None, "Heartbeat should be gap"
    assert buf.shape[0] == 0, "Heartbeat should have zero samples"

    print("✓ File error without rates initializes from sample and sends heartbeat")


def test_sample_rate_validation_error_unit(tmp_path, monkeypatch):
    """Test that sample rate mismatch raises validation error."""
    monkeypatch.setattr("gpstime.gpsnow", lambda: 1032)

    # Write initial frame with correct rate
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path1 = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path1), channels, t0=1000, sample_rate=16384.0)

    # Write second frame with WRONG sample rate
    channels_wrong = {"L1:TEST": np.random.randn(32 * 8192)}  # Half the rate
    frame_path2 = tmp_path / "L1-TEST-1032-32.gwf"
    gwframe.write(str(frame_path2), channels_wrong, t0=1032, sample_rate=8192.0)

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        duration=32,
    )

    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    # Process first frame to initialize rates
    file_queue: queue.Queue = queue.Queue()
    file_queue.put((str(frame_path1), 1000))

    context.state["initialized"] = True
    context.state["file_queue"] = file_queue
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1000
    context.state["last_file_time"] = None
    context.state["rates"] = {}
    context.state["buffer_duration"] = None
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    logger = logging.getLogger("test")

    # Process first frame - should initialize rates
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Now add mismatched frame
    context.state["file_queue"].put((str(frame_path2), 1032))
    context.state["next_buffer_time"] = 1032

    # Process second frame - should detect mismatch and send heartbeat
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Should have sent heartbeat due to validation error
    # First buffer is from successful first frame, skip it
    output_queue.get()
    # Second should be heartbeat from failed validation
    _pad, buf = output_queue.get()
    assert buf.data is None, "Should be heartbeat/gap"
    print("✓ Sample rate validation error handled")


def test_no_sample_files_raises_error_unit(tmp_path, monkeypatch):
    """Test that missing sample files with no rates raises RuntimeError."""
    monkeypatch.setattr("gpstime.gpsnow", lambda: 1000)

    # Create EMPTY directory (no sample files)
    empty_dir = tmp_path / "empty"
    empty_dir.mkdir()

    # Write a frame but delete it to trigger file error
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path = empty_dir / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    src = GWFrameWatchSource(
        name="test",
        channels=["L1:TEST"],
        watch_dir=str(empty_dir),
        duration=32,
    )

    output_queue: queue.Queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        worker_stop=None,
        worker_shutdown=None,
    )

    file_queue: queue.Queue = queue.Queue()
    file_queue.put((str(frame_path), 1000))

    context.state["initialized"] = True
    context.state["file_queue"] = file_queue
    context.state["observer"] = Mock()
    context.state["next_buffer_time"] = 1000
    context.state["last_file_time"] = None
    context.state["rates"] = {}  # No rates!
    context.state["buffer_duration"] = None
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    logger = logging.getLogger("test")

    # Delete file so read fails
    frame_path.unlink()

    # Should raise RuntimeError because no sample files exist to initialize rates
    with pytest.raises(RuntimeError, match="Cannot initialize GWFrameWatchSource"):
        src.worker_process(
            context=context,
            watch_dir=str(empty_dir),
            channels=["L1:TEST"],
            srcs=src.srcs,
            watch_suffix=".gwf",
            queue_timeout=0.1,
            discont_wait_time=60,
            start=1000,
            logger=logger,
        )

    print("✓ No sample files raises RuntimeError")


# =============================================================================
# Isolated Helper Function Tests
# =============================================================================


def test_read_and_validate_frame_file_not_exists(tmp_path):
    """Test that _read_and_validate_frame raises FileNotFoundError for missing file."""
    nonexistent_file = str(tmp_path / "nonexistent.gwf")

    # Should raise FileNotFoundError when file doesn't exist
    with pytest.raises(FileNotFoundError, match="File no longer exists"):
        _read_and_validate_frame(
            filepath=nonexistent_file,
            channels=["L1:TEST"],
            rates={},
            buffer_duration=None,
            file_time=1000,
        )

    print("✓ File not exists raises FileNotFoundError")


def test_read_and_validate_frame_file_deleted_after_queue(tmp_path):
    """Test file deletion between queuing and reading (race condition)."""
    # Write a frame file
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    # File exists at queue time
    assert frame_path.exists()

    # But gets deleted before read (simulating race condition)
    frame_path.unlink()

    # Should detect missing file and raise FileNotFoundError
    with pytest.raises(FileNotFoundError, match="File no longer exists"):
        _read_and_validate_frame(
            filepath=str(frame_path),
            channels=["L1:TEST"],
            rates={"L1:TEST": 16384},
            buffer_duration=32.0,
            file_time=1000,
        )

    print("✓ Race condition file deletion detected")


def test_read_and_validate_frame_sample_rate_mismatch(tmp_path):
    """Test that _read_and_validate_frame raises ValueError for sample rate mismatch."""
    # Write frame with 8192 Hz
    channels = {"L1:TEST": np.random.randn(32 * 8192)}
    frame_path = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=8192.0)

    # But expect 16384 Hz
    with pytest.raises(ValueError, match="Sample rate mismatch"):
        _read_and_validate_frame(
            filepath=str(frame_path),
            channels=["L1:TEST"],
            rates={"L1:TEST": 16384},  # Expect different rate
            buffer_duration=32.0,
            file_time=1000,
        )

    print("✓ Sample rate mismatch raises ValueError")


def test_read_and_validate_frame_duration_mismatch(tmp_path):
    """Test that _read_and_validate_frame raises ValueError for duration mismatch."""
    # Write frame with 16 second duration
    channels = {"L1:TEST": np.random.randn(16 * 16384)}
    frame_path = tmp_path / "L1-TEST-1000-16.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    # But expect 32 second duration
    with pytest.raises(ValueError, match="Duration mismatch"):
        _read_and_validate_frame(
            filepath=str(frame_path),
            channels=["L1:TEST"],
            rates={"L1:TEST": 16384},
            buffer_duration=32.0,  # Expect different duration
            file_time=1000,
        )

    print("✓ Duration mismatch raises ValueError")


def test_read_and_validate_frame_t0_mismatch(tmp_path):
    """Test that _read_and_validate_frame raises ValueError for t0 mismatch."""
    # Write frame with t0=1000
    channels = {"L1:TEST": np.random.randn(32 * 16384)}
    frame_path = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    # But filename suggests t0=2000
    with pytest.raises(ValueError, match="Time mismatch"):
        _read_and_validate_frame(
            filepath=str(frame_path),
            channels=["L1:TEST"],
            rates={"L1:TEST": 16384},
            buffer_duration=32.0,
            file_time=2000,  # Filename suggests different time
        )

    print("✓ t0 mismatch raises ValueError")


def test_initialize_rates_from_sample_no_files(tmp_path):
    """Test that _initialize_rates_from_sample returns None when no files exist."""
    logger = logging.getLogger("test")

    # Empty directory with no .gwf files
    result = _initialize_rates_from_sample(
        watch_dir=str(tmp_path),
        watch_suffix=".gwf",
        channels=["L1:TEST"],
        logger=logger,
    )

    assert result is None, "Should return None when no files exist"
    print("✓ No files returns None")


def test_initialize_rates_from_sample_read_error(tmp_path):
    """Test that _initialize_rates_from_sample handles missing channel error."""
    logger = logging.getLogger("test")

    # Write a valid frame but with DIFFERENT channel name
    channels = {"L1:DIFFERENT_CHANNEL": np.random.randn(32 * 16384)}
    frame_path = tmp_path / "L1-TEST-1000-32.gwf"
    gwframe.write(str(frame_path), channels, t0=1000, sample_rate=16384.0)

    # Try to read with channel that doesn't exist - catches KeyError, returns None
    result = _initialize_rates_from_sample(
        watch_dir=str(tmp_path),
        watch_suffix=".gwf",
        channels=["L1:TEST"],  # Channel not in file
        logger=logger,
    )

    assert result is None, "Should return None when channel not found in file"
    print("✓ Missing channel returns None")


def test_drain_and_get_file_discards_old_files():
    """Test that _drain_and_get_file discards files in the past (lines 410-415)."""
    logger = logging.getLogger("test")
    file_queue: queue.Queue = queue.Queue()

    # Queue multiple files, some in the past
    file_queue.put(("/path/to/file1.gwf", 1000))  # Old file
    file_queue.put(("/path/to/file2.gwf", 1032))  # Old file
    file_queue.put(("/path/to/file3.gwf", 1064))  # Current file

    # Expected next time is 1064, last file was at 1032
    next_buffer_time = 1064
    last_file_time = 1032

    # Should skip files 1 and 2, return file 3
    filepath, file_time = _drain_and_get_file(
        file_queue=file_queue,
        queue_timeout=0.1,
        next_buffer_time=next_buffer_time,
        last_file_time=last_file_time,
        logger=logger,
    )

    assert filepath == "/path/to/file3.gwf", "Should return current file"
    assert file_time == 1064, "Should return current time"
    print("✓ Old files discarded from queue")


def test_framewatch_with_explicit_start_time(tmp_path):
    """Test GWFrameWatchSource with explicit start time (not None)."""
    watch_dir = tmp_path / "frames"
    watch_dir.mkdir()

    # Create a frame file
    frame_file = watch_dir / "H1-TEST-1000000000-1.gwf"
    frame = gwframe.Frame(t0=1000000000, duration=1, name="H1")
    data = np.random.randn(16384)
    frame.add_channel("H1:TEST", data, sample_rate=16384)
    frame.write(str(frame_file))

    # Create source with explicit start time
    src = GWFrameWatchSource(
        name="test",
        channels=["H1:TEST"],
        watch_dir=str(watch_dir),
        start=1000000000,  # Explicit start time (not None)
        end=1000000002,
    )

    pipeline = Pipeline()
    sink = NullSink(name="sink", sink_pad_names=("H1:TEST",))
    pipeline.connect(src, sink)
    pipeline.run()


def test_framewatch_event_handler_directory_events(tmp_path):
    """Test event handler ignores directory events."""
    file_queue = queue.Queue()
    handler = _GWFrameFileEventHandler(file_queue, ".gwf", 1000000000)

    # Create mock directory event
    class MockEvent:
        def __init__(self, path, is_dir):
            self.is_directory = is_dir
            self.src_path = path
            self.dest_path = path

    # Directory events should be ignored
    dir_event = MockEvent(str(tmp_path / "somedir"), is_dir=True)
    handler.on_closed(dir_event)  # type: ignore[arg-type]
    handler.on_moved(dir_event)  # type: ignore[arg-type]

    # Queue should be empty (directory events ignored)
    assert file_queue.empty()


def test_framewatch_event_handler_wrong_extension(tmp_path):
    """Test event handler ignores files with wrong extension."""
    file_queue = queue.Queue()
    handler = _GWFrameFileEventHandler(file_queue, ".gwf", 1000000000)

    # Create file with wrong extension
    wrong_file = tmp_path / "H1-TEST-1000000001-1.txt"
    wrong_file.touch()

    class MockEvent:
        def __init__(self, path):
            self.is_directory = False
            self.src_path = str(path)
            self.dest_path = str(path)

    event = MockEvent(wrong_file)
    handler.on_closed(event)  # type: ignore[arg-type]

    # Queue should be empty (wrong extension ignored)
    assert file_queue.empty()


def test_framewatch_event_handler_old_files(tmp_path):
    """Test event handler ignores files before watermark."""
    file_queue = queue.Queue()
    watermark = 1000000100
    handler = _GWFrameFileEventHandler(file_queue, ".gwf", watermark)

    # Create file before watermark
    old_file = tmp_path / "H1-TEST-1000000050-1.gwf"
    old_file.touch()

    class MockEvent:
        def __init__(self, path):
            self.is_directory = False
            self.src_path = str(path)
            self.dest_path = str(path)

    event = MockEvent(old_file)
    handler.on_closed(event)  # type: ignore[arg-type]

    # Queue should be empty (old file ignored)
    assert file_queue.empty()


def test_framewatch_event_handler_file_moved(tmp_path):
    """Test event handler handles file move/rename events."""
    file_queue = queue.Queue()
    watermark = 1000000000
    handler = _GWFrameFileEventHandler(file_queue, ".gwf", watermark)

    # Create files
    src_file = tmp_path / "H1-TEST-1000000100-1.gwf.tmp"
    dest_file = tmp_path / "H1-TEST-1000000100-1.gwf"
    src_file.touch()

    class MockEvent:
        def __init__(self, src_path, dest_path):
            self.is_directory = False
            self.src_path = str(src_path)
            self.dest_path = str(dest_path)

    # Simulate file move (rename)
    event = MockEvent(src_file, dest_file)
    handler.on_moved(event)  # type: ignore[arg-type]

    # Queue should have the destination file
    assert not file_queue.empty()
    file_path, gps_time = file_queue.get()
    assert file_path == str(dest_file)
    assert gps_time == 1000000100


def test_framewatch_observer_cleanup_on_stop(tmp_path):
    """Test that observer is stopped and joined when context.should_stop() is True."""
    # Create frame file
    frame_path = tmp_path / "L1-TEST-1000000000-32.gwf"
    gwframe.write(
        str(frame_path),
        {"L1:TEST": np.random.randn(512)},
        t0=1000000000,
        sample_rate={"L1:TEST": 16},
    )

    src = GWFrameWatchSource(
        name="test_watch",
        channels=["L1:TEST"],
        watch_dir=str(tmp_path),
        start=1000,
        duration=1,
    )

    # Create mock context
    output_queue = queue.Queue()
    context = WorkerContext(
        output_queue=output_queue,
        srcs=src.srcs,
    )

    # Initialize state
    context.state["file_queue"] = queue.Queue()
    context.state["observer"] = None
    context.state["next_buffer_time"] = None
    context.state["last_file_time"] = None
    context.state["rates"] = None
    context.state["buffer_duration"] = None
    context.state["pending_frame"] = None
    context.state["pending_file_time"] = None

    logger = logging.getLogger("test")

    # Add frame to queue
    context.state["file_queue"].put((str(frame_path), 1000000000))

    # Process first to initialize rates and start observer
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Verify observer was created and is running
    observer = context.state.get("observer")
    assert observer is not None
    assert observer.is_alive()

    # Mock should_stop to return True
    context.should_stop = Mock(return_value=True)

    # Process again - should trigger observer cleanup
    src.worker_process(
        context=context,
        watch_dir=str(tmp_path),
        channels=["L1:TEST"],
        srcs=src.srcs,
        watch_suffix=".gwf",
        queue_timeout=0.1,
        discont_wait_time=60,
        start=1000,
        logger=logger,
    )

    # Observer should be stopped
    # Give it a moment to clean up
    time.sleep(0.1)
    assert not observer.is_alive()
