from __future__ import annotations

import re
import unicodedata
from dataclasses import dataclass


SUPPORTED_TAG_RE = re.compile(r"</?(?:i|b|u)>", re.IGNORECASE)
ANY_TAG_RE = re.compile(r"<[^>]*>")
SPACE_RE = re.compile(r"\s+")
WORD_RE = re.compile(r"\S+")
CJK_RE = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]")


@dataclass(frozen=True)
class CaptionProfile:
    name: str = "General subtitles"
    max_units_per_line: int = 42
    max_lines: int = 2
    max_cps: float = 17.0
    min_duration_ms: int = 1000
    max_duration_ms: int = 7000
    preferred_gap_ms: int = 80
    group_max_duration_ms: int = 20_000
    group_max_units: int = 500

    def validate(self) -> None:
        if self.max_units_per_line < 1 or self.max_lines < 1:
            raise ValueError("Caption line limits must be positive.")
        if self.max_cps <= 0:
            raise ValueError("Maximum CPS must be positive.")
        if self.min_duration_ms < 1 or self.max_duration_ms < self.min_duration_ms:
            raise ValueError("Caption duration limits are invalid.")
        if self.preferred_gap_ms < 0:
            raise ValueError("Preferred gap must be non-negative.")
        if self.group_max_duration_ms < 1 or self.group_max_units < 1:
            raise ValueError("Sentence-group limits must be positive.")


def strip_markup(text: str) -> str:
    return ANY_TAG_RE.sub("", text)


def _is_ignored_codepoint(character: str) -> bool:
    code = ord(character)
    return (
        unicodedata.combining(character) != 0
        or code == 0x200D
        or 0xFE00 <= code <= 0xFE0F
        or 0xE0100 <= code <= 0xE01EF
    )


def normalize_visible_spaces(text: str) -> str:
    return SPACE_RE.sub(" ", strip_markup(text)).strip()


def display_unit_count(text: str, *, exclude_whitespace: bool = False) -> int:
    normalized = normalize_visible_spaces(text)
    return sum(
        1
        for character in normalized
        if not _is_ignored_codepoint(character)
        and not (exclude_whitespace and character.isspace())
    )


def characters_per_second(text: str, duration_ms: int) -> float:
    if duration_ms <= 0:
        raise ValueError("Duration must be positive.")
    return display_unit_count(text, exclude_whitespace=True) / (duration_ms / 1000.0)


def split_cue_wide_markup(text: str) -> tuple[str, tuple[str, ...], list[str]]:
    value = text.strip()
    wrappers: list[str] = []
    warnings: list[str] = []
    while True:
        match = re.fullmatch(r"<(i|b|u)>(.*)</\1>", value, re.IGNORECASE | re.DOTALL)
        if not match:
            break
        wrappers.append(match.group(1).lower())
        value = match.group(2).strip()
    if ANY_TAG_RE.search(value):
        warnings.append("Unsupported or malformed inline markup was stripped.")
        value = strip_markup(value)
    return value, tuple(wrappers), warnings


def restore_cue_wide_markup(text: str, wrappers: tuple[str, ...]) -> str:
    value = text
    for wrapper in reversed(wrappers):
        value = f"<{wrapper}>{value}</{wrapper}>"
    return value


def split_text_by_display_capacity(text: str, capacity: int) -> list[str]:
    """Split unspaced scripts without dropping or inventing visible characters."""
    if capacity < 1:
        raise ValueError("Display capacity must be positive.")
    normalized = normalize_visible_spaces(text)
    total = display_unit_count(normalized)
    if total <= capacity:
        return [normalized] if normalized else []
    part_count = (total + capacity - 1) // capacity
    targets = []
    remaining = total
    for remaining_parts in range(part_count, 0, -1):
        target = min(capacity, (remaining + remaining_parts - 1) // remaining_parts)
        targets.append(target)
        remaining -= target
    parts: list[str] = []
    start = 0
    used = 0
    target_index = 0
    for index, character in enumerate(normalized):
        character_units = 0 if _is_ignored_codepoint(character) else 1
        if (
            target_index < len(targets) - 1
            and used > 0
            and used + character_units > targets[target_index]
        ):
            parts.append(normalized[start:index].strip())
            start = index
            used = 0
            target_index += 1
        used += character_units
    parts.append(normalized[start:].strip())
    return [part for part in parts if part]


def wrap_text(text: str, max_units_per_line: int, max_lines: int) -> tuple[str, list[str]]:
    if max_units_per_line < 1 or max_lines < 1:
        raise ValueError("Line limits must be positive.")
    plain, wrappers, warnings = split_cue_wide_markup(text)
    words = WORD_RE.findall(normalize_visible_spaces(plain))
    if not words:
        return restore_cue_wide_markup("", wrappers), warnings
    if CJK_RE.search(plain) and any(
        display_unit_count(word) > max_units_per_line for word in words
    ):
        lines = split_text_by_display_capacity(plain, max_units_per_line)
        if len(lines) <= max_lines:
            return restore_cue_wide_markup("\n".join(lines), wrappers), warnings
        warnings.append(
            "Text cannot satisfy the configured line count without changing caption boundaries."
        )
        return restore_cue_wide_markup("\n".join(lines), wrappers), warnings
    if any(display_unit_count(word) > max_units_per_line for word in words):
        warnings.append("An indivisible token exceeds the configured line length.")

    line_count = min(
        max_lines,
        len(words),
        max(1, (display_unit_count(plain) + max_units_per_line - 1) // max_units_per_line),
    )
    if line_count == 1:
        return restore_cue_wide_markup(" ".join(words), wrappers), warnings

    # Dynamic programming minimizes overflow first, then line imbalance.
    prefixes = [0]
    for word in words:
        prefixes.append(prefixes[-1] + display_unit_count(word))

    def units(start: int, end: int) -> int:
        return prefixes[end] - prefixes[start] + max(0, end - start - 1)

    target = display_unit_count(" ".join(words)) / line_count
    states: dict[tuple[int, int], tuple[float, list[int]]] = {(0, 0): (0.0, [])}
    for used_lines in range(line_count):
        for position in range(len(words) + 1):
            state = states.get((used_lines, position))
            if state is None:
                continue
            for end in range(position + 1, len(words) + 1):
                length = units(position, end)
                overflow = max(0, length - max_units_per_line)
                score = state[0] + overflow * overflow * 1000 + (length - target) ** 2
                key = (used_lines + 1, end)
                candidate = (score, state[1] + [end])
                if key not in states or candidate[0] < states[key][0]:
                    states[key] = candidate
    _, boundaries = states[(line_count, len(words))]
    lines: list[str] = []
    start = 0
    for end in boundaries:
        lines.append(" ".join(words[start:end]))
        start = end
    if any(display_unit_count(line) > max_units_per_line for line in lines):
        warnings.append("Text cannot satisfy the configured line limit without removing or splitting a token.")
    return restore_cue_wide_markup("\n".join(lines), wrappers), warnings
