from __future__ import annotations

from dataclasses import dataclass
from fractions import Fraction
from math import gcd
from typing import Any, Mapping


COMMON_FRAME_RATES: dict[str, tuple[int, int]] = {
    "23.976": (24_000, 1_001),
    "24": (24, 1),
    "25": (25, 1),
    "29.97": (30_000, 1_001),
    "30": (30, 1),
    "50": (50, 1),
    "59.94": (60_000, 1_001),
    "60": (60, 1),
}


def _require_integer(value: Any, field: str, *, minimum: int = 1) -> int:
    if isinstance(value, bool) or not isinstance(value, int):
        raise ValueError(f"{field} must be an integer.")
    if value < minimum:
        raise ValueError(f"{field} must be at least {minimum}.")
    return value


def _ceil_fraction(value: Fraction) -> int:
    return -(-value.numerator // value.denominator)


def _round_half_up(value: Fraction) -> int:
    if value < 0:
        return -_round_half_up(-value)
    quotient, remainder = divmod(value.numerator, value.denominator)
    return quotient + (1 if remainder * 2 >= value.denominator else 0)


@dataclass(frozen=True)
class RationalTimebase:
    """Exact physical frame rate represented as frames per second."""

    numerator: int
    denominator: int

    def __post_init__(self) -> None:
        numerator = _require_integer(self.numerator, "Frame-rate numerator")
        denominator = _require_integer(self.denominator, "Frame-rate denominator")
        divisor = gcd(numerator, denominator)
        object.__setattr__(self, "numerator", numerator // divisor)
        object.__setattr__(self, "denominator", denominator // divisor)

    @classmethod
    def from_json(cls, value: Any) -> RationalTimebase:
        if not isinstance(value, Mapping):
            raise ValueError("Delivery frame rate must be an object.")
        unknown = set(value) - {"numerator", "denominator"}
        if unknown:
            raise ValueError(
                "Delivery frame rate contains unknown field(s): "
                + ", ".join(sorted(str(item) for item in unknown))
                + "."
            )
        if "numerator" not in value or "denominator" not in value:
            raise ValueError(
                "Delivery frame rate requires numerator and denominator."
            )
        return cls(value["numerator"], value["denominator"])

    @classmethod
    def from_label(cls, label: str) -> RationalTimebase:
        normalized = str(label).strip()
        values = COMMON_FRAME_RATES.get(normalized)
        if values is None:
            raise ValueError(f"Unsupported standard delivery frame rate: {label}.")
        return cls(*values)

    def to_json(self) -> dict[str, int]:
        return {
            "numerator": self.numerator,
            "denominator": self.denominator,
        }

    @property
    def frames_per_second(self) -> Fraction:
        return Fraction(self.numerator, self.denominator)

    @property
    def frame_duration_seconds(self) -> Fraction:
        return Fraction(self.denominator, self.numerator)

    def frame_duration_ms(self, frame_count: int = 1) -> Fraction:
        frames = _require_integer(frame_count, "Frame count", minimum=0)
        return Fraction(frames * self.denominator * 1000, self.numerator)

    def frame_index_to_ms(self, frame_index: int, *, quantizer: str = "nearest") -> int:
        index = _require_integer(frame_index, "Frame index", minimum=0)
        milliseconds = self.frame_duration_ms(index)
        if quantizer == "nearest":
            return _round_half_up(milliseconds)
        if quantizer == "floor":
            return milliseconds.numerator // milliseconds.denominator
        if quantizer == "ceil":
            return _ceil_fraction(milliseconds)
        raise ValueError("Frame quantizer must be nearest, floor, or ceil.")

    def minimum_timestamp_gap_ms(self, frame_count: int) -> int:
        """Conservatively quantize a frame-defined lower bound to SRT milliseconds."""
        return _ceil_fraction(self.frame_duration_ms(frame_count))

    def maximum_timestamp_delta_ms(self, frame_count: int) -> int:
        """Conservatively quantize a frame-defined upper bound to SRT milliseconds."""
        value = self.frame_duration_ms(frame_count)
        return value.numerator // value.denominator

    def milliseconds_to_frames(self, milliseconds: int) -> Fraction:
        value = _require_integer(milliseconds, "Milliseconds", minimum=0)
        return Fraction(value * self.numerator, 1000 * self.denominator)

    def seconds_to_frames(self, seconds: Any) -> Fraction:
        if isinstance(seconds, bool):
            raise ValueError("Seconds must be a non-negative number.")
        try:
            value = Fraction(str(seconds))
        except (ValueError, ZeroDivisionError) as exc:
            raise ValueError("Seconds must be a non-negative number.") from exc
        if value < 0:
            raise ValueError("Seconds must be a non-negative number.")
        return value * self.frames_per_second

    def seconds_to_nearest_frame_index(self, seconds: Any) -> int:
        return _round_half_up(self.seconds_to_frames(seconds))

    def nearest_frame_index(self, milliseconds: int) -> int:
        return _round_half_up(self.milliseconds_to_frames(milliseconds))

    def frame_grid_error_ms(self, milliseconds: int) -> Fraction:
        value = _require_integer(milliseconds, "Milliseconds", minimum=0)
        nearest = self.frame_duration_ms(self.nearest_frame_index(value))
        return abs(Fraction(value, 1) - nearest)

    def is_frame_aligned(
        self,
        milliseconds: int,
        *,
        tolerance_ms: Fraction = Fraction(1, 2),
    ) -> bool:
        if tolerance_ms < 0:
            raise ValueError("Frame-grid tolerance must be non-negative.")
        return self.frame_grid_error_ms(milliseconds) <= tolerance_ms
