from __future__ import annotations

import copy
import math
import re
import unicodedata
from dataclasses import dataclass
from enum import Enum

from caption_profile import (
    CJK_RE,
    CaptionProfile,
    characters_per_second,
    display_unit_count,
    normalize_visible_spaces,
    restore_cue_wide_markup,
    split_cue_wide_markup,
    wrap_text,
)
from operations import SentenceGroup, form_sentence_groups
from timed_text import Cue, TimedTextDocument, TimingFidelity
from translation import (
    LinguisticContext,
    normalize_context_text,
    protected_content_warnings,
    resolve_document_protected_terms,
)


class CondensationGroupStatus(str, Enum):
    UNCHANGED = "UNCHANGED"
    REDISTRIBUTED = "REDISTRIBUTED"
    WORKFLOW_AI_REQUIRED = "WORKFLOW_AI_REQUIRED"
    REWRITTEN = "REWRITTEN"
    UNRESOLVED = "UNRESOLVED"


@dataclass(frozen=True)
class CondensationGroupResult:
    group_id: str
    source_cue_ids: tuple[str, ...]
    status: CondensationGroupStatus
    source_cue_count: int
    output_cue_count: int
    source_max_cps: float
    output_max_cps: float | None
    reason_codes: tuple[str, ...] = ()
    warnings: tuple[str, ...] = ()
    source_text: str = ""
    output_text: str = ""
    reduction_percent: float = 0.0
    source_reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class CondensationPreparation:
    document: TimedTextDocument
    groups: tuple[CondensationGroupResult, ...]
    notes: tuple[str, ...] = ()

    @property
    def workflow_ai_group_ids(self) -> tuple[str, ...]:
        return tuple(
            group.group_id
            for group in self.groups
            if group.status == CondensationGroupStatus.WORKFLOW_AI_REQUIRED
        )

    @property
    def unresolved_group_ids(self) -> tuple[str, ...]:
        return tuple(
            group.group_id
            for group in self.groups
            if group.status == CondensationGroupStatus.UNRESOLVED
        )

    @property
    def deterministic_status(self) -> str:
        if self.workflow_ai_group_ids:
            return "WORKFLOW_AI_REQUIRED"
        if self.unresolved_group_ids:
            return "PARTIAL"
        return "COMPLETED"


@dataclass(frozen=True)
class CondensationPlan:
    preparation: CondensationPreparation
    batches: tuple[dict[str, object], ...]
    unused_protected_terms: tuple[str, ...] = ()


@dataclass(frozen=True)
class _TextAtom:
    text: str
    space_before: bool


@dataclass(frozen=True)
class CondensationCueAllocation:
    text: str
    start_ms: int
    end_ms: int


def _copy_cue(cue: Cue, *, keep_words: bool) -> Cue:
    return Cue(
        id=cue.id,
        srt_index=cue.srt_index,
        start_ms=cue.start_ms,
        end_ms=cue.end_ms,
        text=cue.text,
        speaker=cue.speaker,
        source_track=cue.source_track,
        source_item=cue.source_item,
        words=list(cue.words) if keep_words else [],
        metadata=dict(cue.metadata),
    )


def _visible_clusters(value: str) -> list[str]:
    clusters: list[str] = []
    for character in value:
        codepoint = ord(character)
        attaches = (
            unicodedata.combining(character) != 0
            or 0xFE00 <= codepoint <= 0xFE0F
            or 0xE0100 <= codepoint <= 0xE01EF
            or character == "\u200d"
            or (clusters and clusters[-1].endswith("\u200d"))
        )
        if attaches and clusters:
            clusters[-1] += character
        else:
            clusters.append(character)
    return clusters


def _text_atoms(text: str) -> list[_TextAtom]:
    normalized = normalize_visible_spaces(text)
    atoms: list[_TextAtom] = []
    for chunk_index, chunk in enumerate(normalized.split(" ")):
        if not chunk:
            continue
        if CJK_RE.search(chunk):
            values: list[str] = []
            non_cjk = ""
            for cluster in _visible_clusters(chunk):
                if CJK_RE.search(cluster):
                    if non_cjk:
                        values.append(non_cjk)
                        non_cjk = ""
                    values.append(cluster)
                else:
                    non_cjk += cluster
            if non_cjk:
                if values and not any(character.isalnum() for character in non_cjk):
                    values[-1] += non_cjk
                else:
                    values.append(non_cjk)
        else:
            values = [chunk]
        for value_index, value in enumerate(values):
            atoms.append(
                _TextAtom(
                    value,
                    space_before=chunk_index > 0 and value_index == 0,
                )
            )
    return atoms


