from __future__ import annotations

import hashlib
import re
import unicodedata
from dataclasses import dataclass
from difflib import SequenceMatcher
from enum import Enum
from typing import Any, Iterable

from timed_text import Cue, TimedTextDocument, TimedWord, TimingFidelity, WordTimingSource


CJK_RE = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]")
LEXICAL_RE = re.compile(
    r"[^\W_]+(?:['’\-\u2010-\u2015][^\W_]+)*",
    re.UNICODE,
)
SPACE_RE = re.compile(r"\s+")
DIGIT_RE = re.compile(r"\d")
# A move farther away than this is reported as separate missing/extra text. The
# bounded window avoids claiming that repeated phrases elsewhere were reordered.
LOCAL_ORDER_MAX_TOKEN_DISTANCE = 8


class MatchKind(str, Enum):
    EXACT = "EXACT"
    NEAR = "NEAR"


class DifferenceKind(str, Enum):
    PROBABLE_RECOGNITION_DIFFERENCE = "PROBABLE_RECOGNITION_DIFFERENCE"
    REFERENCE_ONLY = "REFERENCE_ONLY"
    TRANSCRIPT_ONLY = "TRANSCRIPT_ONLY"
    POSSIBLE_WORDING_REPLACEMENT = "POSSIBLE_WORDING_REPLACEMENT"
    POSSIBLE_ORDER_DIFFERENCE = "POSSIBLE_ORDER_DIFFERENCE"


class ReferenceTimingFidelity(str, Enum):
    TRANSCRIPT_WORD_ALIGNED = "TRANSCRIPT_WORD_ALIGNED"
    INTERPOLATED_BETWEEN_ANCHORS = "INTERPOLATED_BETWEEN_ANCHORS"
    LOW_CONFIDENCE_ESTIMATE = "LOW_CONFIDENCE_ESTIMATE"
    UNTIMED = "UNTIMED"


class AlignmentReadiness(str, Enum):
    READY_FOR_SRT = "READY_FOR_SRT"
    INSUFFICIENT_ANCHORS = "INSUFFICIENT_ANCHORS"
    UNTIMED_REFERENCE = "UNTIMED_REFERENCE"


@dataclass(frozen=True)
class ReferenceToken:
    index: int
    text: str
    normalized: str
    start_character: int
    end_character: int


@dataclass(frozen=True)
class TranscriptToken:
    index: int
    text: str
    normalized: str
    start_ms: int
    end_ms: int
    source_word_index: int


@dataclass(frozen=True)
class TokenMatch:
    reference_index: int
    transcript_index: int
    kind: MatchKind
    similarity: float


@dataclass(frozen=True)
class ReferenceTokenTiming:
    reference_index: int
    start_ms: int | None
    end_ms: int | None
    fidelity: ReferenceTimingFidelity
    timing_source: WordTimingSource | None


@dataclass(frozen=True)
class AlignmentDifference:
    kind: DifferenceKind
    reference_start: int
    reference_end: int
    transcript_start: int
    transcript_end: int
    reference_text: str
    transcript_text: str


@dataclass(frozen=True)
class ReferenceScriptAlignment:
    reference_text: str
    reference_tokens: tuple[ReferenceToken, ...]
    transcript_tokens: tuple[TranscriptToken, ...]
    matches: tuple[TokenMatch, ...]
    timings: tuple[ReferenceTokenTiming, ...]
    differences: tuple[AlignmentDifference, ...]
    exact_anchor_count: int
    exact_anchor_coverage: float
    readiness: AlignmentReadiness
    can_generate_srt: bool
    reliability_message: str


def normalize_reference_whitespace(text: str) -> str:
    """Normalize layout whitespace without changing visible reference tokens."""
    return SPACE_RE.sub(" ", text).strip()


def _comparison_form(text: str) -> str:
    return "".join(
        character
        for character in unicodedata.normalize("NFKC", text).casefold()
        if character.isalnum()
    )


def _split_lexical_span(text: str, start: int, end: int) -> Iterable[tuple[str, int, int]]:
    """Split unspaced CJK runs while retaining ordinary words and product tokens."""
    position = start
    run_start = start
    while position < end:
        if CJK_RE.match(text[position]):
            if run_start < position:
                yield text[run_start:position], run_start, position
            yield text[position : position + 1], position, position + 1
            position += 1
            run_start = position
        else:
            position += 1
    if run_start < end:
        yield text[run_start:end], run_start, end


