from __future__ import annotations

import json
import re
import sys
from collections import Counter
from dataclasses import dataclass
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any

from lexical_alignment import (
    AlignmentMetrics,
    anchored_candidate_indices,
    build_informative_index,
    intervals_overlap,
    local_window_alignments,
    measure_alignment,
    normalize_word,
)
from target_text import TargetUnit, parse_target_text
from transcript import SENTENCE_END_RE, extract_transcript_words, iter_transcript_word_records


PROFILE_VERSION = "offline-transcript-assembly-v0.1"
CONTRACT_VERSION = 1
MIN_MATCHED_TOKENS = 4
MIN_INFORMATIVE_TOKENS = 3
NEAR_SIMILARITY = 0.77
NEAR_CHARACTER_SIMILARITY = 0.80
PARTIAL_SIMILARITY = 0.68
PARTIAL_REFERENCE_COVERAGE = 0.45
PARTIAL_OCCURRENCE_COVERAGE = 0.82
PAUSE_BOUNDARY_MS = 900
MISSING_PLACEHOLDER_MS = 1000
DEFAULT_BLOCK_GAP_MS = 750

LOW_INFORMATION_WORDS = {
    "a", "an", "and", "are", "as", "at", "be", "but", "der", "die", "das",
    "ein", "eine", "er", "for", "halt", "i", "ich", "im", "in", "is", "it",
    "ja", "me", "mit", "of", "on", "or", "so", "the", "to", "und", "was",
    "we", "what", "you", "äh", "ähm", "hm", "know", "mean", "du", "weißt",
    "meine",
}
NUMBER_WORDS = {
    "zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
    "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
    "sixteen", "seventeen", "eighteen", "nineteen", "twenty", "thirty",
    "forty", "fifty", "sixty", "seventy", "eighty", "ninety", "hundred",
    "null", "eins", "ein", "eine", "zwei", "drei", "vier", "fünf", "sechs",
    "sieben", "acht", "neun", "zehn", "elf", "zwölf", "dreizehn", "vierzehn",
    "fünfzehn", "sechzehn", "siebzehn", "achtzehn", "neunzehn", "zwanzig",
    "dreißig", "vierzig", "fünfzig", "sechzig", "siebzig", "achtzig",
    "neunzig", "hundert",
}
NEGATION_WORDS = {
    "not", "no", "never", "neither", "nor", "without",
    "nicht", "nie", "niemals", "kein", "keine", "keinen", "keinem", "keiner",
    "ohne",
}


@dataclass(frozen=True)
class SourceWord:
    source_id: str
    source_order: int
    word_id: str
    raw_text: str
    normalized: str
    start_ms: int
    end_ms: int
    sentence_end: bool
    speaker_label: str


@dataclass(frozen=True)
class SourcePassage:
    passage_id: str
    source_id: str
    source_order: int
    words: tuple[SourceWord, ...]

    @property
    def normalized_words(self) -> tuple[SourceWord, ...]:
        return tuple(word for word in self.words if word.normalized)

    @property
    def normalized(self) -> tuple[str, ...]:
        return tuple(word.normalized for word in self.normalized_words)

    @property
    def start_ms(self) -> int:
        return self.words[0].start_ms

    @property
    def end_ms(self) -> int:
        return self.words[-1].end_ms

    @property
    def speaker_label(self) -> str:
        labels = {word.speaker_label for word in self.words if word.speaker_label}
        return next(iter(labels)) if len(labels) == 1 else ""


@dataclass(frozen=True)
class Classification:
    match_class: str
    reason_codes: tuple[str, ...]
    accepted: bool


@dataclass(frozen=True)
class Candidate:
    candidate_id: str
    target_unit_id: str
    passage: SourcePassage
    metrics: AlignmentMetrics
    classification: Classification
    boundary_quality: int
    speaker_agreement: int