def _layout_candidate(
    plain: str,
    visible_units: int,
    cps_units: int,
    profile: CaptionProfile,
) -> tuple[str, int] | None:
    if visible_units <= profile.max_units_per_line:
        return plain, cps_units
    wrapped, _ = wrap_text(
        plain,
        profile.max_units_per_line,
        profile.max_lines,
    )
    lines = wrapped.splitlines()
    if not lines or len(lines) > profile.max_lines:
        return None
    if any(display_unit_count(line) > profile.max_units_per_line for line in lines):
        return None
    return wrapped, cps_units


def _allocation_intervals(
    atoms: list[_TextAtom],
    profile: CaptionProfile,
) -> tuple[dict[tuple[int, int], tuple[str, int]], list[int]]:
    """Build count-independent layout candidates once for one sentence group."""
    atom_units = [
        max(1, display_unit_count(atom.text, exclude_whitespace=True))
        for atom in atoms
    ]
    prefixes = [0]
    for units in atom_units:
        prefixes.append(prefixes[-1] + units)

    intervals: dict[tuple[int, int], tuple[str, int]] = {}
    capacity = profile.max_units_per_line * profile.max_lines
    for start in range(len(atoms)):
        plain = ""
        visible_units = 0
        minimum_line_count = 1
        current_line_units = 0
        for end in range(start + 1, len(atoms) + 1):
            atom = atoms[end - 1]
            atom_visible_units = display_unit_count(atom.text)
            if atom_visible_units > profile.max_units_per_line:
                break
            spacer_units = 1 if current_line_units and atom.space_before else 0
            if (
                current_line_units
                and current_line_units + spacer_units + atom_visible_units
                > profile.max_units_per_line
            ):
                minimum_line_count += 1
                current_line_units = atom_visible_units
            else:
                current_line_units += spacer_units + atom_visible_units
            if plain and atom.space_before:
                plain += " "
                visible_units += 1
            plain += atom.text
            visible_units += atom_visible_units
            if visible_units > capacity:
                break
            requested_line_count = max(
                1,
                (visible_units + profile.max_units_per_line - 1)
                // profile.max_units_per_line,
            )
            if minimum_line_count > requested_line_count:
                continue
            intervals[(start, end)] = (
                plain,
                prefixes[end] - prefixes[start],
            )
    return intervals, prefixes


def _allocation_for_count(
    atoms: list[_TextAtom],
    intervals: dict[tuple[int, int], tuple[str, int]],
    prefixes: list[int],
    count: int,
    start_ms: int,
    end_ms: int,
    profile: CaptionProfile,
) -> list[CondensationCueAllocation] | None:
    if count < 1 or count > len(atoms):
        return None
    total_units = prefixes[-1]
    target_units = total_units / count
    duration_ms = end_ms - start_ms
    states: dict[
        tuple[int, int], tuple[float, tuple[CondensationCueAllocation, ...]]
    ] = {(0, start_ms): (0.0, ())}
    for used in range(count):
        next_states: dict[
            tuple[int, int], tuple[float, tuple[CondensationCueAllocation, ...]]
        ] = {}
        remaining_segments = count - used - 1
        for (start, segment_start_ms), (score, segments) in states.items():
            latest_end = len(atoms) - remaining_segments
            for end in range(start + 1, latest_end + 1):
                candidate = intervals.get((start, end))
                if candidate is None:
                    continue
                wrapped, units = candidate
                if remaining_segments == 0:
                    segment_end_ms = end_ms
                else:
                    cumulative_units = prefixes[end]
                    proportional_boundary = start_ms + int(
                        round(duration_ms * cumulative_units / total_units)
                    )
                    segment_end_ms = max(
                        segment_start_ms + 1,
                        min(proportional_boundary, end_ms - remaining_segments),
                    )
                segment_duration_ms = segment_end_ms - segment_start_ms
                if (
                    segment_duration_ms < profile.min_duration_ms
                    or segment_duration_ms > profile.max_duration_ms
                    or characters_per_second(wrapped, segment_duration_ms)
                    > profile.max_cps + 1e-9
                ):
                    continue
                next_score = score + (units - target_units) ** 2
                next_segments = segments + (
                    CondensationCueAllocation(
                        wrapped,
                        segment_start_ms,
                        segment_end_ms,
                    ),
                )
                key = (end, segment_end_ms)
                previous = next_states.get(key)
                next_order = tuple(item.text for item in next_segments)
                if previous is None or (next_score, next_order) < (
                    previous[0],
                    tuple(item.text for item in previous[1]),
                ):
                    next_states[key] = (next_score, next_segments)
        states = next_states
        if not states:
            return None
    completed = [
        value
        for (end, boundary), value in states.items()
        if end == len(atoms) and boundary == end_ms
    ]
    if not completed:
        return None
    selected = min(
        completed,
        key=lambda value: (value[0], tuple(item.text for item in value[1])),
    )
    rendered: list[CondensationCueAllocation] = []
    for item in selected[1]:
        candidate = _layout_candidate(
            item.text,
            display_unit_count(item.text),
            display_unit_count(item.text, exclude_whitespace=True),
            profile,
        )
        if candidate is None:
            return None
        rendered.append(
            CondensationCueAllocation(
                candidate[0],
                item.start_ms,
                item.end_ms,
            )
        )
    return rendered