def tokenize_reference(text: str) -> tuple[str, tuple[ReferenceToken, ...]]:
    normalized_text = normalize_reference_whitespace(text)
    tokens: list[ReferenceToken] = []
    for match in LEXICAL_RE.finditer(normalized_text):
        for raw, start, end in _split_lexical_span(
            normalized_text, match.start(), match.end()
        ):
            normalized = _comparison_form(raw)
            if normalized:
                tokens.append(
                    ReferenceToken(len(tokens), raw, normalized, start, end)
                )
    if not tokens:
        raise ValueError("The reference script contains no spoken text tokens.")
    return normalized_text, tuple(tokens)


def _allocate_word_parts(word: TimedWord, parts: list[tuple[str, str]]) -> list[tuple[int, int]]:
    if len(parts) == 1:
        return [(word.start_ms, word.end_ms)]
    duration = word.end_ms - word.start_ms
    if duration < len(parts):
        # Transcript exports normally provide much wider word windows. Retaining
        # the same source window is safer than fabricating reversed sub-windows.
        return [(word.start_ms, word.end_ms) for _ in parts]
    weights = [max(1, len(normalized)) for _, normalized in parts]
    total_weight = sum(weights)
    extra = duration - len(parts)
    boundaries = [word.start_ms]
    used_weight = 0
    for position, weight in enumerate(weights[:-1], start=1):
        used_weight += weight
        boundary = word.start_ms + position + round(extra * used_weight / total_weight)
        boundaries.append(boundary)
    boundaries.append(word.end_ms)
    return list(zip(boundaries, boundaries[1:]))


def tokenize_transcript(words: list[TimedWord]) -> tuple[TranscriptToken, ...]:
    tokens: list[TranscriptToken] = []
    for word_index, word in enumerate(words):
        if word.end_ms <= word.start_ms:
            raise ValueError("Transcript word end must be greater than its start.")
        parts: list[tuple[str, str]] = []
        for match in LEXICAL_RE.finditer(word.text):
            for raw, _, _ in _split_lexical_span(word.text, match.start(), match.end()):
                normalized = _comparison_form(raw)
                if normalized:
                    parts.append((raw, normalized))
        timings = _allocate_word_parts(word, parts)
        for (raw, normalized), (start_ms, end_ms) in zip(parts, timings):
            tokens.append(
                TranscriptToken(
                    index=len(tokens),
                    text=raw,
                    normalized=normalized,
                    start_ms=start_ms,
                    end_ms=end_ms,
                    source_word_index=word_index,
                )
            )
    if not tokens:
        raise ValueError("The sequence transcript contains no timed spoken text tokens.")
    return tuple(tokens)


def _near_similarity(left: str, right: str) -> float:
    if left == right:
        return 1.0
    if min(len(left), len(right)) < 4:
        return 0.0
    left_digits = "".join(DIGIT_RE.findall(left))
    right_digits = "".join(DIGIT_RE.findall(right))
    if left_digits != right_digits:
        return 0.0
    ratio = SequenceMatcher(None, left, right, autojunk=False).ratio()
    return ratio if ratio >= 0.84 else 0.0


