from __future__ import annotations

import math
import re
from typing import Any, Iterator

from caption_profile import CaptionProfile, display_unit_count, wrap_text
from language import normalize_language_tag
from timed_text import Cue, TimedTextDocument, TimedWord, TimingFidelity


PUNCTUATION_ONLY_RE = re.compile(r"^[,.;:!?%)\]}]+$")
SENTENCE_END_RE = re.compile(r"""[.!?]["')\]}]*$""")
NON_LANGUAGE_TAGS = {"mis", "mul", "und", "zxx"}


def detect_transcript_language(transcript: dict[str, Any]) -> tuple[str, str | None]:
    """Return one normalized transcript language or an editor-facing issue."""
    raw_values: list[str] = []
    root_language = transcript.get("language")
    if isinstance(root_language, str) and root_language.strip():
        raw_values.append(root_language)
    segments = transcript.get("segments")
    if isinstance(segments, list):
        for segment in segments:
            if not isinstance(segment, dict):
                continue
            segment_language = segment.get("language")
            if isinstance(segment_language, str) and segment_language.strip():
                raw_values.append(segment_language)
    if not raw_values:
        return "", "Premiere's transcript does not identify its language."
    normalized: set[str] = set()
    invalid: list[str] = []
    unavailable: list[str] = []
    for raw_value in raw_values:
        try:
            normalized_value = normalize_language_tag(raw_value)
            if normalized_value.split("-", 1)[0] in NON_LANGUAGE_TAGS:
                unavailable.append(normalized_value)
            else:
                normalized.add(normalized_value)
        except ValueError:
            invalid.append(raw_value)
    if invalid:
        return "", (
            "Premiere's transcript contains an invalid language value: "
            + ", ".join(sorted(set(invalid)))
            + "."
        )
    if unavailable:
        return "", (
            "Premiere's transcript does not identify one usable spoken language: "
            + ", ".join(sorted(set(unavailable)))
            + "."
        )
    most_specific = max(normalized, key=lambda value: (len(value.split("-")), value))
    if any(
        value != most_specific and not most_specific.startswith(value + "-")
        for value in normalized
    ):
        return "", (
            "Premiere's transcript contains inconsistent language values: "
            + ", ".join(sorted(normalized))
            + "."
        )
    return most_specific, None


def _seconds_to_ms(value: Any, label: str) -> int:
    if not isinstance(value, (int, float)) or not math.isfinite(value):
        raise ValueError(f"{label} must be a finite number.")
    return int(round(float(value) * 1000.0))


def join_transcript_word_text(words: list[dict[str, Any]]) -> str:
    output = ""
    for word in words:
        value = str(word.get("text", "")).strip()
        if not value:
            continue
        if not output or PUNCTUATION_ONLY_RE.fullmatch(value):
            output += value
        elif output[-1] in "([{":
            output += value
        else:
            output += " " + value
    return output.strip()


def iter_transcript_word_records(
    transcript: dict[str, Any],
) -> Iterator[tuple[int, int, dict[str, Any]]]:
    """Yield non-empty Premiere word records with their source indices."""
    segments = transcript.get("segments")
    if not isinstance(segments, list):
        raise ValueError("Transcript JSON does not contain a segments array.")
    for segment_index, segment in enumerate(segments):
        if not isinstance(segment, dict):
            continue
        raw_words = segment.get("words")
        if not isinstance(raw_words, list):
            continue
        for word_index, raw_word in enumerate(raw_words):
            if not isinstance(raw_word, dict):
                continue
            if not str(raw_word.get("text", "")).strip():
                continue
            yield segment_index, word_index, raw_word


def extract_transcript_words(
    transcript: dict[str, Any], *, warnings: list[str] | None = None
) -> list[TimedWord]:
    return [
        timed_word
        for _, _, _, timed_word in iter_timed_transcript_word_records(
            transcript,
            warnings=warnings,
        )
    ]


