"""This module contains the GWFrameSink class.

Writes time series data to .gwf files.
"""

from __future__ import annotations

import shutil
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Sequence

import gwframe
from sgnts.base import Offset, TSFrame, TSSink

if TYPE_CHECKING:
    from sgn.base import SinkPad

# filename format parameters
FILENAME_PARAMS = (
    "instruments",
    "description",
    "gps_start_time",
    "duration",
)


@dataclass(kw_only=True)
class GWFrameSink(TSSink):
    """A sink element that writes time series data to file

    Args:
        channels:
            Sequence[str], the instruments to write to the file
        duration:
            int, the duration of the data to write to the file
        path:
            str, the path to write the frame files to.  The file name
            must contain the following format parameters (in curly braces):
            - {instruments}, the sorted list of instruments inferred from
                the included channel names (e.g. "H1" for "H1:GDS-CAL...")
            - {description}, the description string for the frame
            - {gps_start_time}, the start time of the data in GPS seconds
            - {duration}, the duration of the data in seconds
            The extension on the the path determines the output file
            type.  Currently ".gwf" and ".hdf5" are supported.
            default: "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
        force:
            bool, whether to overwrite existing files. Default: False
        description:
            str, description string to include in the filename. Default: "SGN"
        max_files:
            int or None, when set to a positive value, enables circular buffer
            mode that keeps only the N most recent frame files. Older files
            are automatically deleted. Default: None (disabled)
        retention_time:
            float or None, retention time in seconds. Files older than this
            will be automatically deleted. Can be combined with max_files.
            Default: None (keep forever)
        subdir_seconds:
            int, organize files into subdirectories based on time buckets.
            Files are grouped by GPS time divided by this value. For example,
            with subdir_seconds=100000, GPS time 1234567890 goes into
            subdirectory 12345/. When 0, files are written to a flat directory
            structure. Default: 0 (flat structure)
        frame_name:
            str or None, name field for the frame metadata. If None, inferred
            from the instrument list (e.g., "H1L1"). Default: None
        frame_history:
            dict mapping history entry names to comments. These are added as
            frame history metadata. Default: empty dict
        frame_duration:
            int, total duration in seconds for multi-frame files. Must be an
            integer multiple of duration. When 0 or equal to duration, writes
            one frame per file. When greater than duration, multiple frames
            are written to a single file. For example, with duration=1 and
            frame_duration=16, the file contains 16 one-second frames and has
            a total duration of 16 seconds. Default: 0 (single frame per file)
        tmpdir:
            str or None, directory for temporary files during atomic writes.
            If None, uses same directory as output. Default: None
        compression:
            int, compression scheme for frame files.
            Default: gwframe.Compression.ZERO_SUPPRESS_OTHERWISE_GZIP
        compression_level:
            int, compression level 0-9. Default: 6

    """

    channels: Sequence[str]
    duration: int
    path: str = "{instruments}-{description}-{gps_start_time}-{duration}.gwf"
    force: bool = False
    description: str = "SGN"
    max_files: Optional[int] = None
    retention_time: Optional[float] = None
    subdir_seconds: int = 0
    frame_name: Optional[str] = None
    frame_history: dict[str, str] = field(default_factory=dict)
    frame_duration: int = 0
    tmpdir: Optional[str] = None
    compression: int = gwframe.Compression.ZERO_SUPPRESS_OTHERWISE_GZIP
    compression_level: int = 6

    @property
    def static_sink_pads(self) -> list[str]:  # type: ignore[override]
        """Return channels as static sink pads."""
        return list(self.channels)

    def configure(self) -> None:
        """Configure the GWFrameSink."""
        # setup the adapter config for the audioadapter
        # ensure data is aligned to second boundaries
        stride = Offset.fromsec(self.duration)
        self.adapter_config.alignment(stride=stride, align_to=Offset.fromsec(1))

        self._instruments_str: str = "".join(
            sorted({chan.split(":")[0] for chan in self.channels})
        )

        # Initialize circular buffer tracking
        # Cache of created files: deque of filepaths in order of creation
        self._file_cache: deque[str] = deque()

        # Multi-frame file tracking
        self._current_writer: gwframe.FrameWriter | None = None
        self._current_file_path: Path | None = None
        self._temp_file_path: Path | None = None
        self._current_file_start: int | None = None
        self._file_duration: int = 0

    def validate(self) -> None:
        """Validate the GWFrameSink configuration."""
        # Check valid duration
        if not isinstance(self.duration, int) or self.duration <= 0:
            msg = f"Duration must be an positive integer, got {self.duration}"
            raise ValueError(msg)

        # Check frame_duration is valid
        if self.frame_duration < 0:
            msg = f"frame_duration must be non-negative, got {self.frame_duration}"
            raise ValueError(msg)
        if self.frame_duration > 0 and self.frame_duration % self.duration != 0:
            msg = (
                f"frame_duration ({self.frame_duration}) must be an integer "
                f"multiple of duration ({self.duration})"
            )
            raise ValueError(msg)

        # Check path contains parameters for duration and gps_start_time
        for param in FILENAME_PARAMS:
            if f"{{{param}}}" not in self.path:
                msg = f"Path must contain parameter {{{param}}}"
                raise ValueError(msg)

    def _get_file_path(self, start: int, file_duration: int) -> Path:
        """Generate file path for given start time and duration.

        Args:
            start: GPS start time (integer seconds)
            file_duration: Total file duration (integer seconds)

        Returns:
            Path object for the output file
        """
        # Format the base filename
        filename = self.path.format(
            instruments=self._instruments_str,
            gps_start_time=f"{start:0=10.0f}",
            duration=file_duration,
            description=self.description,
        )
        base_path = Path(filename)

        # Organize into subdirectories if enabled
        if self.subdir_seconds > 0:
            subdir_name = str(start // self.subdir_seconds)
            outpath = base_path.parent / subdir_name / base_path.name
            outpath.parent.mkdir(parents=True, exist_ok=True)
        else:
            outpath = base_path

        return outpath

    def _remove_old_file(
        self, file_path: str | Path, current_time: float, retention_time: float
    ) -> bool:
        """Remove a file if it meets criteria for removal according to retention.

        Args:
            file_path: Path to file to check
            current_time: Current time in seconds (from time.time())
            retention_time: Retention time in seconds

        Returns:
            True if file was old and removed (or didn't exist), False otherwise
        """
        file_path = Path(file_path)

        try:
            mod_time = file_path.stat().st_mtime
        except FileNotFoundError:
            return True

        is_old = (current_time - mod_time) > retention_time
        if is_old:
            try:
                file_path.unlink()
                self.logger.debug("Removed old frame file: %s", file_path)
            except FileNotFoundError:
                pass
            except OSError:
                self.logger.exception("Error deleting file %s", file_path)

        return is_old

    def _cleanup_files(self) -> int:
        """Clean up old files according to count and/or time-based retention policies.

        This function handles both count-based (keep only N most recent files) and
        time-based (delete files older than X seconds) retention policies.

        Returns:
            Number of files deleted
        """
        deleted_count = 0
        current_time = time.time()

        # Time-based retention: remove old files from front of deque
        if self.retention_time:
            while self._file_cache and self._remove_old_file(
                self._file_cache[0], current_time, self.retention_time
            ):
                self._file_cache.popleft()
                deleted_count += 1

        # Count-based retention: remove excess files from front of deque
        if self.max_files and self.max_files > 0:
            while len(self._file_cache) > self.max_files:
                filepath = self._file_cache.popleft()
                try:
                    Path(filepath).unlink()
                    deleted_count += 1
                    self.logger.debug("Deleted old frame file: %s", filepath)
                except FileNotFoundError:
                    pass
                except OSError:
                    self.logger.exception("Error deleting file %s", filepath)

        return deleted_count

    def _clean_old_frames(self, frame_dir: str | Path, retention_time: float) -> None:
        """Remove frames in directory older than retention time specified.

        This is useful for cleaning up untracked files that may have been
        created by other processes or left over from crashes.

        Args:
            frame_dir: Directory containing frame files
            retention_time: Retention time in seconds
        """
        if not retention_time:
            return

        frame_dir = Path(frame_dir)
        if not frame_dir.exists():
            return

        current_time = time.time()
        for entry in frame_dir.iterdir():
            if entry.suffix == ".gwf":
                self._remove_old_file(entry, current_time, retention_time)

    def _close_current_file(self) -> None:
        """Close the current file and perform cleanup."""
        if self._current_writer is not None:
            self._current_writer.__exit__(None, None, None)
            self._current_writer = None

            # Move temp file to final location atomically
            if self._temp_file_path and self._current_file_path:
                shutil.move(str(self._temp_file_path), str(self._current_file_path))
                self.logger.info("Wrote file %s", self._current_file_path)

            # Add to file cache and cleanup if needed
            if self._current_file_path is not None and (
                self.max_files is not None or self.retention_time is not None
            ):
                self._file_cache.append(str(self._current_file_path))
                deleted_count = self._cleanup_files()
                if deleted_count > 0:
                    self.logger.info("Cleaned up %d old frame files", deleted_count)

            self._current_file_path = None
            self._temp_file_path = None
            self._current_file_start = None
            self._file_duration = 0

    def _write_frame(self, frame: gwframe.Frame, start: float, duration: float) -> None:
        """Write a frame to file.

        Args:
            frame: Frame object to write
            start: GPS start time (floating point seconds)
            duration: Duration in seconds
        """
        # Ensure integer GPS time and duration
        assert int(start) == start, f"start must be integer seconds: {start}"
        assert int(duration) == duration, (
            f"duration must be integer seconds: {duration}"
        )
        start_int = int(start)
        duration_int = int(duration)

        # Determine file duration
        file_duration = (
            self.frame_duration if self.frame_duration > 0 else self.duration
        )

        # Check if we need to start a new file
        if self._current_writer is None:
            self._current_file_start = start_int
            self._file_duration = file_duration
            self._current_file_path = self._get_file_path(start_int, file_duration)

            if self._current_file_path.exists():
                if self.force:
                    self._current_file_path.unlink()
                else:
                    msg = f"output file exists: {self._current_file_path}"
                    raise FileExistsError(msg)

            # Use tmpdir for atomic writes
            if self.tmpdir:
                tmpdir_path = Path(self.tmpdir)
                tmpdir_path.mkdir(parents=True, exist_ok=True)
                self._temp_file_path = (
                    tmpdir_path / f".{self._current_file_path.name}.tmp"
                )
            else:
                self._temp_file_path = self._current_file_path.parent / (
                    f".{self._current_file_path.name}.tmp"
                )

            self._current_writer = gwframe.FrameWriter(
                str(self._temp_file_path),
                compression=self.compression,
                compression_level=self.compression_level,
            )
            self._current_writer.__enter__()

        # Write frame to current file
        self._current_writer.write_frame(frame)

        # Close file if this frame completes it
        # File spans [_current_file_start, _current_file_start + _file_duration)
        # After writing this frame, we have data up to start + duration
        assert self._current_file_start is not None, "File start should be set"
        if start_int + duration_int >= self._current_file_start + self._file_duration:
            self._close_current_file()

    def process(self, input_frames: dict[SinkPad, TSFrame]) -> None:
        """Process input frames and write to file.

        Args:
            input_frames:
                dict[SinkPad, TSFrame], mapping of sink pads to their input frames
        """
        # Collect channel data
        channels = {}
        sample_rates = {}
        start: float | None = None

        for pad, frame in input_frames.items():
            channel = pad.pad_name
            if frame.is_gap:
                return
            if frame.EOS:
                self.mark_eos(pad)

            # Load first buffer
            # TODO: fix this indexing to handle multiple buffers as multiple segments
            # This requires converting frame data to masked arrays using
            # the data_valid flag. Needs: sgn-ts MR 171 + gwframe masked array support
            data = frame.buffers[0]

            # TODO: check for above todo, For now, check if the buffer has enough data
            #  for the duration, later we'll need to cumulate check across multiple
            #  segments
            exp_samples = self.duration * data.sample_rate
            if data.samples < exp_samples:
                self.logger.warning(
                    "Data does not contain enough samples for duration %d. Skipping",
                    self.duration,
                )
                return

            # Compute GPS time from frame offset
            frame_start = Offset.offset_ref_start + Offset.tosec(frame.offset)
            frame_start = int(frame_start)  # Cast to int (aligned to second boundary)

            # Store start from first channel
            if start is None:
                start = frame_start

            # Store channel data and sample rate
            channels[channel] = data.data
            sample_rates[channel] = data.sample_rate

        # Create frame and add channels
        assert start is not None, "No valid frames to write"
        frame_name = self.frame_name or self._instruments_str
        gwf = gwframe.Frame(t0=start, duration=self.duration, name=frame_name)

        # Add history metadata
        for name, comment in self.frame_history.items():
            gwf.add_history(name, comment)

        for channel, data_array in channels.items():
            gwf.add_channel(
                channel=channel,
                data=data_array,
                sample_rate=sample_rates[channel],
            )

        self._write_frame(gwf, start, self.duration)