def _fuzzy_gap_matches(
    reference: tuple[ReferenceToken, ...],
    transcript: tuple[TranscriptToken, ...],
    reference_start: int,
    reference_end: int,
    transcript_start: int,
    transcript_end: int,
) -> list[TokenMatch]:
    ref_count = reference_end - reference_start
    transcript_count = transcript_end - transcript_start
    if ref_count == 0 or transcript_count == 0 or ref_count * transcript_count > 40_000:
        return []
    scores = [[0] * (transcript_count + 1) for _ in range(ref_count + 1)]
    choices = [[""] * (transcript_count + 1) for _ in range(ref_count + 1)]
    for ref_offset in range(1, ref_count + 1):
        for transcript_offset in range(1, transcript_count + 1):
            similarity = _near_similarity(
                reference[reference_start + ref_offset - 1].normalized,
                transcript[transcript_start + transcript_offset - 1].normalized,
            )
            match_score = (
                scores[ref_offset - 1][transcript_offset - 1]
                + int(round(similarity * 1000))
                if similarity
                else -1
            )
            skip_reference = scores[ref_offset - 1][transcript_offset]
            skip_transcript = scores[ref_offset][transcript_offset - 1]
            best = max(match_score, skip_reference, skip_transcript)
            scores[ref_offset][transcript_offset] = best
            # Stable ties prefer a real match, then the earlier transcript token.
            if match_score == best:
                choices[ref_offset][transcript_offset] = "MATCH"
            elif skip_transcript == best:
                choices[ref_offset][transcript_offset] = "TRANSCRIPT"
            else:
                choices[ref_offset][transcript_offset] = "REFERENCE"
    matches: list[TokenMatch] = []
    ref_offset = ref_count
    transcript_offset = transcript_count
    while ref_offset and transcript_offset:
        choice = choices[ref_offset][transcript_offset]
        if choice == "MATCH":
            ref_index = reference_start + ref_offset - 1
            transcript_index = transcript_start + transcript_offset - 1
            similarity = _near_similarity(
                reference[ref_index].normalized,
                transcript[transcript_index].normalized,
            )
            matches.append(
                TokenMatch(ref_index, transcript_index, MatchKind.NEAR, similarity)
            )
            ref_offset -= 1
            transcript_offset -= 1
        elif choice == "TRANSCRIPT":
            transcript_offset -= 1
        else:
            ref_offset -= 1
    return list(reversed(matches))


def _monotonic_matches(
    reference: tuple[ReferenceToken, ...], transcript: tuple[TranscriptToken, ...]
) -> list[TokenMatch]:
    matcher = SequenceMatcher(
        None,
        [token.normalized for token in reference],
        [token.normalized for token in transcript],
        autojunk=False,
    )
    exact: list[TokenMatch] = []
    for block in matcher.get_matching_blocks():
        for offset in range(block.size):
            exact.append(
                TokenMatch(
                    block.a + offset,
                    block.b + offset,
                    MatchKind.EXACT,
                    1.0,
                )
            )
    all_matches: list[TokenMatch] = []
    previous_reference = 0
    previous_transcript = 0
    for match in exact:
        all_matches.extend(
            _fuzzy_gap_matches(
                reference,
                transcript,
                previous_reference,
                match.reference_index,
                previous_transcript,
                match.transcript_index,
            )
        )
        all_matches.append(match)
        previous_reference = match.reference_index + 1
        previous_transcript = match.transcript_index + 1
    all_matches.extend(
        _fuzzy_gap_matches(
            reference,
            transcript,
            previous_reference,
            len(reference),
            previous_transcript,
            len(transcript),
        )
    )
    return all_matches


def _span_text(tokens: tuple[Any, ...], start: int, end: int) -> str:
    return " ".join(token.text for token in tokens[start:end])


def _difference_for_gap(
    reference: tuple[ReferenceToken, ...],
    transcript: tuple[TranscriptToken, ...],
    reference_start: int,
    reference_end: int,
    transcript_start: int,
    transcript_end: int,
) -> AlignmentDifference | None:
    has_reference = reference_start < reference_end
    has_transcript = transcript_start < transcript_end
    if not has_reference and not has_transcript:
        return None
    if has_reference and has_transcript:
        reference_values = sorted(
            token.normalized for token in reference[reference_start:reference_end]
        )
        transcript_values = sorted(
            token.normalized for token in transcript[transcript_start:transcript_end]
        )
        kind = (
            DifferenceKind.POSSIBLE_ORDER_DIFFERENCE
            if reference_values == transcript_values
            else DifferenceKind.POSSIBLE_WORDING_REPLACEMENT
        )
    elif has_reference:
        kind = DifferenceKind.REFERENCE_ONLY
    else:
        kind = DifferenceKind.TRANSCRIPT_ONLY
    return AlignmentDifference(
        kind,
        reference_start,
        reference_end,
        transcript_start,
        transcript_end,
        _span_text(reference, reference_start, reference_end),
        _span_text(transcript, transcript_start, transcript_end),
    )