def iter_timed_transcript_word_records(
    transcript: dict[str, Any], *, warnings: list[str] | None = None
) -> Iterator[tuple[int, int, dict[str, Any], TimedWord]]:
    """Yield each raw transcript word together with its normalized timing."""
    previous_word: TimedWord | None = None
    found_word = False
    for segment_index, word_index, raw_word in iter_transcript_word_records(transcript):
        text = str(raw_word.get("text", "")).strip()
        start_ms = _seconds_to_ms(raw_word.get("start"), f"segments[{segment_index}].words[{word_index}].start")
        duration_ms = _seconds_to_ms(
            raw_word.get("duration"),
            f"segments[{segment_index}].words[{word_index}].duration",
        )
        if duration_ms < 1:
            duration_ms = 1
            if warnings is not None:
                warnings.append(
                    f"Clamped non-positive duration for transcript word {text!r} to 1 ms."
                )
        end_ms = start_ms + duration_ms
        if previous_word is not None and start_ms < previous_word.end_ms:
            start_ms = previous_word.end_ms
            end_ms = max(end_ms, start_ms + 1)
            if warnings is not None:
                warnings.append(
                    f"Clamped overlapping transcript word {text!r} to the previous word end."
                )
        word = TimedWord(text=text, start_ms=start_ms, end_ms=end_ms)
        previous_word = word
        found_word = True
        yield segment_index, word_index, raw_word, word
    if not found_word:
        raise ValueError("Transcript JSON contains no timed word text.")


def _preferred_boundary(word: TimedWord, next_word: TimedWord | None) -> int:
    score = 0
    if SENTENCE_END_RE.search(word.text):
        score += 100
    elif re.search(r"[,;:]$", word.text):
        score += 40
    if next_word is not None and next_word.start_ms - word.end_ms >= 250:
        score += 30
    return score


def _choose_cue_end(words: list[TimedWord], start: int, profile: CaptionProfile) -> int:
    capacity = profile.max_units_per_line * profile.max_lines
    best_fit = start + 1
    best_boundary: tuple[int, int] | None = None
    for end in range(start + 1, len(words) + 1):
        text = join_transcript_word_text([{"text": word.text} for word in words[start:end]])
        duration = words[end - 1].end_ms - words[start].start_ms
        units = display_unit_count(text)
        cps = display_unit_count(text, exclude_whitespace=True) / max(duration / 1000.0, 0.001)
        fits = units <= capacity and duration <= profile.max_duration_ms
        if not fits and end > start + 1:
            break
        best_fit = end
        score = _preferred_boundary(words[end - 1], words[end] if end < len(words) else None)
        if fits and duration >= profile.min_duration_ms and cps <= profile.max_cps:
            candidate = (score, end)
            if best_boundary is None or candidate > best_boundary:
                best_boundary = candidate
        if score >= 100 and fits:
            return end
    return best_boundary[1] if best_boundary else best_fit


def transcript_to_document(
    transcript: dict[str, Any],
    *,
    profile: CaptionProfile,
    language: str = "",
    source_name: str = "",
) -> tuple[TimedTextDocument, list[str]]:
    profile.validate()
    warnings: list[str] = []
    words = extract_transcript_words(transcript, warnings=warnings)
    cues: list[Cue] = []
    position = 0
    while position < len(words):
        end = _choose_cue_end(words, position, profile)
        span = words[position:end]
        plain = join_transcript_word_text([{"text": word.text} for word in span])
        wrapped, wrap_warnings = wrap_text(plain, profile.max_units_per_line, profile.max_lines)
        warnings.extend(wrap_warnings)
        cue = Cue(
            id=f"cue_{len(cues) + 1:06d}",
            srt_index=len(cues) + 1,
            start_ms=span[0].start_ms,
            end_ms=span[-1].end_ms,
            text=wrapped,
            words=list(span),
        )
        cues.append(cue)
        position = end
    document = TimedTextDocument(
        language=language or str(transcript.get("language", "")),
        source_kind="premiere-transcript",
        source_name=source_name,
        timing_fidelity=TimingFidelity.WORD_ALIGNED_EXACT,
        cues=cues,
    )
    document.validate(allow_overlaps=False)
    return document, warnings