def _speaker_for_segment(segment: Any) -> str:
    if not isinstance(segment, dict):
        return ""
    for key in ("speakerName", "speakerLabel", "speaker"):
        value = segment.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def parse_source_transcript(
    transcript: dict[str, Any], *, source_id: str, source_order: int
) -> list[SourceWord]:
    timed_words = extract_transcript_words(transcript)
    raw_records = list(iter_transcript_word_records(transcript))
    if len(timed_words) != len(raw_records):
        raise ValueError("Premiere transcript word mapping is inconsistent.")
    segments = transcript.get("segments")
    if not isinstance(segments, list):
        raise ValueError("Transcript JSON does not contain a segments array.")
    output: list[SourceWord] = []
    for index, (timed, (segment_index, _, _)) in enumerate(
        zip(timed_words, raw_records, strict=True)
    ):
        output.append(
            SourceWord(
                source_id=source_id,
                source_order=source_order,
                word_id=f"{source_id}.w{index:06d}",
                raw_text=timed.text,
                normalized=normalize_word(timed.text),
                start_ms=timed.start_ms,
                end_ms=timed.end_ms,
                sentence_end=bool(SENTENCE_END_RE.search(timed.text)),
                speaker_label=_speaker_for_segment(segments[segment_index]),
            )
        )
    return output


def segment_source_words(words: list[SourceWord]) -> list[SourcePassage]:
    passages: list[SourcePassage] = []
    current: list[SourceWord] = []
    for word in words:
        if current and word.source_id != current[-1].source_id:
            passages.append(_make_passage(current, len(passages)))
            current = []
        if current and word.start_ms - current[-1].end_ms >= PAUSE_BOUNDARY_MS:
            passages.append(_make_passage(current, len(passages)))
            current = []
        current.append(word)
        if word.sentence_end:
            passages.append(_make_passage(current, len(passages)))
            current = []
    if current:
        passages.append(_make_passage(current, len(passages)))
    return [passage for passage in passages if passage.normalized]


def _make_passage(words: list[SourceWord], index: int) -> SourcePassage:
    return SourcePassage(
        passage_id=f"{words[0].source_id}.p{index:06d}",
        source_id=words[0].source_id,
        source_order=words[0].source_order,
        words=tuple(words),
    )


def _slice_passage(
    passage: SourcePassage, start: int, end: int, suffix: str
) -> SourcePassage:
    normalized_words = passage.normalized_words
    selected = normalized_words[start:end]
    return SourcePassage(
        passage_id=f"{passage.passage_id}.{suffix}",
        source_id=passage.source_id,
        source_order=passage.source_order,
        words=selected,
    )


def _changed_words(
    reference: tuple[str, ...],
    occurrence: tuple[str, ...],
    metrics: AlignmentMetrics,
) -> tuple[list[str], list[str]]:
    changed_reference: list[str] = []
    changed_occurrence: list[str] = []
    for tag, reference_start, reference_end, occurrence_start, occurrence_end in (
        metrics.opcodes
    ):
        if tag != "equal":
            changed_reference.extend(reference[reference_start:reference_end])
            changed_occurrence.extend(occurrence[occurrence_start:occurrence_end])
    return changed_reference, changed_occurrence