def _differences(
    reference: tuple[ReferenceToken, ...],
    transcript: tuple[TranscriptToken, ...],
    matches: list[TokenMatch],
) -> tuple[AlignmentDifference, ...]:
    output: list[AlignmentDifference] = []
    previous_reference = 0
    previous_transcript = 0
    for match in matches:
        gap = _difference_for_gap(
            reference,
            transcript,
            previous_reference,
            match.reference_index,
            previous_transcript,
            match.transcript_index,
        )
        if gap:
            output.append(gap)
        if match.kind == MatchKind.NEAR:
            output.append(
                AlignmentDifference(
                    DifferenceKind.PROBABLE_RECOGNITION_DIFFERENCE,
                    match.reference_index,
                    match.reference_index + 1,
                    match.transcript_index,
                    match.transcript_index + 1,
                    reference[match.reference_index].text,
                    transcript[match.transcript_index].text,
                )
            )
        previous_reference = match.reference_index + 1
        previous_transcript = match.transcript_index + 1
    gap = _difference_for_gap(
        reference,
        transcript,
        previous_reference,
        len(reference),
        previous_transcript,
        len(transcript),
    )
    if gap:
        output.append(gap)
    return _pair_local_order_differences(reference, transcript, output)


def _difference_signature(
    tokens: tuple[Any, ...], start: int, end: int
) -> tuple[str, ...]:
    return tuple(token.normalized for token in tokens[start:end])


def _pair_local_order_differences(
    reference: tuple[ReferenceToken, ...],
    transcript: tuple[TranscriptToken, ...],
    differences: list[AlignmentDifference],
) -> tuple[AlignmentDifference, ...]:
    """Pair unique nearby delete/insert regions as possible text movement.

    Exact uniqueness is deliberately required. Repeated words without enough
    local evidence remain separate additions/omissions instead of being called
    a reordering with false confidence.
    """
    reference_only = [
        (index, difference)
        for index, difference in enumerate(differences)
        if difference.kind == DifferenceKind.REFERENCE_ONLY
    ]
    transcript_only = [
        (index, difference)
        for index, difference in enumerate(differences)
        if difference.kind == DifferenceKind.TRANSCRIPT_ONLY
    ]
    reference_signatures = [
        _difference_signature(
            reference, difference.reference_start, difference.reference_end
        )
        for _, difference in reference_only
    ]
    transcript_signatures = [
        _difference_signature(
            transcript, difference.transcript_start, difference.transcript_end
        )
        for _, difference in transcript_only
    ]
    removed: set[int] = set()
    replacements: list[AlignmentDifference] = []
    for (reference_list_index, (reference_index, reference_difference)) in enumerate(
        reference_only
    ):
        signature = reference_signatures[reference_list_index]
        if not signature or reference_signatures.count(signature) != 1:
            continue
        candidate_positions = [
            transcript_list_index
            for transcript_list_index, transcript_signature in enumerate(
                transcript_signatures
            )
            if transcript_signature == signature
        ]
        if len(candidate_positions) != 1:
            continue
        transcript_list_index = candidate_positions[0]
        transcript_index, transcript_difference = transcript_only[transcript_list_index]
        if (
            abs(
                reference_difference.reference_start
                - transcript_difference.transcript_start
            )
            > LOCAL_ORDER_MAX_TOKEN_DISTANCE
        ):
            continue
        removed.update({reference_index, transcript_index})
        replacements.append(
            AlignmentDifference(
                DifferenceKind.POSSIBLE_ORDER_DIFFERENCE,
                reference_difference.reference_start,
                reference_difference.reference_end,
                transcript_difference.transcript_start,
                transcript_difference.transcript_end,
                reference_difference.reference_text,
                transcript_difference.transcript_text,
            )
        )
    retained = [
        difference
        for index, difference in enumerate(differences)
        if index not in removed
    ]
    retained.extend(replacements)
    retained.sort(
        key=lambda difference: (
            difference.reference_start,
            difference.transcript_start,
            difference.kind.value,
        )
    )
    return tuple(retained)