def allocate_text_within_group(
    text: str,
    start_ms: int,
    end_ms: int,
    profile: CaptionProfile,
) -> tuple[list[CondensationCueAllocation] | None, tuple[str, ...]]:
    """Find the smallest feasible cue count inside one fixed outer window."""
    profile.validate()
    normalized = normalize_visible_spaces(text)
    atoms = _text_atoms(normalized)
    duration_ms = end_ms - start_ms
    if not atoms:
        return None, ("EMPTY_GROUP_TEXT",)
    if duration_ms < profile.min_duration_ms:
        return None, ("GROUP_WINDOW_BELOW_MIN_DURATION",)

    source_units = display_unit_count(normalized, exclude_whitespace=True)
    if source_units / (duration_ms / 1000.0) > profile.max_cps:
        return None, ("GROUP_CPS_EXCEEDS_LIMIT",)

    capacity = profile.max_units_per_line * profile.max_lines
    minimum_count = max(
        1,
        (display_unit_count(normalized) + capacity - 1) // capacity,
        (duration_ms + profile.max_duration_ms - 1)
        // profile.max_duration_ms,
    )
    maximum_count = min(len(atoms), duration_ms // profile.min_duration_ms)
    if minimum_count > maximum_count:
        return None, ("NO_FEASIBLE_CUE_COUNT",)

    intervals, prefixes = _allocation_intervals(atoms, profile)
    for count in range(minimum_count, maximum_count + 1):
        allocated = _allocation_for_count(
            atoms,
            intervals,
            prefixes,
            count,
            start_ms,
            end_ms,
            profile,
        )
        if allocated is not None:
            return allocated, ()
    return None, ("NO_TEXT_PRESERVING_ALLOCATION",)


def _cue_issue_codes(cue: Cue, profile: CaptionProfile) -> tuple[str, ...]:
    issues: list[str] = []
    lines = cue.text.splitlines() or [cue.text]
    if len(lines) > profile.max_lines:
        issues.append("TOO_MANY_LINES")
    if any(display_unit_count(line) > profile.max_units_per_line for line in lines):
        issues.append("LINE_TOO_LONG")
    duration_ms = cue.end_ms - cue.start_ms
    if characters_per_second(cue.text, duration_ms) > profile.max_cps:
        issues.append("CPS_TOO_HIGH")
    if duration_ms < profile.min_duration_ms:
        issues.append("CUE_TOO_SHORT")
    if duration_ms > profile.max_duration_ms:
        issues.append("CUE_TOO_LONG")
    return tuple(issues)


def _uniform_value(cues: list[Cue], field: str) -> str:
    values = {getattr(cue, field) for cue in cues if getattr(cue, field)}
    return next(iter(values)) if len(values) == 1 else ""


def _group_source_text(cues: list[Cue]) -> str:
    parts = [
        normalize_visible_spaces(split_cue_wide_markup(cue.text)[0])
        for cue in cues
    ]
    value = ""
    for part in parts:
        if not part:
            continue
        if not value:
            value = part
        elif CJK_RE.search(value[-1:]) or CJK_RE.search(part[:1]) or re.match(
            r"^[,.;:!?%)\]}]", part
        ):
            value += part
        else:
            value += " " + part
    return value


def _allocated_group_cues(
    group: SentenceGroup,
    source_cues: list[Cue],
    allocated: list[CondensationCueAllocation],
    *,
    action: str = "REDISTRIBUTED",
) -> list[Cue]:
    metadata = {
        "sourceCueIds": list(group.cue_ids),
        "sourceCueMetadata": {
            cue.id: copy.deepcopy(cue.metadata)
            for cue in source_cues
            if cue.metadata
        },
        "condensationAction": action,
    }
    return [
        Cue(
            id=f"{group.id}_cue_{index:04d}",
            srt_index=index,
            start_ms=item.start_ms,
            end_ms=item.end_ms,
            text=restore_cue_wide_markup(item.text, group.wrappers),
            speaker=_uniform_value(source_cues, "speaker"),
            source_track=_uniform_value(source_cues, "source_track"),
            source_item=_uniform_value(source_cues, "source_item"),
            metadata=dict(metadata),
        )
        for index, item in enumerate(allocated, start=1)
    ]


def prepare_condensation(
    document: TimedTextDocument,
    profile: CaptionProfile,
) -> CondensationPreparation:
    """Apply text-preserving full-window redistribution before semantic rewriting."""
    profile.validate()
    document.validate(allow_overlaps=False)
    groups = form_sentence_groups(document, profile)
    source_by_id = {cue.id: cue for cue in document.cues}
    output: list[Cue] = []
    group_results: list[CondensationGroupResult] = []
    timing_changed = False

    for group in groups:
        source_cues = [source_by_id[cue_id] for cue_id in group.cue_ids]
        source_issues = tuple(
            dict.fromkeys(
                issue
                for cue in source_cues
                for issue in _cue_issue_codes(cue, profile)
            )
        )
        source_max_cps = max(
            characters_per_second(cue.text, cue.end_ms - cue.start_ms)
            for cue in source_cues
        )
        if "CPS_TOO_HIGH" not in source_issues:
            output.extend(_copy_cue(cue, keep_words=True) for cue in source_cues)
            group_results.append(
                CondensationGroupResult(
                    group.id,
                    group.cue_ids,
                    CondensationGroupStatus.UNCHANGED,
                    len(source_cues),
                    len(source_cues),
                    source_max_cps,
                    source_max_cps,
                    reason_codes=source_issues,
                    warnings=group.warnings,
                )
            )
            continue

        if group.warnings:
            output.extend(_copy_cue(cue, keep_words=True) for cue in source_cues)
            group_results.append(
                CondensationGroupResult(
                    group.id,
                    group.cue_ids,
                    CondensationGroupStatus.UNRESOLVED,
                    len(source_cues),
                    len(source_cues),
                    source_max_cps,
                    source_max_cps,
                    reason_codes=source_issues + ("SOURCE_MARKUP_REQUIRES_REVIEW",),
                    warnings=group.warnings,
                )
            )
            continue

        allocated, allocation_reasons = allocate_text_within_group(
            _group_source_text(source_cues),
            group.start_ms,
            group.end_ms,
            profile,
        )
        if allocated is None:
            status = (
                CondensationGroupStatus.UNRESOLVED
                if set(allocation_reasons)
                & {"EMPTY_GROUP_TEXT", "GROUP_WINDOW_BELOW_MIN_DURATION"}
                else CondensationGroupStatus.WORKFLOW_AI_REQUIRED
            )
            output.extend(_copy_cue(cue, keep_words=True) for cue in source_cues)
            group_results.append(
                CondensationGroupResult(
                    group.id,
                    group.cue_ids,
                    status,
                    len(source_cues),
                    len(source_cues),
                    source_max_cps,
                    None,
                    reason_codes=source_issues + allocation_reasons,
                )
            )
            continue

        redistributed = _allocated_group_cues(group, source_cues, allocated)
        output.extend(redistributed)
        timing_changed = True
        source_text = _group_source_text(source_cues)
        group_results.append(
            CondensationGroupResult(
                group.id,
                group.cue_ids,
                CondensationGroupStatus.REDISTRIBUTED,
                len(source_cues),
                len(redistributed),
                source_max_cps,
                max(
                    characters_per_second(cue.text, cue.end_ms - cue.start_ms)
                    for cue in redistributed
                ),
                reason_codes=(),
                source_reason_codes=source_issues,
                source_text=source_text,
                output_text=source_text,
            )
        )

    if timing_changed:
        for cue in output:
            cue.words = []
    result = TimedTextDocument(
        version=document.version,
        language=document.language,
        source_kind=document.source_kind,
        source_name=document.source_name,
        timing_fidelity=(
            TimingFidelity.ESTIMATED_WITHIN_GROUP
            if timing_changed
            else document.timing_fidelity
        ),
        cues=output,
        metadata=dict(document.metadata),
    )
    result.reindex()
    result.validate(allow_overlaps=False)
    return CondensationPreparation(result, tuple(group_results))


def _group_context(
    groups: list[SentenceGroup],
    position: int,
    direction: int,
) -> list[dict[str, str]]:
    neighbor = position + direction
    if neighbor < 0 or neighbor >= len(groups):
        return []
    group = groups[neighbor]
    return [{"id": group.id, "text": group.plain_text}]


def plan_condensation_requests(
    document: TimedTextDocument,
    *,
    profile: CaptionProfile,
    context: LinguisticContext = LinguisticContext(),
    max_groups_per_batch: int = 25,
) -> CondensationPlan:
    """Prepare deterministic changes and bounded requests for remaining CPS targets."""
    if max_groups_per_batch < 1:
        raise ValueError("Workflow AI groups per request must be at least 1.")
    preparation = prepare_condensation(document, profile)
    groups = form_sentence_groups(document, profile)
    positions = {group.id: index for index, group in enumerate(groups)}
    target_ids = set(preparation.workflow_ai_group_ids)
    targets = [group for group in groups if group.id in target_ids]
    protected_terms, unused_terms = resolve_document_protected_terms(
        document,
        profile,
        context.protected_terms,
    )
    normalized_context = {
        "projectContext": normalize_context_text(context.project_context),
        "knownNamesAndTerminology": normalize_context_text(
            context.known_names_and_terminology
        ),
        "condensationInstructions": normalize_context_text(context.task_instructions),
        "protectedTerms": protected_terms,
    }
    batches: list[dict[str, object]] = []
    for start in range(0, len(targets), max_groups_per_batch):
        selected = targets[start : start + max_groups_per_batch]
        selected_ids = {group.id for group in selected}
        request_groups: list[dict[str, object]] = []
        for group in selected:
            position = positions[group.id]
            source_units = display_unit_count(
                group.plain_text,
                exclude_whitespace=True,
            )
            readable_units = math.floor(
                (group.end_ms - group.start_ms) * profile.max_cps / 1000.0
            )
            request_groups.append(
                {
                    "id": group.id,
                    "sourceCueIds": list(group.cue_ids),
                    "sourceText": group.plain_text,
                    "sourceUnits": source_units,
                    "targetMaximumCps": profile.max_cps,
                    "maximumReadableUnitsInWindow": readable_units,
                    "minimumUsefulReductionUnits": max(
                        2,
                        math.ceil(source_units * 0.10),
                    ),
                    "contextBefore": [
                        item
                        for item in _group_context(groups, position, -1)
                        if item["id"] not in selected_ids
                    ],
                    "contextAfter": [
                        item
                        for item in _group_context(groups, position, 1)
                        if item["id"] not in selected_ids
                    ],
                }
            )
        batches.append(
            {
                "version": 1,
                "batchId": f"batch_{len(batches) + 1:04d}",
                "task": "CONDENSE_FOR_READABILITY",
                "context": normalized_context,
                "semanticTask": {
                    "visibleGoal": (
                        "Reduce reading speed with minimal wording changes while preserving "
                        "the complete meaning, tone, facts, qualifiers, and calls to action."
                    ),
                    "editorInstructions": normalized_context[
                        "condensationInstructions"
                    ],
                    "authority": (
                        "Editor instructions control wording and tone. The exact response IDs, "
                        "non-empty text, and protected-text requirements remain fixed."
                    ),
                },
                "instructions": [
                    "Return exactly one non-empty condensedText result for every supplied target group id, in the same order.",
                    "Do not return results for contextBefore or contextAfter ids.",
                    "Do not return timing, cue boundaries, file paths, comments, or markdown.",
                    "Keep protectedTerms entries that occur in a target group exactly unchanged.",
                    "Preserve names, brands, numbers, URLs, product codes, factual claims, legal qualifiers, and calls to action.",
                    "Do not invent facts, speaker labels, sounds, or visual descriptions.",
                ],
                "groups": request_groups,
            }
        )
    return CondensationPlan(
        preparation,
        tuple(batches),
        tuple(unused_terms),
    )


def _condensation_response_map(
    responses: list[dict[str, object]],
    expected_ids: tuple[str, ...],
) -> tuple[dict[str, object], tuple[str, ...], tuple[str, ...]]:
    result: dict[str, object] = {}
    returned_order: list[str] = []
    seen_ids: set[str] = set()
    notes: list[str] = []
    for response in responses:
        entries = response.get("groups")
        if not isinstance(entries, list):
            raise ValueError('Condensation response must contain a "groups" array.')
        for entry in entries:
            if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
                raise ValueError("Every condensation group response needs a string id.")
            group_id = entry["id"]
            if group_id in seen_ids:
                raise ValueError(f"Duplicate condensation group id: {group_id}")
            seen_ids.add(group_id)
            if group_id not in expected_ids:
                notes.append(
                    f"Workflow AI returned unexpected or context-only group {group_id}; it was ignored."
                )
                continue
            result[group_id] = entry
            returned_order.append(group_id)
    ordered_subset = tuple(group_id for group_id in expected_ids if group_id in result)
    if tuple(returned_order) != ordered_subset:
        notes.append(
            "Workflow AI returned caption groups in a different order; workflow-owned IDs were used to restore chronological order."
        )
    missing = tuple(group_id for group_id in expected_ids if group_id not in result)
    return result, missing, tuple(dict.fromkeys(notes))


def _prepared_group_cues(
    preparation: CondensationPreparation,
) -> dict[str, list[Cue]]:
    result: dict[str, list[Cue]] = {}
    cursor = 0
    for group in preparation.groups:
        next_cursor = cursor + group.output_cue_count
        result[group.group_id] = preparation.document.cues[cursor:next_cursor]
        cursor = next_cursor
    if cursor != len(preparation.document.cues):
        raise ValueError("Condensation preparation cue mapping is inconsistent.")
    return result


def merge_condensation_responses(
    document: TimedTextDocument,
    responses: list[dict[str, object]],
    *,
    profile: CaptionProfile,
    protected_terms: str = "",
) -> CondensationPreparation:
    """Merge group-local model output while retaining every rejected source group."""
    plan = plan_condensation_requests(
        document,
        profile=profile,
        context=LinguisticContext(protected_terms=protected_terms),
    )
    expected_ids = plan.preparation.workflow_ai_group_ids
    response_by_id, missing_ids, response_notes = _condensation_response_map(
        responses,
        expected_ids,
    )
    original_groups = form_sentence_groups(document, profile)
    source_by_id = {cue.id: cue for cue in document.cues}
    prepared_cues = _prepared_group_cues(plan.preparation)
    prepared_results = {group.group_id: group for group in plan.preparation.groups}
    resolved_terms, unused_terms = resolve_document_protected_terms(
        document,
        profile,
        protected_terms,
    )
    output: list[Cue] = []
    results: list[CondensationGroupResult] = []
    text_or_timing_changed = False

    for group in original_groups:
        prepared = prepared_results[group.id]
        source_cues = [source_by_id[cue_id] for cue_id in group.cue_ids]
        if prepared.status != CondensationGroupStatus.WORKFLOW_AI_REQUIRED:
            selected = [copy.deepcopy(cue) for cue in prepared_cues[group.id]]
            output.extend(selected)
            results.append(prepared)
            text_or_timing_changed = text_or_timing_changed or (
                prepared.status == CondensationGroupStatus.REDISTRIBUTED
            )
            continue

        source_text = _group_source_text(source_cues)
        entry = response_by_id.get(group.id)
        reason_codes = list(prepared.reason_codes)
        warnings: list[str] = []
        rewritten: list[Cue] | None = None
        target_text = ""
        reduction_percent = 0.0

        if group.id in missing_ids:
            reason_codes.append("MODEL_RESULT_MISSING")
        elif not isinstance(entry, dict):
            reason_codes.append("MODEL_RESULT_INVALID")
        else:
            raw_text = entry.get("condensedText")
            if not isinstance(raw_text, str) or not raw_text.strip():
                reason_codes.append("MODEL_TEXT_EMPTY")
            else:
                target_text, _, markup_warnings = split_cue_wide_markup(raw_text.strip())
                if markup_warnings:
                    reason_codes.append("MODEL_MARKUP_INVALID")
                    warnings.extend(markup_warnings)
                else:
                    source_units = display_unit_count(
                        source_text,
                        exclude_whitespace=True,
                    )
                    target_units = display_unit_count(
                        target_text,
                        exclude_whitespace=True,
                    )
                    reduction_units = source_units - target_units
                    reduction_percent = (
                        reduction_units / source_units * 100.0 if source_units else 0.0
                    )
                    target_cps = target_units / (
                        (group.end_ms - group.start_ms) / 1000.0
                    )
                    minimum_reduction = max(2, math.ceil(source_units * 0.10))
                    if target_cps > profile.max_cps and reduction_units < minimum_reduction:
                        reason_codes.append("MODEL_TEXT_NOT_USEFULLY_SHORTER")
                    else:
                        try:
                            warnings.extend(
                                protected_content_warnings(
                                    group.plain_text,
                                    target_text,
                                    resolved_terms,
                                    group.id,
                                )
                            )
                        except ValueError as exc:
                            reason_codes.append("PROTECTED_TEXT_CHANGED")
                            warnings.append(str(exc))
                        else:
                            allocated, allocation_reasons = allocate_text_within_group(
                                target_text,
                                group.start_ms,
                                group.end_ms,
                                profile,
                            )
                            if allocated is None:
                                reason_codes.extend(allocation_reasons)
                                reason_codes.append("MODEL_TEXT_CANNOT_FIT")
                            else:
                                rewritten = _allocated_group_cues(
                                    group,
                                    source_cues,
                                    allocated,
                                    action="REWRITTEN",
                                )

        if rewritten is None:
            output.extend(_copy_cue(cue, keep_words=True) for cue in source_cues)
            results.append(
                CondensationGroupResult(
                    group.id,
                    group.cue_ids,
                    CondensationGroupStatus.UNRESOLVED,
                    len(source_cues),
                    len(source_cues),
                    prepared.source_max_cps,
                    prepared.source_max_cps,
                    reason_codes=tuple(dict.fromkeys(reason_codes)),
                    warnings=tuple(dict.fromkeys(warnings)),
                    source_text=source_text,
                    output_text=source_text,
                )
            )
            continue

        output.extend(rewritten)
        text_or_timing_changed = True
        results.append(
            CondensationGroupResult(
                group.id,
                group.cue_ids,
                CondensationGroupStatus.REWRITTEN,
                len(source_cues),
                len(rewritten),
                prepared.source_max_cps,
                max(
                    characters_per_second(cue.text, cue.end_ms - cue.start_ms)
                    for cue in rewritten
                ),
                reason_codes=(),
                warnings=tuple(dict.fromkeys(warnings)),
                source_text=source_text,
                output_text=target_text,
                reduction_percent=reduction_percent,
                source_reason_codes=prepared.reason_codes,
            )
        )

    if text_or_timing_changed:
        for cue in output:
            cue.words = []
    result_document = TimedTextDocument(
        version=document.version,
        language=document.language,
        source_kind=document.source_kind,
        source_name=document.source_name,
        timing_fidelity=(
            TimingFidelity.ESTIMATED_WITHIN_GROUP
            if text_or_timing_changed
            else document.timing_fidelity
        ),
        cues=output,
        metadata=dict(document.metadata),
    )
    result_document.reindex()
    result_document.validate(allow_overlaps=False)
    notes = list(response_notes)
    if unused_terms:
        notes.append(
            "Protected text was not found in the source: "
            + ", ".join(repr(item) for item in unused_terms)
        )
    return CondensationPreparation(
        result_document,
        tuple(results),
        tuple(dict.fromkeys(notes)),
    )


def condensation_preparation_report(
    preparation: CondensationPreparation,
    *,
    workflow_ai_used: bool = False,
    workflow_ai_attempted: bool | None = None,
) -> dict[str, object]:
    if workflow_ai_attempted is None:
        workflow_ai_attempted = workflow_ai_used
    counts = {
        status.value: sum(group.status == status for group in preparation.groups)
        for status in CondensationGroupStatus
    }
    return {
        "version": 1,
        "status": preparation.deterministic_status,
        "timingFidelity": preparation.document.timing_fidelity.value,
        "workflowAiAttempted": workflow_ai_attempted,
        "workflowAiUsed": workflow_ai_used,
        "editorialReviewRequired": counts[CondensationGroupStatus.REWRITTEN.value] > 0,
        "textChanged": counts[CondensationGroupStatus.REWRITTEN.value] > 0,
        "cueTimingChanged": (
            counts[CondensationGroupStatus.REDISTRIBUTED.value]
            + counts[CondensationGroupStatus.REWRITTEN.value]
            > 0
        ),
        "counts": counts,
        "workflowAiGroupIds": list(preparation.workflow_ai_group_ids),
        "unresolvedGroupIds": list(preparation.unresolved_group_ids),
        "notes": list(
            dict.fromkeys(
                list(preparation.notes)
                + (
                    [
                        "Workflow AI was requested, but no provider response was received. "
                        "The original wording was retained for those groups."
                    ]
                    if workflow_ai_attempted and not workflow_ai_used
                    else []
                )
                + [
                    warning
                    for group in preparation.groups
                    for warning in group.warnings
                ]
            )
        ),
        "groups": [
            {
                "id": group.group_id,
                "sourceCueIds": list(group.source_cue_ids),
                "status": group.status.value,
                "sourceCueCount": group.source_cue_count,
                "outputCueCount": group.output_cue_count,
                "sourceMaxCps": round(group.source_max_cps, 4),
                "outputMaxCps": (
                    round(group.output_max_cps, 4)
                    if group.output_max_cps is not None
                    else None
                ),
                "reasonCodes": list(group.reason_codes),
                "sourceReasonCodes": list(group.source_reason_codes),
                "warnings": list(group.warnings),
                "sourceText": group.source_text,
                "outputText": group.output_text,
                "reductionPercent": round(group.reduction_percent, 2),
            }
            for group in preparation.groups
        ],
    }


def condensation_report_markdown(report: dict[str, object]) -> str:
    counts = report["counts"]
    assert isinstance(counts, dict)
    lines = [
        "# SRT condensation report",
        "",
        f"Status: {report['status']}",
        "",
        "## Overview",
        "",
        f"- Caption groups left unchanged: {counts.get('UNCHANGED', 0)}",
        f"- Caption groups redistributed without Workflow AI: {counts.get('REDISTRIBUTED', 0)}",
        f"- Caption groups rewritten with Workflow AI: {counts.get('REWRITTEN', 0)}",
        f"- Caption groups still needing review: {counts.get('UNRESOLVED', 0)}",
        f"- Workflow AI requested: {'yes' if report['workflowAiAttempted'] else 'no'}",
        f"- Usable Workflow AI response received: {'yes' if report['workflowAiUsed'] else 'no'}",
        f"- Editorial review required: {'yes' if report['editorialReviewRequired'] else 'no'}",
        f"- Caption timing changed: {'yes' if report['cueTimingChanged'] else 'no'}",
        "",
        (
            "Rebuilt captions inside a sentence group may touch with no empty time "
            "between them. Run Analyze afterward to check the final reading speed and gaps."
        ),
        (
            "Automation Agent checks structure and protected text, but it cannot prove that "
            "shortened wording preserves every nuance. Review every rewritten group."
        ),
    ]
    caption_settings = report.get("captionSettings")
    if isinstance(caption_settings, dict):
        lines.extend(["", "## Caption settings", ""])
        lines.append(
            "- Selected settings: "
            + str(caption_settings.get("profileName", "Standard settings"))
        )
        effective = caption_settings.get("effective")
        if isinstance(effective, list):
            for item in effective:
                if isinstance(item, dict):
                    lines.append(
                        f"- {item.get('label')}: {item.get('displayValue')} "
                        f"({item.get('sourceLabel')})"
                    )
        not_checked = caption_settings.get("notChecked")
        if isinstance(not_checked, list) and not_checked:
            lines.append(
                "- Stored profile settings not used by this tool: "
                + ", ".join(
                    str(item.get("label"))
                    for item in not_checked
                    if isinstance(item, dict)
                )
            )
        profile_result = caption_settings.get("profileRuleResult")
        if profile_result:
            lines.append(f"- Profile use: {profile_result}")
    notes = report.get("notes")
    if isinstance(notes, list) and notes:
        lines.extend(["", "## Notes", ""])
        lines.extend(f"- {note}" for note in notes)
    groups = report.get("groups")
    if isinstance(groups, list):
        review_groups = [
            group
            for group in groups
            if isinstance(group, dict)
            and group.get("status")
            in {"REWRITTEN", "UNRESOLVED"}
        ]
        if review_groups:
            lines.extend(["", "## Review changed and unresolved groups", ""])
        for group in review_groups:
            lines.append(f"### {group['id']} — {group['status']}")
            lines.append("")
            if group.get("sourceText"):
                lines.append(f"Original: {group['sourceText']}")
            if group.get("outputText"):
                lines.append(f"New: {group['outputText']}")
            if group.get("status") == "REWRITTEN":
                lines.append(f"Text reduction: {group['reductionPercent']}%")
                source_reasons = group.get("sourceReasonCodes")
                if isinstance(source_reasons, list) and source_reasons:
                    lines.append(
                        "Why Workflow AI was used: "
                        + ", ".join(str(item) for item in source_reasons)
                    )
            reasons = group.get("reasonCodes")
            if isinstance(reasons, list) and reasons:
                lines.append("Needs attention: " + ", ".join(str(item) for item in reasons))
            warnings = group.get("warnings")
            if isinstance(warnings, list):
                lines.extend(f"- {warning}" for warning in warnings)
            lines.append("")
    return "\n".join(lines).rstrip() + "\n"