def classify_alignment(
    reference: tuple[str, ...], occurrence: tuple[str, ...], metrics: AlignmentMetrics
) -> Classification:
    informative_reference = sum(word not in LOW_INFORMATION_WORDS for word in reference)
    informative_occurrence = sum(word not in LOW_INFORMATION_WORDS for word in occurrence)
    if metrics.matched_tokens < MIN_MATCHED_TOKENS:
        return Classification("REJECT_TOO_SHORT", ("TOO_FEW_MATCHED_TOKENS",), False)
    if min(informative_reference, informative_occurrence) < MIN_INFORMATIVE_TOKENS:
        return Classification("REJECT_TOO_COMMON", ("LOW_INFORMATION_WORDING",), False)

    changed_reference, changed_occurrence = _changed_words(
        reference, occurrence, metrics
    )
    informative_changed_reference = [
        word for word in changed_reference if word not in LOW_INFORMATION_WORDS
    ]
    informative_changed_occurrence = [
        word for word in changed_occurrence if word not in LOW_INFORMATION_WORDS
    ]
    number_conflict = (
        {word for word in informative_changed_reference if word.isdigit() or word in NUMBER_WORDS}
        != {word for word in informative_changed_occurrence if word.isdigit() or word in NUMBER_WORDS}
        and any(
            word.isdigit() or word in NUMBER_WORDS
            for word in informative_changed_reference + informative_changed_occurrence
        )
    )
    negation_conflict = Counter(
        word for word in reference if word in NEGATION_WORDS
    ) != Counter(word for word in occurrence if word in NEGATION_WORDS)
    replacements = [opcode for opcode in metrics.opcodes if opcode[0] == "replace"]
    single_informative_substitution = (
        len(reference) == len(occurrence)
        and len(replacements) == 1
        and replacements[0][2] - replacements[0][1] == 1
        and replacements[0][4] - replacements[0][3] == 1
        and reference[replacements[0][1]] not in LOW_INFORMATION_WORDS
        and occurrence[replacements[0][3]] not in LOW_INFORMATION_WORDS
        and SequenceMatcher(
            a=reference[replacements[0][1]],
            b=occurrence[replacements[0][3]],
            autojunk=False,
        ).ratio() < 0.55
    )
    terminal_conflict = (
        reference
        and occurrence
        and reference[-1] != occurrence[-1]
        and reference[-1] not in LOW_INFORMATION_WORDS
        and occurrence[-1] not in LOW_INFORMATION_WORDS
        and SequenceMatcher(a=reference[-1], b=occurrence[-1], autojunk=False).ratio()
        < 0.55
    )
    anchored_factual_conflict = (
        number_conflict or negation_conflict
    ) and (
        min(metrics.reference_coverage, metrics.occurrence_coverage) >= 0.75
        or metrics.anchor_size >= MIN_MATCHED_TOKENS
    )
    coverage_bounded_word_conflict = (
        min(metrics.reference_coverage, metrics.occurrence_coverage) >= 0.75
        and (single_informative_substitution or terminal_conflict)
    )
    if anchored_factual_conflict or coverage_bounded_word_conflict:
        reasons = []
        if number_conflict:
            reasons.append("FACTUAL_NUMBER_CONFLICT")
        if negation_conflict:
            reasons.append("NEGATION_CONFLICT")
        if single_informative_substitution or terminal_conflict:
            reasons.append("INFORMATIVE_WORD_CONFLICT")
        return Classification("AMBIGUOUS_FACTUAL_CONFLICT", tuple(reasons), False)

    if reference == occurrence:
        return Classification("EXACT", ("EXACT_TARGET_WORDING",), True)
    if (
        metrics.token_similarity >= NEAR_SIMILARITY
        and min(metrics.reference_coverage, metrics.occurrence_coverage) >= 0.72
    ) or (
        metrics.character_similarity >= NEAR_CHARACTER_SIMILARITY
        and metrics.reference_coverage >= 0.62
        and metrics.occurrence_coverage >= 0.55
        and metrics.anchor_size >= 3
    ):
        return Classification("NEAR", ("MINOR_WORDING_DIFFERENCE",), True)
    if (
        metrics.reference_coverage >= PARTIAL_REFERENCE_COVERAGE
        and metrics.occurrence_coverage >= PARTIAL_OCCURRENCE_COVERAGE
        and metrics.anchor_size >= MIN_MATCHED_TOKENS
        and metrics.token_similarity >= PARTIAL_SIMILARITY
    ):
        return Classification("PARTIAL", ("PARTIAL_TARGET_WORDING",), True)
    return Classification("REJECT_LOW_SIMILARITY", ("LOW_LEXICAL_SIMILARITY",), False)


def _candidate_rank(candidate: Candidate) -> tuple:
    class_rank = {"EXACT": 3, "NEAR": 2, "PARTIAL": 1}[candidate.classification.match_class]
    return (
        class_rank,
        candidate.metrics.reference_coverage,
        candidate.metrics.matched_tokens,
        max(candidate.metrics.token_similarity, candidate.metrics.character_similarity),
        candidate.boundary_quality,
        candidate.speaker_agreement,
        candidate.passage.source_order,
        candidate.passage.start_ms,
        candidate.passage.end_ms,
        candidate.candidate_id,
    )


def _candidate_score(candidate: Candidate) -> tuple[int, ...]:
    rank = _candidate_rank(candidate)
    return (
        1,
        int(rank[0]),
        int(rank[1] * 1_000_000),
        int(rank[2]),
        int(rank[3] * 1_000_000),
        int(rank[4]),
        int(rank[5]),
        int(rank[6]),
        int(rank[7]),
    )


def _add_score(first: tuple[int, ...], second: tuple[int, ...]) -> tuple[int, ...]:
    return tuple(a + b for a, b in zip(first, second, strict=True))


ZERO_SCORE = (0,) * 9