def _allocated_timings(
    reference: tuple[ReferenceToken, ...],
    transcript: tuple[TranscriptToken, ...],
    matches: list[TokenMatch],
) -> tuple[ReferenceTokenTiming, ...]:
    timings: list[ReferenceTokenTiming | None] = [None] * len(reference)
    for match in matches:
        transcript_token = transcript[match.transcript_index]
        timings[match.reference_index] = ReferenceTokenTiming(
            match.reference_index,
            transcript_token.start_ms,
            transcript_token.end_ms,
            ReferenceTimingFidelity.TRANSCRIPT_WORD_ALIGNED,
            (
                WordTimingSource.TRANSCRIPT_EXACT
                if match.kind == MatchKind.EXACT
                else WordTimingSource.TRANSCRIPT_NEAR
            ),
        )
    match_by_reference = {match.reference_index: match for match in matches}
    position = 0
    while position < len(reference):
        if timings[position] is not None:
            position += 1
            continue
        end = position + 1
        while end < len(reference) and timings[end] is None:
            end += 1
        left_match = match_by_reference.get(position - 1)
        right_match = match_by_reference.get(end)
        interval_start = (
            transcript[left_match.transcript_index].end_ms
            if left_match
            else transcript[0].start_ms
        )
        interval_end = (
            transcript[right_match.transcript_index].start_ms
            if right_match
            else transcript[-1].end_ms
        )
        indices = list(range(position, end))
        fidelity = (
            ReferenceTimingFidelity.INTERPOLATED_BETWEEN_ANCHORS
            if left_match and right_match
            else ReferenceTimingFidelity.LOW_CONFIDENCE_ESTIMATE
        )
        timing_source = (
            WordTimingSource.INTERPOLATED_BETWEEN_ANCHORS
            if left_match and right_match
            else WordTimingSource.EDGE_ESTIMATE
        )
        duration = interval_end - interval_start
        if duration >= len(indices):
            weights = [max(1, len(reference[index].normalized)) for index in indices]
            total_weight = sum(weights)
            remaining = duration - len(indices)
            boundaries = [interval_start]
            used_weight = 0
            for offset, weight in enumerate(weights[:-1], start=1):
                used_weight += weight
                boundaries.append(
                    interval_start
                    + offset
                    + round(remaining * used_weight / total_weight)
                )
            boundaries.append(interval_end)
            for index, (start_ms, end_ms) in zip(indices, zip(boundaries, boundaries[1:])):
                timings[index] = ReferenceTokenTiming(
                    index, start_ms, end_ms, fidelity, timing_source
                )
        elif (
            duration == 0
            and len(indices) == 1
            and left_match
            and right_match
            and transcript[right_match.transcript_index].end_ms > interval_end
        ):
            # A single approved word can occur between two transcript words
            # whose timestamps touch. Give that word the following anchor's
            # window so the complete approved text can still be captioned.
            # The shared window keeps both words in one cue and deliberately
            # downgrades timing fidelity, so no exact word-timing sidecar is
            # written. Larger zero-duration insertions remain blocked.
            following = transcript[right_match.transcript_index]
            timings[indices[0]] = ReferenceTokenTiming(
                indices[0],
                following.start_ms,
                following.end_ms,
                ReferenceTimingFidelity.LOW_CONFIDENCE_ESTIMATE,
                WordTimingSource.SHARED_ANCHOR_WINDOW,
            )
            following_timing = timings[end]
            assert following_timing is not None
            timings[end] = ReferenceTokenTiming(
                following_timing.reference_index,
                following_timing.start_ms,
                following_timing.end_ms,
                following_timing.fidelity,
                WordTimingSource.SHARED_ANCHOR_WINDOW,
            )
        else:
            for index in indices:
                timings[index] = ReferenceTokenTiming(
                    index,
                    None,
                    None,
                    ReferenceTimingFidelity.UNTIMED,
                    None,
                )
        position = end
    return tuple(timing for timing in timings if timing is not None)


