"""Test for the GWFrameSink class"""

import logging
import os
import pathlib
import time
from collections import deque
from tempfile import TemporaryDirectory
from unittest.mock import patch

import gwframe
import numpy as np
import pytest
from sgn.apps import Pipeline
from sgnts.base import Offset, SeriesBuffer, TSFrame
from sgnts.sources import FakeSeriesSource, TSIterSource
from sgnts.transforms import Resampler

from sgn_gwframe.sinks import GWFrameSink


def test_frame_sink():
    with pytest.raises(ValueError):
        GWFrameSink(
            name="snk",
            channels=(
                "H1:FOO-BAR",
                "L1:BAZ-QUX_0",
            ),
            duration=256,
            description="testing",
            path="",
        )


class TestGWFrameSink:
    """Test group for framesink class"""

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink(self):
        """Test the frame sink with two different rate sources


        The pipeline is as follows:
              --------------------        --------------------
              | FakeSeriesSource |        | FakeSeriesSource |
              --------------------        --------------------
                               |            |
                              ----------------
                              | FrameWriter  |
                              ----------------
        """

        pipeline = Pipeline()
        start = 0.0
        duration = 3  # seconds

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = path / (
                "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )
            out1 = path / "H1L1-testing-0000000003-3.gwf"
            out2 = path / "H1L1-testing-0000000000-3.gwf"

            # Verify the files do not exist
            assert not out1.exists()
            assert not out2.exists()

            # Run pipeline
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=2 * duration,
                    real_time=True,
                ),
                FakeSeriesSource(
                    name="src_L1",
                    source_pad_names=("L1",),
                    rate=512,
                    start=start,
                    end=2 * duration,
                    real_time=True,
                ),
                GWFrameSink(
                    name="snk",
                    channels=(
                        "H1:FOO-BAR",
                        "L1:BAZ-QUX_0",
                    ),
                    duration=duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                link_map={
                    "snk:snk:H1:FOO-BAR": "src_H1:src:H1",
                    "snk:snk:L1:BAZ-QUX_0": "src_L1:src:L1",
                },
            )
            pipeline.run()

            # Verify the files exist
            assert out1.exists()
            assert out2.exists()

    @pytest.mark.skip(reason="FakeSeriesSource crashes when end < requested duration")
    def test_frame_sink_not_enough_data(self):
        """Test GWFrameSink handles insufficient data gracefully.

        When the requested frame duration exceeds available data,
        GWFrameSink should skip writing and log a warning without crashing.

        Note: This test is skipped because FakeSeriesSource raises an error
        when trying to prepare frames that extend beyond its end time,
        preventing us from testing GWFrameSink's insufficient data handling.
        """

        pipeline = Pipeline()
        start = 0.0
        frame_duration = 3
        data_duration = 2

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = path / (
                "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )

            # Run pipeline
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=data_duration,
                    real_time=False,
                ),
                GWFrameSink(
                    name="snk",
                    channels=("H1:FOO-BAR",),
                    duration=frame_duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                link_map={
                    "snk:snk:H1:FOO-BAR": "src_H1:src:H1",
                },
            )
            pipeline.run()

            # No files should be written since we don't have enough data
            files = list(path.glob("*.gwf"))
            assert len(files) == 0, (
                f"Expected no files when data insufficient, found: {files}"
            )

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_path_exists_force(self):
        r"""Test the frame sink with two different rate sources


        The pipeline is as follows:
              --------------------        --------------------
              | FakeSeriesSource |        | FakeSeriesSource |
              --------------------        --------------------
                        |         \       /        |
                        |          \     /         |
                        |           \   /          |
                        |            \ /           |
                        |             \            |
                        |            / \           |
                        |           /   \          |
                      ----------------  ---------------
                      | FrameWriter  |  | FrameWriter |
                      ----------------  ---------------
        """

        pipeline = Pipeline()
        start = 0.0
        duration = 3  # seconds

        if True:  # with pytest.raises(FileExistsError):
            with TemporaryDirectory() as tmpdir:
                path = pathlib.Path(tmpdir)
                path_format = path / (
                    "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
                )
                out1 = path / "H1L1-testing-0000000003-3.gwf"
                out2 = path / "H1L1-testing-0000000000-3.gwf"

                # Verify the files do not exist
                assert not out1.exists()
                assert not out2.exists()

                # Run pipeline
                pipeline.insert(
                    FakeSeriesSource(
                        name="src_H1",
                        source_pad_names=("H1",),
                        rate=256,
                        start=start,
                        end=2 * duration,
                        real_time=True,
                    ),
                    FakeSeriesSource(
                        name="src_L1",
                        source_pad_names=("L1",),
                        rate=512,
                        start=start,
                        end=2 * duration,
                        real_time=True,
                    ),
                    GWFrameSink(
                        name="snk",
                        channels=(
                            "H1:FOO-BAR",
                            "L1:BAZ-QUX_0",
                        ),
                        duration=duration,
                        path=path_format.as_posix(),
                        description="testing",
                        force=True,
                    ),
                    GWFrameSink(
                        name="snk2",
                        channels=(
                            "H1:FOO-BAR",
                            "L1:BAZ-QUX_0",
                        ),
                        duration=duration,
                        path=path_format.as_posix(),
                        description="testing",
                        force=True,
                    ),
                    link_map={
                        "snk:snk:H1:FOO-BAR": "src_H1:src:H1",
                        "snk:snk:L1:BAZ-QUX_0": "src_L1:src:L1",
                        "snk2:snk:H1:FOO-BAR": "src_H1:src:H1",
                        "snk2:snk:L1:BAZ-QUX_0": "src_L1:src:L1",
                    },
                )
                pipeline.run()

                # Verify the files exist
                assert out1.exists()
                assert out2.exists()

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_path_exists(self):
        r"""Test the frame sink with two different rate sources


        The pipeline is as follows:
              --------------------        --------------------
              | FakeSeriesSource |        | FakeSeriesSource |
              --------------------        --------------------
                        |         \       /        |
                        |          \     /         |
                        |           \   /          |
                        |            \ /           |
                        |             \            |
                        |            / \           |
                        |           /   \          |
                      ----------------  ---------------
                      | FrameWriter  |  | FrameWriter |
                      ----------------  ---------------
        """

        pipeline = Pipeline()
        start = 0.0
        duration = 3  # seconds

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = path / (
                "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )
            out1 = path / "H1L1-testing-0000000003-3.gwf"
            out2 = path / "H1L1-testing-0000000000-3.gwf"

            # Verify the files do not exist
            assert not out1.exists()
            assert not out2.exists()

            # Run pipeline
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=2 * duration,
                    real_time=True,
                ),
                FakeSeriesSource(
                    name="src_L1",
                    source_pad_names=("L1",),
                    rate=512,
                    start=start,
                    end=2 * duration,
                    real_time=True,
                ),
                GWFrameSink(
                    name="snk",
                    channels=(
                        "H1:FOO-BAR",
                        "L1:BAZ-QUX_0",
                    ),
                    duration=duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                GWFrameSink(
                    name="snk2",
                    channels=(
                        "H1:FOO-BAR",
                        "L1:BAZ-QUX_0",
                    ),
                    duration=duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                link_map={
                    "snk:snk:H1:FOO-BAR": "src_H1:src:H1",
                    "snk:snk:L1:BAZ-QUX_0": "src_L1:src:L1",
                    "snk2:snk:H1:FOO-BAR": "src_H1:src:H1",
                    "snk2:snk:L1:BAZ-QUX_0": "src_L1:src:L1",
                },
            )

            with pytest.raises(FileExistsError):
                pipeline.run()

            # At least one file should have been written before the error
            assert out1.exists() or out2.exists()


class TestGWFrameSinkCoverage:
    """Additional tests for full coverage of GWFrameSink"""

    def test_frame_sink_circular_buffer_cleanup(self, tmp_path):
        """Test the circular buffer cleanup functionality directly"""
        # Create a minimal GWFrameSink instance
        sink = GWFrameSink(
            name="test_sink",
            channels=["H1:TEST"],
            duration=10,
            max_files=2,
        )

        # Manually populate the file cache with 4 files
        sink._file_cache = deque(
            [
                str(tmp_path / "H1-TEST-1234567700-10.gwf"),  # oldest - delete
                str(tmp_path / "H1-TEST-1234567750-10.gwf"),  # second oldest - delete
                str(tmp_path / "H1-TEST-1234567785-10.gwf"),  # third - keep
                str(tmp_path / "H1-TEST-1234567850-10.gwf"),  # newest - keep
            ]
        )

        # Mock Path.unlink to track deletions
        with patch("pathlib.Path.unlink") as mock_unlink:
            # Call cleanup with max_files=2
            deleted_count = sink._cleanup_files()

            # Should delete 2 oldest files to maintain max_files=2
            assert deleted_count == 2
            assert mock_unlink.call_count == 2
            # Should keep 2 newest files in cache
            assert len(sink._file_cache) == 2
            assert list(sink._file_cache) == [
                str(tmp_path / "H1-TEST-1234567785-10.gwf"),
                str(tmp_path / "H1-TEST-1234567850-10.gwf"),
            ]

    def test_frame_sink_circular_buffer_error_handling(self, tmp_path):
        """Test circular buffer cleanup error handling"""
        # Create a minimal GWFrameSink instance
        sink = GWFrameSink(
            name="test_sink",
            channels=["H1:TEST"],
            duration=10,
            max_files=1,
        )

        # Populate cache with 2 files (one will be deleted)
        sink._file_cache = deque(
            [
                str(tmp_path / "H1-TEST-1234567700-10.gwf"),
                str(tmp_path / "H1-TEST-1234567750-10.gwf"),
            ]
        )

        # Mock Path.unlink to raise an exception
        with patch("pathlib.Path.unlink") as mock_unlink:
            mock_unlink.side_effect = OSError("Permission denied")

            # Should not raise, just log exception
            deleted_count = sink._cleanup_files()

            # File should be removed from cache even if deletion failed
            assert len(sink._file_cache) == 1
            assert list(sink._file_cache) == [
                str(tmp_path / "H1-TEST-1234567750-10.gwf")
            ]
            # deleted_count reflects successful deletions (0 when deletion fails)
            assert deleted_count == 0

    def test_frame_sink_hdf5_output(self):
        """Test frame sink with HDF5 output format"""
        pipeline = Pipeline()
        start = 0.0
        duration = 1

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = path / (
                "{instruments}-{description}-{gps_start_time}-{duration}.hdf5"
            )
            out = path / "H1-testing-0000000000-1.hdf5"

            # Verify the file does not exist
            assert not out.exists()

            # Run pipeline
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=duration,
                ),
                GWFrameSink(
                    name="snk",
                    channels=("H1:TEST",),
                    duration=duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                link_map={
                    "snk:snk:H1:TEST": "src_H1:src:H1",
                },
            )
            pipeline.run()

            # Verify the file exists
            assert out.exists()

    def test_frame_sink_invalid_duration(self):
        """Test invalid duration values"""
        # Test zero duration
        with pytest.raises(ValueError, match="Duration must be an positive integer"):
            GWFrameSink(
                name="test",
                channels=("H1:TEST",),
                duration=0,
            )

        # Test negative duration
        with pytest.raises(ValueError, match="Duration must be an positive integer"):
            GWFrameSink(
                name="test",
                channels=("H1:TEST",),
                duration=-1,
            )

    @pytest.mark.parametrize(
        ("path", "missing_param"),
        [
            ("test-{description}-{gps_start_time}-{duration}.gwf", "instruments"),
            ("{instruments}-test-{gps_start_time}-{duration}.gwf", "description"),
            ("{instruments}-{description}-test-{duration}.gwf", "gps_start_time"),
            ("{instruments}-{description}-{gps_start_time}-test.gwf", "duration"),
        ],
    )
    def test_frame_sink_missing_path_params(self, path, missing_param):
        """Test missing required path parameters"""
        with pytest.raises(ValueError, match="Path must contain parameter"):
            GWFrameSink(
                name="test",
                channels=("H1:TEST",),
                duration=1,
                path=path,
            )

    def test_frame_sink_circular_buffer_disabled(self):
        """Test that cleanup returns early when max_files is None or <= 0"""
        # Test with cache of 3 files and no max_files limit
        sink = GWFrameSink(
            name="test_sink",
            channels=["H1:TEST"],
            duration=10,
            max_files=None,
        )
        sink._file_cache = deque(["file1.gwf", "file2.gwf", "file3.gwf"])

        # Should return 0 deleted files
        deleted_count = sink._cleanup_files()
        assert deleted_count == 0
        assert len(sink._file_cache) == 3

        # Test with max_files=0
        sink.max_files = 0
        deleted_count = sink._cleanup_files()
        assert deleted_count == 0
        assert len(sink._file_cache) == 3

        # Test with max_files=-10
        sink.max_files = -10
        deleted_count = sink._cleanup_files()
        assert deleted_count == 0
        assert len(sink._file_cache) == 3

    def test_frame_sink_no_cleanup_when_under_limit(self, tmp_path):
        """Test that cleanup does nothing when file count is under the limit"""
        # Create a minimal GWFrameSink instance
        sink = GWFrameSink(
            name="test_sink",
            channels=["H1:TEST"],
            duration=10,
            max_files=5,
        )

        # Populate cache with only 3 files (under the limit of 5)
        sink._file_cache = deque(
            [
                str(tmp_path / "H1-TEST-1.gwf"),
                str(tmp_path / "H1-TEST-2.gwf"),
                str(tmp_path / "H1-TEST-3.gwf"),
            ]
        )

        with patch("pathlib.Path.unlink") as mock_unlink:
            # Call cleanup with max_files=5 (cache has 3 files, under limit)
            deleted_count = sink._cleanup_files()

            # Should not delete anything since we're under the limit
            assert deleted_count == 0
            mock_unlink.assert_not_called()
            # Cache should remain unchanged
            assert len(sink._file_cache) == 3

    def test_frame_sink_subdir_seconds(self):
        """Test GPS-based subdirectory organization"""
        pipeline = Pipeline()
        start = 0.0
        duration = 1

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = (
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )

            # Run pipeline with subdir_seconds=100000
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=duration,
                ),
                GWFrameSink(
                    name="snk",
                    channels=("H1:TEST",),
                    duration=duration,
                    path=path_format.as_posix(),
                    description="testing",
                    subdir_seconds=100000,
                ),
                link_map={
                    "snk:snk:H1:TEST": "src_H1:src:H1",
                },
            )
            pipeline.run()

            # GPS time 0 with subdir_seconds=100000 should create subdirectory 0/
            expected_file = path / "0" / "H1-testing-0000000000-1.gwf"
            assert expected_file.exists(), f"Expected file at {expected_file}"

    def test_frame_sink_frame_duration_validation(self):
        """Test frame_duration validation"""
        # Test negative frame_duration
        with pytest.raises(ValueError, match="frame_duration must be non-negative"):
            GWFrameSink(
                name="test",
                channels=("H1:TEST",),
                duration=1,
                frame_duration=-1,
            )

        # Test frame_duration not a multiple of duration
        with pytest.raises(
            ValueError,
            match=r"frame_duration .* must be an integer multiple of duration",
        ):
            GWFrameSink(
                name="test",
                channels=("H1:TEST",),
                duration=3,
                frame_duration=10,  # 10 is not a multiple of 3
            )

    def test_frame_sink_multi_frame(self):
        """Test multi-frame file writing"""
        pipeline = Pipeline()
        start = 0.0
        frame_duration = 1
        file_duration = 3  # 3 frames per file

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = (
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )

            # Run pipeline with frame_duration=3 (3 frames per file)
            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=256,
                    start=start,
                    end=file_duration * 2,  # Generate 6 seconds of data
                ),
                GWFrameSink(
                    name="snk",
                    channels=("H1:TEST",),
                    duration=frame_duration,
                    frame_duration=file_duration,
                    path=path_format.as_posix(),
                    description="testing",
                ),
                link_map={
                    "snk:snk:H1:TEST": "src_H1:src:H1",
                },
            )
            pipeline.run()

            # Should create 2 files with duration=3 each
            expected_files = [
                path / "H1-testing-0000000000-3.gwf",
                path / "H1-testing-0000000003-3.gwf",
            ]
            for f in expected_files:
                assert f.exists(), f"Expected multi-frame file at {f}"

    def test_frame_sink_with_misaligned_resampler(self):
        """Test GWFrameSink with misaligned input from Resampler.

        Pipeline: FakeSeriesSource -> Resampler -> GWFrameSink

        The Resampler introduces offset misalignment due to FIR filter latency.
        Without automatic alignment in AdapterConfig, this should cause an
        assertion error when GWFrameSink tries to verify integer second boundaries.
        """
        pipeline = Pipeline()
        start = 0.0
        inrate = 16384
        outrate = 2048

        # 2 frame files, extra 1 second padding to handle resampler offset
        frame_duration = 3  # seconds
        total_duration = (2 * frame_duration) + 1

        with TemporaryDirectory() as tmpdir:
            path = pathlib.Path(tmpdir)
            path_format = (
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            )

            pipeline.insert(
                FakeSeriesSource(
                    name="src_H1",
                    source_pad_names=("H1",),
                    rate=inrate,
                    start=start,
                    end=total_duration,
                ),
                Resampler(
                    name="resample",
                    sink_pad_names=("H1",),
                    source_pad_names=("H1",),
                    inrate=inrate,
                    outrate=outrate,
                ),
                GWFrameSink(
                    name="snk",
                    channels=("H1:FOO-BAR",),
                    duration=frame_duration,
                    path=path_format.as_posix(),
                    description="RESAMPLE_TEST",
                ),
                link_map={
                    "resample:snk:H1": "src_H1:src:H1",
                    "snk:snk:H1:FOO-BAR": "resample:src:H1",
                },
            )

            pipeline.run()

            # Verify both frames were written
            files = list(path.glob("*.gwf"))
            expected_files = [
                path / "H1-RESAMPLE_TEST-0000000000-3.gwf",
                path / "H1-RESAMPLE_TEST-0000000003-3.gwf",
            ]
            assert len(files) == 2, (
                f"Expected 2 frames, got {len(files)}: {[f.name for f in files]}"
            )
            assert all(f.exists() for f in expected_files), "Missing expected frames"