def _candidates_overlap(first: Candidate, second: Candidate) -> bool:
    return (
        first.passage.source_id == second.passage.source_id
        and intervals_overlap(
            first.passage.start_ms,
            first.passage.end_ms,
            second.passage.start_ms,
            second.passage.end_ms,
        )
    )


def _primary_assignment(
    candidates_by_unit: list[list[Candidate]],
) -> list[Candidate | None]:
    states: dict[str, tuple[tuple[int, ...], tuple[Candidate | None, ...], Candidate | None]] = {
        "NONE": (ZERO_SCORE, tuple(), None)
    }
    for candidates in candidates_by_unit:
        options: list[Candidate | None] = [None, *candidates]
        next_states: dict[
            str, tuple[tuple[int, ...], tuple[Candidate | None, ...], Candidate | None]
        ] = {}
        for option in options:
            option_key = option.candidate_id if option else "NONE"
            for score, path, previous in states.values():
                if option is not None and previous is not None and _candidates_overlap(option, previous):
                    continue
                next_score = _add_score(score, _candidate_score(option) if option else ZERO_SCORE)
                next_path = (*path, option)
                incumbent = next_states.get(option_key)
                tie_key = tuple(item.candidate_id if item else "" for item in next_path)
                if incumbent is None:
                    next_states[option_key] = (next_score, next_path, option)
                    continue
                incumbent_tie = tuple(
                    item.candidate_id if item else "" for item in incumbent[1]
                )
                if next_score > incumbent[0] or (
                    next_score == incumbent[0] and tie_key < incumbent_tie
                ):
                    next_states[option_key] = (next_score, next_path, option)
        states = next_states
    best_score = max(item[0] for item in states.values())
    best = min(
        (item for item in states.values() if item[0] == best_score),
        key=lambda item: tuple(
            "~" if candidate is None else candidate.candidate_id
            for candidate in item[1]
        ),
    )
    return list(best[1])


def _discover_candidates(
    units: list[TargetUnit], passages: list[SourcePassage]
) -> tuple[list[list[Candidate]], list[list[dict[str, Any]]]]:
    index = build_informative_index(
        (passage.normalized for passage in passages), LOW_INFORMATION_WORDS
    )
    accepted_by_unit: list[list[Candidate]] = []
    rejected_by_unit: list[list[dict[str, Any]]] = []
    for unit_index, unit in enumerate(units):
        accepted: dict[tuple[str, int, int], Candidate] = {}
        rejected: list[dict[str, Any]] = []
        passage_indices = anchored_candidate_indices(
            unit.normalized, index, LOW_INFORMATION_WORDS
        )
        for passage_index in sorted(passage_indices):
            passage = passages[passage_index]
            windows = local_window_alignments(
                unit.normalized,
                passage.normalized,
                LOW_INFORMATION_WORDS,
                minimum_window_tokens=MIN_MATCHED_TOKENS,
            )
            for window_index, window in enumerate(windows):
                classification = classify_alignment(
                    unit.normalized,
                    passage.normalized[window.start : window.end],
                    window.metrics,
                )
                window_passage = _slice_passage(
                    passage,
                    window.start,
                    window.end,
                    f"u{unit_index:04d}.w{window_index:04d}",
                )
                boundary_quality = int(window.start == 0) + int(
                    window.end == len(passage.normalized)
                )
                target_speaker = unit.speaker_label.casefold().strip()
                source_speaker = window_passage.speaker_label.casefold().strip()
                speaker_agreement = int(
                    bool(target_speaker)
                    and bool(source_speaker)
                    and target_speaker == source_speaker
                    and not re.fullmatch(r"[0-9a-f-]{12,}", source_speaker)
                )
                candidate = Candidate(
                    candidate_id=(
                        f"{unit.unit_id}.c.{window_passage.source_id}."
                        f"{window_passage.start_ms:010d}.{window_passage.end_ms:010d}"
                    ),
                    target_unit_id=unit.unit_id,
                    passage=window_passage,
                    metrics=window.metrics,
                    classification=classification,
                    boundary_quality=boundary_quality,
                    speaker_agreement=speaker_agreement,
                )
                if classification.accepted:
                    key = (
                        window_passage.source_id,
                        window_passage.start_ms,
                        window_passage.end_ms,
                    )
                    current = accepted.get(key)
                    if current is None or _candidate_rank(candidate) > _candidate_rank(current):
                        accepted[key] = candidate
                elif classification.match_class == "AMBIGUOUS_FACTUAL_CONFLICT":
                    rejected.append(
                        {
                            "candidateId": candidate.candidate_id,
                            "sourceId": window_passage.source_id,
                            "sourceStartMs": window_passage.start_ms,
                            "sourceEndMs": window_passage.end_ms,
                            "matchClass": classification.match_class,
                            "reasonCodes": list(classification.reason_codes),
                        }
                    )
        ranked = sorted(accepted.values(), key=_candidate_rank, reverse=True)
        selected: list[Candidate] = []
        for candidate in ranked:
            if any(_candidates_overlap(candidate, existing) for existing in selected):
                continue
            selected.append(candidate)
        accepted_by_unit.append(selected)
        rejected_by_unit.append(rejected)
    return accepted_by_unit, rejected_by_unit


