"""Reading, writing, and timeline helpers for sidecar LRC lyrics files."""

from __future__ import annotations

from bisect import bisect_right
from dataclasses import dataclass, field
from pathlib import Path
import re


_TIMESTAMP_RE = re.compile(
    r"\[(?P<minutes>\d{1,3}):(?P<seconds>\d{2})"
    r"(?:[\.:](?P<fraction>\d{1,3}))?\]"
)
_PLAIN_TIMESTAMP_RE = re.compile(
    r"^\s*\[?(?P<minutes>\d{1,3}):(?P<seconds>\d{2})"
    r"(?:[\.:](?P<fraction>\d{1,3}))?\]?\s*$"
)
_METADATA_RE = re.compile(r"^\[(?P<key>[A-Za-z][A-Za-z0-9_-]*):(?P<value>.*)\]$")


@dataclass(slots=True)
class LrcEntry:
    timestamp_ms: int | None
    text: str


@dataclass(slots=True)
class LrcDocument:
    entries: list[LrcEntry] = field(default_factory=list)
    metadata: list[tuple[str, str]] = field(default_factory=list)

    def synchronized_entries(self) -> list[LrcEntry]:
        """Return timestamped entries in stable chronological order."""

        return sorted(
            (entry for entry in self.entries if entry.timestamp_ms is not None),
            key=lambda entry: int(entry.timestamp_ms or 0),
        )


def lrc_path_for_audio(path: str | Path) -> Path:
    """Resolve the sidecar path requested by the product: same stem, .lrc."""

    return Path(path).with_suffix(".lrc")


def lrc_path_for_track(track: object) -> Path:
    """Resolve a stable, per-track LRC path for audio and CUE tracks.

    Virtual tracks from one CUE sheet can share the same source audio file, so
    deriving their LRC names from the audio path would make every track overwrite
    the same sidecar. CUE tracks instead use the CUE stem and track number.
    """

    cue_path = str(getattr(track, "cue_path", "") or "")
    if not cue_path:
        return lrc_path_for_audio(str(getattr(track, "path", "") or ""))

    raw_number = str(getattr(track, "track_number", "") or "").strip()
    cue_tags = getattr(track, "cue_tags", {})
    if not raw_number and isinstance(cue_tags, dict):
        raw_number = str(cue_tags.get("cue_track", "") or "").strip()
    number_match = re.search(r"\d+", raw_number)
    if number_match is not None:
        track_token = f"{int(number_match.group()):02d}"
    else:
        start_ms = max(0, int(getattr(track, "cue_start_ms", 0) or 0))
        track_token = f"at-{start_ms:09d}ms"

    cue = Path(cue_path)
    return cue.with_name(f"{cue.stem} - Track {track_token}.lrc")


def _fraction_to_ms(fraction: str | None) -> int:
    if not fraction:
        return 0
    if len(fraction) == 1:
        return int(fraction) * 100
    if len(fraction) == 2:
        return int(fraction) * 10
    return int(fraction[:3])


def _match_to_ms(match: re.Match[str]) -> int:
    minutes = int(match.group("minutes"))
    seconds = min(59, int(match.group("seconds")))
    return minutes * 60_000 + seconds * 1_000 + _fraction_to_ms(
        match.group("fraction")
    )


def parse_timestamp(value: str) -> int | None:
    """Parse ``mm:ss.xx`` or ``[mm:ss.xxx]`` for manual corrections."""

    match = _PLAIN_TIMESTAMP_RE.match(str(value or ""))
    return _match_to_ms(match) if match is not None else None


def format_timestamp(milliseconds: int, *, brackets: bool = True) -> str:
    """Format an LRC timestamp using centisecond precision."""

    centiseconds = max(0, int(milliseconds)) // 10
    minutes, remainder = divmod(centiseconds, 6_000)
    seconds, fraction = divmod(remainder, 100)
    value = f"{minutes:02d}:{seconds:02d}.{fraction:02d}"
    return f"[{value}]" if brackets else value


def parse_lrc(text: str) -> LrcDocument:
    document = LrcDocument()
    for raw_line in str(text or "").lstrip("\ufeff").splitlines():
        timestamp_matches = list(_TIMESTAMP_RE.finditer(raw_line))
        if timestamp_matches:
            lyric_text = _TIMESTAMP_RE.sub("", raw_line).strip()
            for match in timestamp_matches:
                document.entries.append(
                    LrcEntry(_match_to_ms(match), lyric_text)
                )
            continue
        metadata_match = _METADATA_RE.match(raw_line.strip())
        if metadata_match is not None:
            document.metadata.append(
                (metadata_match.group("key"), metadata_match.group("value"))
            )
            continue
        document.entries.append(LrcEntry(None, raw_line))
    return document


def serialize_lrc(document: LrcDocument) -> str:
    lines = [f"[{key}:{value}]" for key, value in document.metadata]
    for entry in document.entries:
        prefix = (
            format_timestamp(entry.timestamp_ms)
            if entry.timestamp_ms is not None
            else ""
        )
        lines.append(prefix + entry.text)
    return "\n".join(lines).rstrip("\n") + "\n"


def load_lrc(path: str | Path) -> LrcDocument:
    source = Path(path)
    if not source.is_file():
        return LrcDocument()
    try:
        text = source.read_text(encoding="utf-8-sig")
    except UnicodeDecodeError:
        text = source.read_text(encoding="cp932")
    return parse_lrc(text)


def save_lrc(path: str | Path, document: LrcDocument) -> Path:
    """Atomically save UTF-8 LRC data beside the source audio file."""

    destination = Path(path)
    destination.parent.mkdir(parents=True, exist_ok=True)
    temporary = destination.with_name(destination.name + ".tmp")
    temporary.write_text(serialize_lrc(document), encoding="utf-8-sig")
    temporary.replace(destination)
    return destination


def active_lyric_index(entries: list[LrcEntry], position_ms: int) -> int:
    """Return the active line, or -1 before the first timestamp."""

    if not entries:
        return -1
    timestamps = [int(entry.timestamp_ms or 0) for entry in entries]
    return bisect_right(timestamps, max(0, int(position_ms))) - 1