class TestGWFrameSinkAdvancedFeatures:
    """Test advanced GWFrameSink features for production readiness."""

    def test_frame_sink_time_based_retention(self, tmp_path):
        """Test time-based retention deletes old files based on mtime."""
        path = tmp_path / "frames"
        path.mkdir()

        # Create test files with different ages
        file1 = path / "H1-TEST-0000000000-2.gwf"
        file2 = path / "H1-TEST-0000000002-2.gwf"
        file3 = path / "H1-TEST-0000000004-2.gwf"

        # Create the files
        for f in [file1, file2, file3]:
            f.touch()

        # Create a sink with retention_time
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            retention_time=1.5,  # Keep files for 1.5 seconds
        )

        # Manually populate the file cache
        snk._file_cache = deque([str(file1), str(file2), str(file3)])

        # Age file1 to be 2 seconds old (beyond retention_time)
        current_time = time.time()
        old_mtime = current_time - 2.0
        os.utime(file1, (old_mtime, old_mtime))

        # Age file2 to be 1 second old (within retention_time)
        recent_mtime = current_time - 1.0
        os.utime(file2, (recent_mtime, recent_mtime))

        # file3 has current mtime (just created)

        # Call cleanup - should delete file1 only
        deleted_count = snk._cleanup_files()

        assert deleted_count == 1, "Should have deleted 1 file"
        assert not file1.exists(), "Old file should be deleted"
        assert file2.exists(), "Recent file should still exist"
        assert file3.exists(), "Current file should still exist"
        assert len(snk._file_cache) == 2, "Cache should have 2 files remaining"

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_custom_tmpdir(self, tmp_path):
        """Test atomic writes using custom tmpdir."""
        output_dir = tmp_path / "output"
        temp_dir = tmp_path / "temp"
        output_dir.mkdir()
        temp_dir.mkdir()

        pipeline = Pipeline()
        src = FakeSeriesSource(
            name="src",
            source_pad_names=("H1",),
            rate=16,
            start=0.0,
            end=2.0,
        )
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            path=str(
                output_dir
                / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            ),
            description="TMPDIR_TEST",
            tmpdir=str(temp_dir),
        )
        pipeline.connect(src, snk)
        pipeline.run()

        # Verify file was written to output_dir
        files = list(output_dir.glob("*.gwf"))
        assert len(files) == 1, "Frame file should exist in output directory"

        # Verify no temp files left in temp_dir
        temp_files = list(temp_dir.glob("*.tmp"))
        assert len(temp_files) == 0, "No temp files should remain after completion"

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_frame_history_metadata(self, tmp_path):
        """Test that frame history metadata is added to frames."""

        path = tmp_path / "frames"
        path.mkdir()

        pipeline = Pipeline()
        src = FakeSeriesSource(
            name="src",
            source_pad_names=("H1",),
            rate=16,
            start=0.0,
            end=2.0,
        )
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            path=str(
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            ),
            description="HISTORY_TEST",
            frame_history={
                "creator": "sgn-gwframe test suite",
                "version": "1.0.0",
            },
        )
        pipeline.connect(src, snk)
        pipeline.run()

        # Read the frame and verify history was added
        files = list(path.glob("*.gwf"))
        assert len(files) == 1

        # Verify file was created successfully
        # Note: Full history verification would require reading raw frame metadata
        assert files[0].exists()

        # Verify we can read the frame back
        frame = gwframe.read(str(files[0]), channel=["H1"])
        assert "H1" in frame

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_file_deletion_error_handling(self, tmp_path, caplog):
        """Test error handling when file deletion fails."""
        path = tmp_path / "frames"
        path.mkdir()

        pipeline = Pipeline()
        src = FakeSeriesSource(
            name="src",
            source_pad_names=("H1",),
            rate=16,
            start=0.0,
            end=4.0,
        )
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            path=str(
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            ),
            description="ERROR_TEST",
            max_files=1,  # Will trigger deletion
        )

        # Mock unlink to raise OSError
        with (
            patch("pathlib.Path.unlink", side_effect=OSError("Permission denied")),
            caplog.at_level(logging.ERROR),
        ):
            pipeline.connect(src, snk)
            pipeline.run()

        # Should log error about file deletion
        assert "error deleting file" in caplog.text.lower()

    @pytest.mark.freeze_time("1980-01-06 00:00:00", auto_tick_seconds=0.5)
    def test_frame_sink_combined_retention_policies(self, tmp_path):
        """Test using both max_files and retention_time together."""
        path = tmp_path / "frames"
        path.mkdir()

        pipeline = Pipeline()
        src = FakeSeriesSource(
            name="src",
            source_pad_names=("H1",),
            rate=16,
            start=0.0,
            end=8.0,
        )
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            path=str(
                path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
            ),
            description="COMBINED_TEST",
            max_files=3,  # Keep max 3 files
            retention_time=1.0,  # Also enforce time-based retention
        )
        pipeline.connect(src, snk)
        pipeline.run()

        # Should have 3 files (max_files enforced during write)
        files = sorted(path.glob("*.gwf"))
        assert len(files) == 3