def align_reference_script(
    reference_text: str, transcript_words: list[TimedWord]
) -> ReferenceScriptAlignment:
    normalized_reference, reference = tokenize_reference(reference_text)
    transcript = tokenize_transcript(transcript_words)
    matches = _monotonic_matches(reference, transcript)
    exact_anchor_count = sum(match.kind == MatchKind.EXACT for match in matches)
    exact_coverage = exact_anchor_count / len(reference)
    if len(reference) <= 4:
        anchors_reliable = exact_anchor_count >= 1 and exact_coverage >= 0.5
        requirement = "at least one exact anchor covering half of this short reference"
    else:
        anchors_reliable = exact_anchor_count >= 2 and exact_coverage >= 0.15
        requirement = "at least two exact anchors covering 15% of the reference"
    timings = _allocated_timings(reference, transcript, matches)
    untimed_count = sum(
        timing.fidelity == ReferenceTimingFidelity.UNTIMED for timing in timings
    )
    reliable = anchors_reliable and untimed_count == 0
    if reliable:
        readiness = AlignmentReadiness.READY_FOR_SRT
        reliability_message = (
            f"Reliable enough for caption construction: {exact_anchor_count} exact anchor(s), "
            f"{exact_coverage:.1%} exact coverage, and every reference token has a positive "
            "timing window."
        )
    elif not anchors_reliable:
        readiness = AlignmentReadiness.INSUFFICIENT_ANCHORS
        reliability_message = (
            "No SRT should be created: the script and sequence transcript could not be "
            f"related reliably enough. This reference requires {requirement}; found "
            f"{exact_anchor_count} exact anchor(s) and {exact_coverage:.1%} coverage."
        )
    else:
        readiness = AlignmentReadiness.UNTIMED_REFERENCE
        reliability_message = (
            "No SRT should be created: although the text has enough exact anchors, "
            f"{untimed_count} reference token(s) have no positive safe timing window."
        )
    return ReferenceScriptAlignment(
        reference_text=normalized_reference,
        reference_tokens=reference,
        transcript_tokens=transcript,
        matches=tuple(matches),
        timings=timings,
        differences=_differences(reference, transcript, matches),
        exact_anchor_count=exact_anchor_count,
        exact_anchor_coverage=exact_coverage,
        readiness=readiness,
        can_generate_srt=reliable,
        reliability_message=reliability_message,
    )


def alignment_report(alignment: ReferenceScriptAlignment) -> dict[str, Any]:
    timing_counts = {
        fidelity.value: sum(timing.fidelity == fidelity for timing in alignment.timings)
        for fidelity in ReferenceTimingFidelity
    }
    timing_source_counts = {
        source.value: sum(timing.timing_source == source for timing in alignment.timings)
        for source in WordTimingSource
    }
    difference_counts = {
        kind.value: sum(difference.kind == kind for difference in alignment.differences)
        for kind in DifferenceKind
    }
    timing_review_regions: list[dict[str, Any]] = []
    position = 0
    while position < len(alignment.timings):
        timing = alignment.timings[position]
        if timing.fidelity == ReferenceTimingFidelity.TRANSCRIPT_WORD_ALIGNED:
            position += 1
            continue
        end = position + 1
        while (
            end < len(alignment.timings)
            and alignment.timings[end].fidelity == timing.fidelity
            and alignment.timings[end].reference_index
            == alignment.timings[end - 1].reference_index + 1
        ):
            end += 1
        span = alignment.timings[position:end]
        reference_start = span[0].reference_index
        reference_end = span[-1].reference_index + 1
        timing_review_regions.append(
            {
                "referenceRange": [reference_start, reference_end],
                "referenceText": _span_text(
                    alignment.reference_tokens, reference_start, reference_end
                ),
                "startMs": span[0].start_ms,
                "endMs": span[-1].end_ms,
                "fidelity": timing.fidelity.value,
            }
        )
        position = end
    return {
        "version": 1,
        "status": alignment.readiness.value,
        "referenceText": alignment.reference_text,
        "referenceTokenCount": len(alignment.reference_tokens),
        "transcriptTokenCount": len(alignment.transcript_tokens),
        "exactAnchorCount": alignment.exact_anchor_count,
        "exactAnchorCoverage": alignment.exact_anchor_coverage,
        "reliabilityMessage": alignment.reliability_message,
        "timingCounts": timing_counts,
        "timingSourceCounts": timing_source_counts,
        "differenceCounts": difference_counts,
        "matches": [
            {
                "referenceIndex": match.reference_index,
                "transcriptIndex": match.transcript_index,
                "kind": match.kind.value,
                "similarity": round(match.similarity, 4),
            }
            for match in alignment.matches
        ],
        "referenceTimings": [
            {
                "referenceIndex": timing.reference_index,
                "startMs": timing.start_ms,
                "endMs": timing.end_ms,
                "fidelity": timing.fidelity.value,
                "timingSource": (
                    timing.timing_source.value if timing.timing_source else None
                ),
            }
            for timing in alignment.timings
        ],
        "timingReviewRegions": timing_review_regions,
        "differences": [
            {
                "kind": difference.kind.value,
                "referenceRange": [difference.reference_start, difference.reference_end],
                "transcriptRange": [difference.transcript_start, difference.transcript_end],
                "referenceText": difference.reference_text,
                "transcriptText": difference.transcript_text,
                "referenceContext": _context_text(
                    alignment.reference_tokens,
                    difference.reference_start,
                    difference.reference_end,
                ),
                "transcriptContext": _context_text(
                    alignment.transcript_tokens,
                    difference.transcript_start,
                    difference.transcript_end,
                ),
            }
            for difference in alignment.differences
        ],
        "editorialLimitation": (
            "A disagreement cannot by itself show whether speech recognition was wrong "
            "or the recorded wording differed from the approved script."
        ),
    }