def _safe_excerpt(text: str, limit: int = 140) -> str:
    compact = " ".join(text.split())
    return compact if len(compact) <= limit else compact[: limit - 1].rstrip() + "…"


def _source_range_with_handles(
    candidate: Candidate,
    all_candidates: list[Candidate],
    source_duration_ms: int,
    handle_before_ms: int,
    handle_after_ms: int,
) -> tuple[int, int]:
    source_in_ms = max(0, candidate.passage.start_ms - handle_before_ms)
    source_out_ms = min(source_duration_ms, candidate.passage.end_ms + handle_after_ms)
    previous_ends = [
        other.passage.end_ms
        for other in all_candidates
        if other.candidate_id != candidate.candidate_id
        and other.passage.source_id == candidate.passage.source_id
        and other.passage.end_ms <= candidate.passage.start_ms
    ]
    following_starts = [
        other.passage.start_ms
        for other in all_candidates
        if other.candidate_id != candidate.candidate_id
        and other.passage.source_id == candidate.passage.source_id
        and other.passage.start_ms >= candidate.passage.end_ms
    ]
    if previous_ends:
        source_in_ms = max(source_in_ms, max(previous_ends))
    if following_starts:
        source_out_ms = min(source_out_ms, min(following_starts))
    return source_in_ms, source_out_ms


def _marker_for_block(
    marker_index: int,
    block: dict[str, Any],
    unit: TargetUnit,
    reason_code: str,
    *,
    conflict_candidates: list[dict[str, Any]],
) -> dict[str, Any]:
    if reason_code == "CLAIMED_BY_ADJACENT_TARGET_UNIT":
        name = "AA-TA · Review adjacent wording"
        severity = "AMBIGUOUS"
        explanation = "A reliable source range was assigned to an adjacent target passage."
    elif conflict_candidates:
        name = "AA-TA · Review changed wording"
        severity = "AMBIGUOUS"
        explanation = "Similar source wording changes a number, negation, or informative word."
    else:
        name = "AA-TA · Missing passage"
        severity = "MISSING"
        explanation = "No reliable lexical source match was found."
    return {
        "markerId": f"sequence-marker-{marker_index:04d}",
        "targetUnitId": unit.unit_id,
        "severity": severity,
        "reasonCodes": [reason_code],
        "markerName": name,
        "markerComment": f"{explanation} Target: {_safe_excerpt(unit.spoken_text)}",
        "sequenceStartMs": block["timelineStartMs"],
        "durationMs": MISSING_PLACEHOLDER_MS,
    }