class TestGWFrameSinkErrorCoverage:
    """Tests for GWFrameSink error handling and edge cases."""

    def test_frame_sink_file_already_deleted_time_based(self, tmp_path):
        """Test time-based retention when file is already deleted."""
        path = tmp_path / "frames"
        path.mkdir()

        # Create test files
        file1 = path / "H1-TEST-0000000000-2.gwf"
        file2 = path / "H1-TEST-0000000002-2.gwf"

        file1.touch()
        file2.touch()

        # Create sink with retention_time
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            retention_time=1.0,
        )

        # Populate cache
        snk._file_cache = deque([str(file1), str(file2)])

        # Age file1 and then delete it before cleanup runs
        current_time = time.time()
        old_mtime = current_time - 2.0
        os.utime(file1, (old_mtime, old_mtime))
        file1.unlink()  # Delete before cleanup

        # Cleanup should handle missing file gracefully
        deleted_count = snk._cleanup_files()

        # Should still count it as deleted and remove from cache
        assert deleted_count == 1
        assert len(snk._file_cache) == 1

    def test_frame_sink_file_already_deleted_count_based(self, tmp_path):
        """Test count-based retention when file is already deleted."""
        path = tmp_path / "frames"
        path.mkdir()

        # Create test files
        file1 = path / "H1-TEST-0000000000-2.gwf"
        file2 = path / "H1-TEST-0000000002-2.gwf"
        file3 = path / "H1-TEST-0000000004-2.gwf"

        file1.touch()
        file2.touch()
        file3.touch()

        # Create sink with max_files
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            max_files=2,
        )

        # Populate cache with 3 files
        snk._file_cache = deque([str(file1), str(file2), str(file3)])

        # Delete file1 before cleanup runs
        file1.unlink()

        # Cleanup should handle missing file gracefully
        snk._cleanup_files()

        # Should still remove from cache even though unlink fails
        assert len(snk._file_cache) == 2

    def test_frame_sink_oserror_during_delete(self, tmp_path, caplog):
        """Test OSError handling during file deletion."""
        import logging

        path = tmp_path / "frames"
        path.mkdir()

        file1 = path / "H1-TEST-0000000000-2.gwf"
        file1.touch()

        # Create sink
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            retention_time=1.0,
        )

        # Populate cache
        snk._file_cache = deque([str(file1)])

        # Age the file
        current_time = time.time()
        old_mtime = current_time - 2.0
        os.utime(file1, (old_mtime, old_mtime))

        # Mock unlink to raise OSError (filesystem permissions don't work as root)
        with (
            patch.object(
                pathlib.Path, "unlink", side_effect=OSError("Permission denied")
            ),
            caplog.at_level(logging.ERROR),
        ):
            snk._cleanup_files()

        # Should log error about deletion failure
        assert any(
            "error deleting file" in rec.message.lower() for rec in caplog.records
        )

    def test_frame_sink_clean_old_frames_utility(self, tmp_path):
        """Test _clean_old_frames utility method."""
        frame_dir = tmp_path / "frames"
        frame_dir.mkdir()

        # Create some old frame files
        old_file = frame_dir / "old.gwf"
        new_file = frame_dir / "new.gwf"
        other_file = frame_dir / "data.txt"  # Non-gwf file

        old_file.touch()
        new_file.touch()
        other_file.touch()

        # Age the old file
        old_mtime = time.time() - 10.0
        os.utime(old_file, (old_mtime, old_mtime))

        # Create sink
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
        )

        # Call _clean_old_frames with 5 second retention
        snk._clean_old_frames(frame_dir, retention_time=5.0)

        # Old gwf file should be deleted
        assert not old_file.exists()

        # New gwf file should still exist
        assert new_file.exists()

        # Non-gwf file should be untouched
        assert other_file.exists()

    def test_frame_sink_clean_old_frames_no_retention(self, tmp_path):
        """Test _clean_old_frames with retention_time=0 (no-op)."""
        frame_dir = tmp_path / "frames"
        frame_dir.mkdir()

        old_file = frame_dir / "old.gwf"
        old_file.touch()

        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
        )

        # Should do nothing when retention_time is 0
        snk._clean_old_frames(frame_dir, retention_time=0)

        # File should still exist
        assert old_file.exists()

    def test_frame_sink_clean_old_frames_nonexistent_dir(self, tmp_path):
        """Test _clean_old_frames with nonexistent directory."""
        nonexistent_dir = tmp_path / "does_not_exist"

        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
        )

        # Should handle gracefully without error
        snk._clean_old_frames(nonexistent_dir, retention_time=5.0)

    def test_frame_sink_file_deleted_during_cleanup(self, tmp_path):
        """Test file being deleted between stat check and unlink."""
        from pathlib import Path
        from unittest.mock import patch

        path = tmp_path / "frames"
        path.mkdir()

        file1 = path / "H1-TEST-0000000000-2.gwf"
        file1.touch()

        # Create sink
        snk = GWFrameSink(
            name="snk",
            channels=["H1"],
            duration=2,
            retention_time=1.0,
        )

        # Populate cache
        snk._file_cache = deque([str(file1)])

        # Age the file
        current_time = time.time()
        old_mtime = current_time - 2.0
        os.utime(file1, (old_mtime, old_mtime))

        # Mock unlink to raise FileNotFoundError (file deleted between check and unlink)
        original_unlink = Path.unlink

        def mock_unlink(self, *args, **kwargs):
            if "H1-TEST" in str(self):
                msg = f"File deleted: {self}"
                raise FileNotFoundError(msg)
            return original_unlink(self, *args, **kwargs)

        with patch.object(Path, "unlink", mock_unlink):
            # Should handle gracefully (FileNotFoundError in unlink is caught)
            deleted_count = snk._cleanup_files()

        # Should still count as deleted and remove from cache
        assert deleted_count == 1
        assert len(snk._file_cache) == 0

    @pytest.mark.skip(
        reason="sgn-ts does not correctly flush partial data at EOS currently"
    )
    def test_frame_sink_insufficient_data_warning(self, tmp_path, caplog):
        """Test warning when frame has insufficient data for configured duration."""
        path = tmp_path / "frames"
        path.mkdir()

        # Create frames: two full 5-second frames, then one partial 2.5-second frame
        frames = [
            # Frame 1: 0-5 seconds, 80 samples at 16 Hz
            TSFrame(
                buffers=[
                    SeriesBuffer(
                        offset=Offset.fromsec(0.0),
                        sample_rate=16,
                        data=np.random.randn(80),
                    )
                ],
            ),
            # Frame 2: 5-10 seconds, 80 samples at 16 Hz
            TSFrame(
                buffers=[
                    SeriesBuffer(
                        offset=Offset.fromsec(5.0),
                        sample_rate=16,
                        data=np.random.randn(80),
                    )
                ],
            ),
            # Frame 3: 10-12.5 seconds, only 40 samples (partial frame!)
            TSFrame(
                buffers=[
                    SeriesBuffer(
                        offset=Offset.fromsec(10.0),
                        sample_rate=16,
                        data=np.random.randn(40),  # Only 2.5 seconds instead of 5
                    )
                ],
            ),
        ]

        pipeline = Pipeline()

        pipeline.insert(
            TSIterSource(
                name="src",
                source_pad_names=("src",),
                frames=frames,
            ),
            GWFrameSink(
                name="snk",
                channels=("H1:FOO-BAR",),
                path=str(
                    path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
                ),
                duration=5,  # Expects 5-second frames (80 samples at 16 Hz)
                description="PARTIAL_TEST",
            ),
            link_map={
                "snk:snk:H1:FOO-BAR": "src:src:src",
            },
        )

        with caplog.at_level(logging.WARNING):
            pipeline.run()

        # Should log warning about insufficient data
        assert any(
            "does not contain enough samples for duration" in record.message
            for record in caplog.records
        ), "Expected warning about insufficient data"

        # Should only write the 2 complete frames (0-5s, 5-10s)
        # The partial frame (10-12.5s) should be skipped
        files = list(path.glob("*.gwf"))
        assert len(files) == 2, (
            f"Expected 2 frames (partial frame skipped), got {len(files)}"
        )

    @pytest.mark.skip(
        reason="FakeSeriesSource doesn't handle end-of-stream gracefully yet"
    )
    def test_frame_sink_insufficient_data_with_fakesource(self, tmp_path, caplog):
        """Test warning when frame has insufficient data for configured duration."""
        path = tmp_path / "frames"
        path.mkdir()

        # Create a source that will produce a partial final frame
        # duration=5 means we expect 5-second frames, but we'll end at 12.5 seconds
        # This should produce frames at: 0-5s, 5-10s, 10-12.5s
        # The last frame only has 2.5 seconds, which is < 5 seconds
        pipeline = Pipeline()

        pipeline.insert(
            FakeSeriesSource(
                name="src",
                source_pad_names=("H1",),
                rate=16,  # 16 Hz
                start=0.0,
                end=12.5,  # Ends with partial frame
            ),
            GWFrameSink(
                name="snk",
                channels=("H1:FOO-BAR",),
                path=str(
                    path / "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
                ),
                duration=5,  # Expects 5-second frames
                description="PARTIAL_TEST",
            ),
            link_map={
                "snk:snk:H1:FOO-BAR": "src:src:H1",
            },
        )

        with caplog.at_level(logging.WARNING):
            pipeline.run()

        # Should log warning about insufficient data
        assert any(
            "does not contain enough samples for duration" in record.message
            for record in caplog.records
        ), "Expected warning about insufficient data"

        # Should only write the 2 complete frames (0-5s, 5-10s)
        # The partial frame (10-12.5s) should be skipped
        files = list(path.glob("*.gwf"))
        assert len(files) == 2, (
            f"Expected 2 frames (partial frame skipped), got {len(files)}"
        )