def _context_text(tokens: tuple[Any, ...], start: int, end: int) -> str:
    context_start = max(0, start - 2)
    context_end = min(len(tokens), end + 2)
    parts: list[str] = []
    for index in range(context_start, context_end):
        text = tokens[index].text
        parts.append(f"[{text}]" if start <= index < end else text)
    return " ".join(parts)


def alignment_report_markdown(
    alignment: ReferenceScriptAlignment, *, heading_level: int = 1
) -> str:
    if heading_level < 1 or heading_level > 5:
        raise ValueError("Heading level must be between 1 and 5.")
    heading = "#" * heading_level
    section = "#" * (heading_level + 1)
    subsection = "#" * (heading_level + 2)
    report = alignment_report(alignment)
    lines = [
        f"{heading} Reference Script Alignment Report",
        "",
        f"**Result:** {report['status']}",
        "",
        alignment.reliability_message,
        "",
        f"- Reference words checked: {len(alignment.reference_tokens)}",
        f"- Transcript words checked: {len(alignment.transcript_tokens)}",
        f"- Exact timing anchors: {alignment.exact_anchor_count} ({alignment.exact_anchor_coverage:.1%})",
        f"- Regions needing review: {len(alignment.differences)}",
        "",
        "The approved reference text remains the caption wording. Transcript differences are listed only as review evidence; they do not prove whether speech recognition or the recorded wording differs.",
        "",
        f"{section} Regions to review",
        "",
    ]
    if not alignment.differences:
        lines.append("No wording differences were found.")
    else:
        for difference, detail in zip(alignment.differences, report["differences"]):
            reference = difference.reference_text or "—"
            transcript = difference.transcript_text or "—"
            lines.extend(
                [
                    f"- **{difference.kind.value.replace('_', ' ').title()}**",
                    f"  - Approved script: {reference}",
                    f"  - Premiere transcript: {transcript}",
                    f"  - Script position: {detail['referenceContext']}",
                    f"  - Transcript position: {detail['transcriptContext']}",
                ]
            )
    lines.extend(["", f"{section} Timing provenance", ""])
    for fidelity, count in report["timingCounts"].items():
        lines.append(f"- {fidelity.replace('_', ' ').title()}: {count}")
    if report["timingReviewRegions"]:
        lines.extend(["", f"{subsection} Estimated or untimed reference regions", ""])
        for region in report["timingReviewRegions"]:
            if region["startMs"] is None:
                location = "no safe time window"
            else:
                location = f"{region['startMs']}–{region['endMs']} ms"
            lines.append(
                f"- **{region['fidelity'].replace('_', ' ').title()}** "
                f"({location}): {region['referenceText']}"
            )
    return "\n".join(lines).rstrip() + "\n"


DIFFERENCE_MARKER_TITLES = {
    DifferenceKind.PROBABLE_RECOGNITION_DIFFERENCE: "Possible recognition difference",
    DifferenceKind.REFERENCE_ONLY: "Text only in approved script",
    DifferenceKind.TRANSCRIPT_ONLY: "Text only in Premiere transcript",
    DifferenceKind.POSSIBLE_WORDING_REPLACEMENT: "Possible wording difference",
    DifferenceKind.POSSIBLE_ORDER_DIFFERENCE: "Possible order difference",
}