def analyze_assembly(
    manifest: dict[str, Any],
    target_text: str,
    *,
    base_dir: Path,
) -> dict[str, Any]:
    if manifest.get("contractVersion") != CONTRACT_VERSION:
        raise ValueError("Session manifest contractVersion is unsupported.")
    raw_sources = manifest.get("sources")
    if not isinstance(raw_sources, list) or not raw_sources:
        raise ValueError("Session manifest sources must be a non-empty list.")
    handle_before_ms = int(round(float(manifest.get("handleBeforeSeconds", 0.2)) * 1000))
    handle_after_ms = int(round(float(manifest.get("handleAfterSeconds", 0.3)) * 1000))
    block_gap_ms = int(round(float(manifest.get("blockGapSeconds", 0.75)) * 1000))
    if handle_before_ms < 0 or handle_after_ms < 0 or block_gap_ms < 250:
        raise ValueError("Handles must be non-negative and block gap at least 0.25 seconds.")

    source_ids: list[str] = []
    source_info: dict[str, dict[str, Any]] = {}
    passages: list[SourcePassage] = []
    for source_order, source in enumerate(raw_sources):
        if not isinstance(source, dict):
            raise ValueError(f"Session source {source_order} must be an object.")
        source_id = source.get("sourceId")
        if not isinstance(source_id, str) or not source_id or source_id in source_ids:
            raise ValueError("Session source IDs must be non-empty and unique.")
        if source.get("sourceOrder", source_order) != source_order:
            raise ValueError("Session sources must use contiguous sourceOrder values.")
        transcript_path = source.get("transcriptPath")
        if not isinstance(transcript_path, str) or not transcript_path:
            raise ValueError(f"Session source {source_id} has no transcriptPath.")
        resolved_path = Path(transcript_path)
        if not resolved_path.is_absolute():
            resolved_path = (base_dir / resolved_path).resolve()
        with resolved_path.open("r", encoding="utf-8") as handle:
            transcript = json.load(handle)
        duration_ms_value = source.get("durationMs")
        if duration_ms_value is None:
            duration_ms_value = int(round(float(source.get("durationSeconds", 0)) * 1000))
        if not isinstance(duration_ms_value, int) or duration_ms_value <= 0:
            raise ValueError(f"Session source {source_id} has invalid duration.")
        has_video = source.get("hasVideo", True)
        has_audio = source.get("hasAudio", True)
        if not isinstance(has_video, bool) or not isinstance(has_audio, bool) or not (has_video or has_audio):
            raise ValueError(f"Session source {source_id} has invalid media-kind flags.")
        source_ids.append(source_id)
        source_info[source_id] = {
            "sourceId": source_id,
            "sourceOrder": source_order,
            "displayName": str(source.get("displayName", source_id)),
            "durationMs": duration_ms_value,
            "hasVideo": has_video,
            "hasAudio": has_audio,
        }
        passages.extend(
            segment_source_words(
                parse_source_transcript(
                    transcript,
                    source_id=source_id,
                    source_order=source_order,
                )
            )
        )

    units, target_parse = parse_target_text(target_text)
    candidates_by_unit, rejected_by_unit = _discover_candidates(units, passages)
    primaries = _primary_assignment(candidates_by_unit)

    accepted_by_unit: list[list[Candidate]] = []
    adjacent_conflict_counts = [0 for _ in units]
    for unit_index, (primary, candidates) in enumerate(zip(primaries, candidates_by_unit, strict=True)):
        accepted: list[Candidate] = []
        if primary is not None:
            accepted.append(primary)
        for candidate in candidates:
            if primary is not None and candidate.candidate_id == primary.candidate_id:
                continue
            if any(_candidates_overlap(candidate, existing) for existing in accepted):
                continue
            conflicts_adjacent = False
            for adjacent_index in (unit_index - 1, unit_index + 1):
                if 0 <= adjacent_index < len(primaries):
                    adjacent_primary = primaries[adjacent_index]
                    if adjacent_primary is not None and _candidates_overlap(candidate, adjacent_primary):
                        conflicts_adjacent = True
                        break
            if conflicts_adjacent:
                adjacent_conflict_counts[unit_index] += 1
                continue
            accepted.append(candidate)
        accepted_by_unit.append(accepted)

    timeline_cursor_ms = 0
    blocks: list[dict[str, Any]] = []
    source_markers: list[dict[str, Any]] = []
    sequence_markers: list[dict[str, Any]] = []
    all_accepted_candidates = [
        candidate for candidates in accepted_by_unit for candidate in candidates
    ]
    for unit_index, unit in enumerate(units):
        candidates = accepted_by_unit[unit_index]
        occurrences: list[dict[str, Any]] = []
        for lane_index, candidate in enumerate(candidates):
            info = source_info[candidate.passage.source_id]
            source_in_ms, source_out_ms = _source_range_with_handles(
                candidate,
                all_accepted_candidates,
                info["durationMs"],
                handle_before_ms,
                handle_after_ms,
            )
            occurrence_id = f"{unit.unit_id}.o{lane_index:03d}"
            occurrence = {
                "occurrenceId": occurrence_id,
                "candidateId": candidate.candidate_id,
                "candidateRank": lane_index,
                "sourceId": candidate.passage.source_id,
                "sourceOrder": candidate.passage.source_order,
                "sequenceStartMs": timeline_cursor_ms,
                "sourceInMs": source_in_ms,
                "sourceOutMs": source_out_ms,
                "matchedSourceInMs": candidate.passage.start_ms,
                "matchedSourceOutMs": candidate.passage.end_ms,
                "videoTrackIndex": lane_index if info["hasVideo"] else -1,
                "audioTrackIndex": lane_index if info["hasAudio"] else -1,
                "matchClass": candidate.classification.match_class,
                "reasonCodes": list(candidate.classification.reason_codes),
                "matchedTokens": candidate.metrics.matched_tokens,
                "tokenSimilarity": candidate.metrics.token_similarity,
                "characterSimilarity": candidate.metrics.character_similarity,
                "targetCoverage": candidate.metrics.reference_coverage,
                "occurrenceCoverage": candidate.metrics.occurrence_coverage,
            }
            occurrences.append(occurrence)
            status = {
                "EXACT": "EXACT",
                "NEAR": "WORDING",
                "PARTIAL": "PARTIAL",
            }[candidate.classification.match_class]
            source_markers.append(
                {
                    "sourceMarkerId": f"source-marker-{len(source_markers):04d}",
                    "sourceId": candidate.passage.source_id,
                    "targetUnitId": unit.unit_id,
                    "occurrenceId": occurrence_id,
                    "status": status,
                    "reasonCodes": list(candidate.classification.reason_codes),
                    "markerName": f"AA-TA · {status}",
                    "markerComment": (
                        f"Target: {_safe_excerpt(unit.spoken_text)} | "
                        f"Source: {_safe_excerpt(' '.join(word.raw_text for word in candidate.passage.words))} | "
                        "automation-agent:transcript-assembly"
                    ),
                    "sourceStartMs": candidate.passage.start_ms,
                    "durationMs": max(candidate.passage.end_ms - candidate.passage.start_ms, 1),
                    "colorIndex": {"EXACT": 3, "WORDING": 2, "PARTIAL": 1}[status],
                }
            )
        if occurrences:
            block_duration_ms = max(
                occurrence["sourceOutMs"] - occurrence["sourceInMs"]
                for occurrence in occurrences
            )
            block_status = "PARTIAL" if occurrences[0]["matchClass"] == "PARTIAL" else "MATCHED"
        else:
            block_duration_ms = MISSING_PLACEHOLDER_MS
            block_status = "MISSING"
        block = {
            "blockId": f"block-{unit_index:04d}",
            "blockOrder": unit_index,
            "targetUnitId": unit.unit_id,
            "timelineStartMs": timeline_cursor_ms,
            "durationMs": block_duration_ms,
            "status": block_status,
            "occurrences": occurrences,
        }
        blocks.append(block)
        if not occurrences:
            if adjacent_conflict_counts[unit_index]:
                reason_code = "CLAIMED_BY_ADJACENT_TARGET_UNIT"
            elif rejected_by_unit[unit_index]:
                reason_code = "AMBIGUOUS_FACTUAL_CONFLICT"
            else:
                reason_code = "MISSING_NO_RELIABLE_MATCH"
            sequence_markers.append(
                _marker_for_block(
                    len(sequence_markers),
                    block,
                    unit,
                    reason_code,
                    conflict_candidates=rejected_by_unit[unit_index],
                )
            )
        elif occurrences[0]["matchClass"] == "PARTIAL":
            sequence_markers.append(
                {
                    "markerId": f"sequence-marker-{len(sequence_markers):04d}",
                    "targetUnitId": unit.unit_id,
                    "severity": "REVIEW",
                    "reasonCodes": ["PRIMARY_PARTIAL_MATCH"],
                    "markerName": "AA-TA · Review partial passage",
                    "markerComment": f"Only part of this target was matched: {_safe_excerpt(unit.spoken_text)}",
                    "sequenceStartMs": timeline_cursor_ms,
                    "durationMs": block_duration_ms,
                }
            )
        timeline_cursor_ms += block_duration_ms + block_gap_ms

    reuse_records: list[dict[str, Any]] = []
    for first_index, first_block in enumerate(blocks):
        for second_index in range(first_index + 2, len(blocks)):
            second_block = blocks[second_index]
            for first_occurrence in first_block["occurrences"]:
                for second_occurrence in second_block["occurrences"]:
                    if (
                        first_occurrence["sourceId"] == second_occurrence["sourceId"]
                        and intervals_overlap(
                            first_occurrence["matchedSourceInMs"],
                            first_occurrence["matchedSourceOutMs"],
                            second_occurrence["matchedSourceInMs"],
                            second_occurrence["matchedSourceOutMs"],
                        )
                    ):
                        reuse_records.append(
                            {
                                "reuseId": f"reuse-{len(reuse_records):04d}",
                                "reasonCode": "REUSED_SOURCE_RANGE",
                                "sourceId": first_occurrence["sourceId"],
                                "targetUnitIds": [
                                    first_block["targetUnitId"],
                                    second_block["targetUnitId"],
                                ],
                                "occurrenceIds": [
                                    first_occurrence["occurrenceId"],
                                    second_occurrence["occurrenceId"],
                                ],
                            }
                        )

    analysis = {
        "contractVersion": CONTRACT_VERSION,
        "profileVersion": PROFILE_VERSION,
        "sourceOrder": source_ids,
        "sessionOrder": {
            "mode": str(manifest.get("orderMode", "MANIFEST_ORDER")),
            "warningCodes": list(manifest.get("orderWarningCodes", [])),
            "displayNames": [source_info[source_id]["displayName"] for source_id in source_ids],
        },
        "sources": [source_info[source_id] for source_id in source_ids],
        "targetParse": target_parse,
        "targetUnits": [
            {
                "targetUnitId": unit.unit_id,
                "parentIds": list(unit.parent_ids),
                "rawText": unit.raw_text,
                "spokenText": unit.spoken_text,
                "speakerLabel": unit.speaker_label,
                "metadataTypes": list(unit.metadata_types),
                "normalizedTokens": list(unit.normalized),
                "rejectedCandidates": rejected_by_unit[index],
                "adjacentConflictCount": adjacent_conflict_counts[index],
            }
            for index, unit in enumerate(units)
        ],
        "settings": {
            "handleBeforeMs": handle_before_ms,
            "handleAfterMs": handle_after_ms,
            "blockGapMs": block_gap_ms,
            "missingPlaceholderMs": MISSING_PLACEHOLDER_MS,
        },
        "blocks": blocks,
        "sourceMarkers": source_markers,
        "sequenceMarkers": sequence_markers,
        "sourceReuse": reuse_records,
    }
    analysis["summary"] = {
        "targetUnitCount": len(units),
        "matchedUnitCount": sum(bool(block["occurrences"]) for block in blocks),
        "missingUnitCount": sum(not block["occurrences"] for block in blocks),
        "partialUnitCount": sum(block["status"] == "PARTIAL" for block in blocks),
        "placementCount": sum(len(block["occurrences"]) for block in blocks),
        "alternateCount": sum(max(len(block["occurrences"]) - 1, 0) for block in blocks),
        "sequenceMarkerCount": len(sequence_markers),
        "sourceMarkerCount": len(source_markers),
        "sourceReuseCount": len(reuse_records),
        "exactPlacementCount": sum(
            occurrence["matchClass"] == "EXACT"
            for block in blocks
            for occurrence in block["occurrences"]
        ),
        "nearPlacementCount": sum(
            occurrence["matchClass"] == "NEAR"
            for block in blocks
            for occurrence in block["occurrences"]
        ),
        "partialPlacementCount": sum(
            occurrence["matchClass"] == "PARTIAL"
            for block in blocks
            for occurrence in block["occurrences"]
        ),
        "factualConflictUnitCount": sum(
            "AMBIGUOUS_FACTUAL_CONFLICT" in marker["reasonCodes"]
            for marker in sequence_markers
        ),
        "maxVideoTrackIndex": max(
            (
                occurrence["videoTrackIndex"]
                for block in blocks
                for occurrence in block["occurrences"]
            ),
            default=-1,
        ),
        "maxAudioTrackIndex": max(
            (
                occurrence["audioTrackIndex"]
                for block in blocks
                for occurrence in block["occurrences"]
            ),
            default=-1,
        ),
        "totalTimelineDurationMs": max(timeline_cursor_ms - block_gap_ms, 0),
    }
    return analysis