def reference_review_marker_document(
    alignment: ReferenceScriptAlignment,
    *,
    include_estimated_timing: bool,
) -> TimedTextDocument:
    """Build sequence-relative review markers only from safely timed regions."""

    candidates: list[dict[str, Any]] = []
    unplaced_wording = 0
    unplaced_timing = 0
    for difference in alignment.differences:
        windows: list[tuple[int, int]] = []
        for index in range(difference.reference_start, difference.reference_end):
            timing = alignment.timings[index]
            if (
                timing.start_ms is not None
                and timing.end_ms is not None
                and timing.end_ms > timing.start_ms
            ):
                windows.append((timing.start_ms, timing.end_ms))
        for index in range(difference.transcript_start, difference.transcript_end):
            token = alignment.transcript_tokens[index]
            if token.end_ms > token.start_ms:
                windows.append((token.start_ms, token.end_ms))
        if not windows:
            unplaced_wording += 1
            continue
        candidates.append(
            {
                "startMs": min(window[0] for window in windows),
                "endMs": max(window[1] for window in windows),
                "kind": "WORDING",
                "title": DIFFERENCE_MARKER_TITLES[difference.kind],
                "lines": [
                    f"Issue: {DIFFERENCE_MARKER_TITLES[difference.kind]}",
                    f"Approved script: {difference.reference_text or '—'}",
                    f"Premiere transcript: {difference.transcript_text or '—'}",
                    "Script context: "
                    + _context_text(
                        alignment.reference_tokens,
                        difference.reference_start,
                        difference.reference_end,
                    ),
                    "Transcript context: "
                    + _context_text(
                        alignment.transcript_tokens,
                        difference.transcript_start,
                        difference.transcript_end,
                    ),
                ],
            }
        )

    if include_estimated_timing:
        report = alignment_report(alignment)
        for region in report["timingReviewRegions"]:
            start_ms = region.get("startMs")
            end_ms = region.get("endMs")
            if (
                not isinstance(start_ms, int)
                or not isinstance(end_ms, int)
                or end_ms <= start_ms
            ):
                unplaced_timing += 1
                continue
            candidates.append(
                {
                    "startMs": start_ms,
                    "endMs": end_ms,
                    "kind": "TIMING",
                    "title": "Estimated caption timing",
                    "lines": [
                        "Timing note: Some word positions in this passage were estimated.",
                        f"Approved script: {region['referenceText']}",
                    ],
                }
            )

    candidates.sort(key=lambda item: (item["startMs"], item["endMs"], item["kind"]))
    groups: list[list[dict[str, Any]]] = []
    for candidate in candidates:
        if groups and candidate["startMs"] < max(item["endMs"] for item in groups[-1]):
            groups[-1].append(candidate)
        else:
            groups.append([candidate])

    source_tag = hashlib.sha256(alignment.reference_text.encode("utf-8")).hexdigest()[:12]
    cues: list[Cue] = []
    wording_finding_count = sum(item["kind"] == "WORDING" for item in candidates)
    timing_region_count = sum(item["kind"] == "TIMING" for item in candidates)
    for marker_index, group in enumerate(groups, start=1):
        has_wording = any(item["kind"] == "WORDING" for item in group)
        has_timing = any(item["kind"] == "TIMING" for item in group)
        if has_wording and has_timing:
            title = "Review script difference and timing"
        elif has_wording:
            title = (
                group[0]["title"]
                if len(group) == 1
                else "Review script vs transcript"
            )
        else:
            title = "Estimated caption timing"
        comment_lines: list[str] = []
        for item_index, item in enumerate(group):
            if item_index:
                comment_lines.append("")
            comment_lines.extend(item["lines"])
        if has_wording:
            comment_lines.extend(
                [
                    "",
                    "A difference may come from speech recognition or from the recorded wording. Review the audio and approved script.",
                ]
            )
        comment_lines.extend(
            [
                "",
                f"AA:REFERENCE-SCRIPT-REVIEW:v1:{source_tag}:location-{marker_index:06d}",
            ]
        )
        cues.append(
            Cue(
                id=f"cue_{marker_index:06d}",
                srt_index=marker_index,
                start_ms=min(item["startMs"] for item in group),
                end_ms=max(item["endMs"] for item in group),
                # The Recipe treats the first line as the Premiere marker name
                # and everything after it as the marker comment.
                text=title + "\n" + "\n".join(comment_lines),
            )
        )

    return TimedTextDocument(
        language="",
        source_kind="reference-script-review-markers",
        source_name="Reference Script Review",
        timing_fidelity=TimingFidelity.CUE_ALIGNED_ONLY,
        cues=cues,
        metadata={
            "markerCount": len(cues),
            "wordingFindingCount": wording_finding_count,
            "estimatedTimingRegionCount": timing_region_count,
            "unplacedWordingFindingCount": unplaced_wording,
            "unplacedEstimatedTimingRegionCount": unplaced_timing,
        },
    )
