from __future__ import annotations

import csv
import math
import re
from pathlib import Path
from typing import Any, Iterable


CONTRACT_VERSION = 1
MIN_GROUP_GAP_MS = 250
INTEGER_RE = re.compile(r"^(?:0|[1-9][0-9]*)$")
PREMIERE_REF_RE = re.compile(r"::(?:SEQ|PI|BIN|TRACK|ITEM|MARKER)\b", re.IGNORECASE)
ABSOLUTE_PATH_RE = re.compile(
    r"(?:^|[\s\"'])/(?:Users|Volumes|private|tmp|var)/|[A-Za-z]:[\\/]"
)
DIAGNOSTIC_SEVERITIES = {"REVIEW", "WARNING", "MISSING"}
DIAGNOSTIC_REASON_CODES = {
    "ACCEPTED_PARTIAL",
    "NON_IDENTICAL_WORDING",
    "AMBIGUOUS_CANDIDATE_EXCLUDED",
    "NO_MATCHING_TAKE",
}
SOURCE_MARKER_STATUSES = {"EXACT", "WORDING", "PARTIAL"}
SOURCE_MARKER_REASON_CODES = {
    "EXACT_REFERENCE_MATCH",
    "NON_IDENTICAL_WORDING",
    "ACCEPTED_PARTIAL",
}


def _finite_float(value: str, label: str, *, non_negative: bool = False) -> float:
    try:
        parsed = float(value)
    except ValueError as error:
        raise ValueError(f"{label} must be a number.") from error
    if not math.isfinite(parsed):
        raise ValueError(f"{label} must be finite.")
    if non_negative and parsed < 0:
        raise ValueError(f"{label} must be non-negative.")
    return parsed


def _non_negative_int(value: str, label: str) -> int:
    if not INTEGER_RE.fullmatch(value):
        raise ValueError(f"{label} must be a non-negative integer.")
    return int(value)


def _seconds(value_ms: int) -> str:
    return f"{value_ms / 1000.0:.3f}"


def _validate_safe_marker_text(value: str, label: str) -> None:
    if PREMIERE_REF_RE.search(value) or ABSOLUTE_PATH_RE.search(value):
        raise ValueError(f"{label} contains a Premiere ref or absolute filesystem path.")


def _expected_row(group: dict[str, Any], occurrence: dict[str, Any]) -> dict[str, str]:
    return {
        "groupId": str(group["groupId"]),
        "groupOrder": str(group["groupOrder"]),
        "occurrenceId": str(occurrence["occurrenceId"]),
        "recordingOrder": str(occurrence["recordingOrder"]),
        "videoTrackIndex": str(occurrence["videoTrackIndex"]),
        "audioTrackIndex": str(occurrence["audioTrackIndex"]),
        "sequenceStartSeconds": _seconds(occurrence["sequenceStartMs"]),
        "sourceInSeconds": _seconds(occurrence["sourceInMs"]),
        "sourceOutSeconds": _seconds(occurrence["sourceOutMs"]),
        "speechInSeconds": _seconds(occurrence["speechInMs"]),
        "speechOutSeconds": _seconds(occurrence["speechOutMs"]),
        "sourceId": str(occurrence["sourceId"]),
        "sourceOrder": str(occurrence["sourceOrder"]),
        "matchClass": str(occurrence["matchClass"]),
        "similarity": f"{occurrence['similarity']:.6f}",
        "referenceCoverage": f"{occurrence['referenceCoverage']:.6f}",
        "occurrenceCoverage": f"{occurrence['occurrenceCoverage']:.6f}",
        "isPartial": str(occurrence["isPartial"]).lower(),
    }


def _expected_marker_row(diagnostic: dict[str, Any]) -> dict[str, str]:
    return {
        "diagnosticId": str(diagnostic["diagnosticId"]),
        "severity": str(diagnostic["severity"]),
        "reasonCode": ",".join(diagnostic["reasonCodes"]),
        "markerName": str(diagnostic["markerName"]),
        "markerComment": str(diagnostic["markerComment"]),
        "sequenceStartSeconds": _seconds(diagnostic["sequenceStartMs"]),
        "durationSeconds": _seconds(diagnostic["durationMs"]),
        "groupId": str(diagnostic["groupId"]),
        "referenceUnitId": str(diagnostic["referenceUnitId"]),
    }


def _expected_source_marker_row(marker: dict[str, Any]) -> dict[str, str]:
    return {
        "sourceMarkerId": str(marker["sourceMarkerId"]),
        "sourceId": str(marker["sourceId"]),
        "occurrenceId": str(marker["occurrenceId"]),
        "groupId": str(marker["groupId"]),
        "referenceUnitId": str(marker["referenceUnitId"]),
        "status": str(marker["status"]),
        "reasonCode": ",".join(marker["reasonCodes"]),
        "markerName": str(marker["markerName"]),
        "markerComment": str(marker["markerComment"]),
        "sourceStartSeconds": _seconds(marker["sourceStartMs"]),
        "durationSeconds": _seconds(marker["durationMs"]),
        "colorIndex": str(marker["colorIndex"]),
    }


def validate_analysis(analysis: dict[str, Any], source_ids: Iterable[str]) -> None:
    expected_sources = list(source_ids)
    if not expected_sources or len(set(expected_sources)) != len(expected_sources):
        raise ValueError("Session source IDs must be non-empty and unique.")
    if analysis.get("contractVersion") != CONTRACT_VERSION:
        raise ValueError("Analysis contractVersion is unsupported.")
    if analysis.get("sourceOrder") != expected_sources:
        raise ValueError("Analysis sourceOrder is not the exact session source permutation.")
    if analysis.get("orderMode") not in {
        "CAPTURE_DATE",
        "PREMIERE_SELECTION_API",
    }:
        raise ValueError("Analysis orderMode is unsupported.")
    order_warnings = analysis.get("orderWarnings")
    if not isinstance(order_warnings, list):
        raise ValueError("Analysis orderWarnings must be a list.")
    for warning in order_warnings:
        if (
            not isinstance(warning, dict)
            or not isinstance(warning.get("reasonCode"), str)
            or not warning["reasonCode"]
            or not isinstance(warning.get("message"), str)
            or not warning["message"]
            or not isinstance(warning.get("sourceIds"), list)
            or any(source_id not in expected_sources for source_id in warning["sourceIds"])
        ):
            raise ValueError("Analysis order warning is inconsistent.")
    groups = analysis.get("groups")
    if not isinstance(groups, list):
        raise ValueError("Analysis groups must be a list.")
    seen_occurrences: set[str] = set()
    seen_passages: set[str] = set()
    seen_source_intervals: set[tuple[str, int, int]] = set()
    source_intervals: dict[str, list[tuple[int, int, str]]] = {}
    track_intervals: dict[int, list[tuple[int, int, str]]] = {}
    previous_group_end = -MIN_GROUP_GAP_MS
    for group_order, group in enumerate(groups):
        if group.get("groupOrder") != group_order:
            raise ValueError("Analysis groupOrder values must be contiguous and zero-based.")
        occurrences = group.get("occurrences")
        if not isinstance(occurrences, list) or not occurrences:
            raise ValueError(f"Analysis group {group_order} has no occurrences.")
        timeline_start = group.get("timelineStartMs")
        duration = group.get("durationMs")
        if (
            not isinstance(timeline_start, int)
            or timeline_start < 0
            or not isinstance(duration, int)
            or duration <= 0
        ):
            raise ValueError(f"Analysis group {group_order} has invalid timeline bounds.")
        if timeline_start < previous_group_end + MIN_GROUP_GAP_MS:
            raise ValueError(f"Analysis group {group_order} violates the minimum group gap.")
        previous_group_end = timeline_start + duration
        expected_recording_orders = list(range(len(occurrences)))
        if [item.get("recordingOrder") for item in occurrences] != expected_recording_orders:
            raise ValueError(f"Analysis group {group_order} recordingOrder is invalid.")
        expected_tracks = list(reversed(expected_recording_orders))
        if [item.get("videoTrackIndex") for item in occurrences] != expected_tracks:
            raise ValueError(f"Analysis group {group_order} video track order is invalid.")
        if [item.get("audioTrackIndex") for item in occurrences] != expected_tracks:
            raise ValueError(f"Analysis group {group_order} audio track order is invalid.")
        for occurrence in occurrences:
            occurrence_id = occurrence.get("occurrenceId")
            if not isinstance(occurrence_id, str) or occurrence_id in seen_occurrences:
                raise ValueError("Analysis occurrence IDs must be non-empty and unique.")
            seen_occurrences.add(occurrence_id)
            passage_id = occurrence.get("passageId")
            if not isinstance(passage_id, str) or not passage_id or passage_id in seen_passages:
                raise ValueError("Analysis passage IDs must be non-empty and unique.")
            seen_passages.add(passage_id)
            source_id = occurrence.get("sourceId")
            if source_id not in expected_sources:
                raise ValueError(f"Unknown source ID in analysis: {source_id!r}")
            if occurrence.get("sourceOrder") != expected_sources.index(source_id):
                raise ValueError(f"Incorrect sourceOrder for occurrence {occurrence_id}.")
            if occurrence.get("matchClass") not in {
                "ACCEPT_FULL",
                "ACCEPT_PARTIAL",
                "SINGLETON_PASS_THROUGH",
            }:
                raise ValueError(f"Non-placeable matchClass for occurrence {occurrence_id}.")
            source_in = occurrence.get("sourceInMs")
            source_out = occurrence.get("sourceOutMs")
            content_in = occurrence.get("contentInMs")
            content_out = occurrence.get("contentOutMs")
            speech_in = occurrence.get("speechInMs")
            speech_out = occurrence.get("speechOutMs")
            sequence_start = occurrence.get("sequenceStartMs")
            if (
                not isinstance(source_in, int)
                or not isinstance(source_out, int)
                or source_out <= source_in
                or not isinstance(content_in, int)
                or not isinstance(content_out, int)
                or content_in < source_in
                or content_out > source_out
                or content_out <= content_in
                or not isinstance(speech_in, int)
                or not isinstance(speech_out, int)
                or speech_in < source_in
                or speech_out > source_out
                or speech_out <= speech_in
                or not isinstance(sequence_start, int)
                or sequence_start < 0
            ):
                raise ValueError(f"Invalid timing for occurrence {occurrence_id}.")
            sequence_end = sequence_start + source_out - source_in
            if sequence_start < timeline_start or sequence_end > timeline_start + duration:
                raise ValueError(f"Occurrence {occurrence_id} exceeds its group bounds.")
            interval = (source_id, content_in, content_out)
            if interval in seen_source_intervals:
                raise ValueError(f"Duplicate source interval in analysis: {interval!r}")
            seen_source_intervals.add(interval)
            source_intervals.setdefault(source_id, []).append(
                (content_in, content_out, occurrence_id)
            )
            track_index = occurrence["videoTrackIndex"]
            track_intervals.setdefault(track_index, []).append(
                (sequence_start, sequence_end, occurrence_id)
            )
    for track_index, intervals in track_intervals.items():
        intervals.sort()
        for previous, current in zip(intervals, intervals[1:]):
            if current[0] < previous[1]:
                raise ValueError(
                    f"Same-track overlap on track {track_index}: "
                    f"{previous[2]} and {current[2]}."
                )
    for source_id, intervals in source_intervals.items():
        intervals.sort()
        for previous, current in zip(intervals, intervals[1:]):
            if current[0] < previous[1]:
                raise ValueError(
                    f"Overlapping source assignment for {source_id}: "
                    f"{previous[2]} and {current[2]}."
                )
    summary = analysis.get("summary")
    if not isinstance(summary, dict):
        raise ValueError("Analysis summary is missing.")
    expected_summary = {
        "groupCount": len(groups),
        "repeatedGroupCount": sum(len(group["occurrences"]) >= 2 for group in groups),
        "singletonGroupCount": sum(len(group["occurrences"]) == 1 for group in groups),
        "occurrenceCount": sum(len(group["occurrences"]) for group in groups),
        "maxTrackCount": max((len(group["occurrences"]) for group in groups), default=0),
    }
    for field, value in expected_summary.items():
        if summary.get(field) != value:
            raise ValueError(f"Analysis summary field {field} is inconsistent.")
    reference_units = analysis.get("referenceTimelineUnits")
    if not isinstance(reference_units, list):
        raise ValueError("Analysis referenceTimelineUnits must be a list.")
    if analysis.get("mode") == "SELF_DISCOVERY" and reference_units:
        raise ValueError("Self-discovery analysis cannot contain reference timeline units.")
    known_groups = {str(group["groupId"]): group for group in groups}
    known_reference_units: dict[str, dict[str, Any]] = {}
    previous_unit_end = -MIN_GROUP_GAP_MS
    for index, unit in enumerate(reference_units):
        reference_unit_id = unit.get("referenceUnitId")
        if (
            not isinstance(reference_unit_id, str)
            or not reference_unit_id
            or reference_unit_id in known_reference_units
        ):
            raise ValueError("Reference timeline unit IDs must be non-empty and unique.")
        timeline_start = unit.get("timelineStartMs")
        duration = unit.get("durationMs")
        group_id = unit.get("groupId")
        if (
            not isinstance(timeline_start, int)
            or timeline_start < 0
            or not isinstance(duration, int)
            or duration <= 0
        ):
            raise ValueError(f"Reference timeline unit {index} has invalid bounds.")
        if timeline_start < previous_unit_end + MIN_GROUP_GAP_MS:
            raise ValueError("Reference timeline units violate the minimum gap.")
        previous_unit_end = timeline_start + duration
        if group_id:
            group = known_groups.get(str(group_id))
            if not group:
                raise ValueError(f"Unknown reference timeline group: {group_id!r}")
            if group.get("referenceUnitId") != reference_unit_id:
                raise ValueError("Reference timeline unit/group linkage is inconsistent.")
            if (
                group["timelineStartMs"] != timeline_start
                or group["durationMs"] != duration
            ):
                raise ValueError("Reference timeline unit/group bounds are inconsistent.")
        known_reference_units[reference_unit_id] = unit
    expected_unmatched = [
        str(unit.get("text", "")) for unit in reference_units if not unit.get("groupId")
    ]
    if summary.get("unmatchedReferencePassages") != expected_unmatched:
        raise ValueError("Analysis unmatchedReferencePassages is inconsistent.")
    diagnostics = analysis.get("diagnostics")
    if not isinstance(diagnostics, list):
        raise ValueError("Analysis diagnostics must be a list.")
    seen_diagnostics: set[str] = set()
    seen_targets: set[str] = set()
    for diagnostic_index, diagnostic in enumerate(diagnostics):
        diagnostic_id = diagnostic.get("diagnosticId")
        if (
            diagnostic_id != f"diagnostic-{diagnostic_index:04d}"
            or diagnostic_id in seen_diagnostics
        ):
            raise ValueError("Diagnostic IDs must be unique, contiguous, and stable.")
        seen_diagnostics.add(diagnostic_id)
        severity = diagnostic.get("severity")
        if severity not in DIAGNOSTIC_SEVERITIES:
            raise ValueError(f"Unsupported diagnostic severity: {severity!r}")
        reason_codes = diagnostic.get("reasonCodes")
        if (
            not isinstance(reason_codes, list)
            or not reason_codes
            or len(reason_codes) != len(set(reason_codes))
            or any(code not in DIAGNOSTIC_REASON_CODES for code in reason_codes)
        ):
            raise ValueError(f"Invalid reason codes for diagnostic {diagnostic_id}.")
        start = diagnostic.get("sequenceStartMs")
        duration = diagnostic.get("durationMs")
        if (
            not isinstance(start, int)
            or start < 0
            or not isinstance(duration, int)
            or duration <= 0
        ):
            raise ValueError(f"Invalid timing for diagnostic {diagnostic_id}.")
        group_id = diagnostic.get("groupId")
        reference_unit_id = diagnostic.get("referenceUnitId")
        group = known_groups.get(str(group_id)) if group_id else None
        unit = (
            known_reference_units.get(str(reference_unit_id))
            if reference_unit_id
            else None
        )
        if not group and not unit:
            raise ValueError(f"Diagnostic {diagnostic_id} has no known target.")
        target_id = str(group_id or reference_unit_id)
        if target_id in seen_targets:
            raise ValueError(f"More than one marker targets {target_id}.")
        seen_targets.add(target_id)
        target = group or unit
        if start != target["timelineStartMs"] or duration != target["durationMs"]:
            raise ValueError(f"Diagnostic {diagnostic_id} does not span its target.")
        if not isinstance(diagnostic.get("markerName"), str) or not diagnostic["markerName"]:
            raise ValueError(f"Diagnostic {diagnostic_id} has no marker name.")
        if (
            not isinstance(diagnostic.get("markerComment"), str)
            or not diagnostic["markerComment"]
        ):
            raise ValueError(f"Diagnostic {diagnostic_id} has no marker comment.")
        _validate_safe_marker_text(
            diagnostic["markerName"], f"Diagnostic {diagnostic_id} markerName"
        )
        _validate_safe_marker_text(
            diagnostic["markerComment"], f"Diagnostic {diagnostic_id} markerComment"
        )
        if severity == "MISSING" and "NO_MATCHING_TAKE" not in reason_codes:
            raise ValueError("MISSING diagnostics require NO_MATCHING_TAKE.")
        if severity != "MISSING" and "NO_MATCHING_TAKE" in reason_codes:
            raise ValueError("NO_MATCHING_TAKE requires MISSING severity.")
        if severity == "WARNING" and "AMBIGUOUS_CANDIDATE_EXCLUDED" not in reason_codes:
            raise ValueError("WARNING diagnostics require an excluded ambiguity.")
        if severity == "REVIEW" and "AMBIGUOUS_CANDIDATE_EXCLUDED" in reason_codes:
            raise ValueError("Excluded ambiguities require WARNING or MISSING severity.")
    diagnostic_summary = {
        "diagnosticCount": len(diagnostics),
        "reviewMarkerCount": sum(item["severity"] == "REVIEW" for item in diagnostics),
        "warningMarkerCount": sum(item["severity"] == "WARNING" for item in diagnostics),
        "missingMarkerCount": sum(item["severity"] == "MISSING" for item in diagnostics),
    }
    for field, value in diagnostic_summary.items():
        if summary.get(field) != value:
            raise ValueError(f"Analysis summary field {field} is inconsistent.")
    source_markers = analysis.get("sourceMarkers")
    if not isinstance(source_markers, list):
        raise ValueError("Analysis sourceMarkers must be a list.")
    if analysis.get("mode") == "SELF_DISCOVERY" and source_markers:
        raise ValueError("Self-discovery analysis cannot contain source markers.")
    occurrences_by_id = {
        occurrence["occurrenceId"]: occurrence
        for group in groups
        for occurrence in group["occurrences"]
    }
    seen_source_marker_ids: set[str] = set()
    seen_source_marker_occurrences: set[str] = set()
    for marker_index, marker in enumerate(source_markers):
        marker_id = marker.get("sourceMarkerId")
        if (
            marker_id != f"source-marker-{marker_index:04d}"
            or marker_id in seen_source_marker_ids
        ):
            raise ValueError("Source marker IDs must be unique, contiguous, and stable.")
        seen_source_marker_ids.add(marker_id)
        occurrence_id = marker.get("occurrenceId")
        occurrence = occurrences_by_id.get(str(occurrence_id))
        if not occurrence or occurrence_id in seen_source_marker_occurrences:
            raise ValueError("Every source marker must target one unique occurrence.")
        seen_source_marker_occurrences.add(str(occurrence_id))
        if marker.get("sourceId") != occurrence["sourceId"]:
            raise ValueError(f"Source marker {marker_id} sourceId is inconsistent.")
        group = known_groups.get(str(marker.get("groupId")))
        if (
            not group
            or marker.get("referenceUnitId") != group.get("referenceUnitId")
            or occurrence not in group["occurrences"]
        ):
            raise ValueError(f"Source marker {marker_id} linkage is inconsistent.")
        status = marker.get("status")
        if status not in SOURCE_MARKER_STATUSES:
            raise ValueError(f"Unsupported source marker status: {status!r}")
        expected_status = (
            "PARTIAL"
            if occurrence["matchClass"] == "ACCEPT_PARTIAL"
            else (
                "EXACT"
                if marker.get("reasonCodes") == ["EXACT_REFERENCE_MATCH"]
                else "WORDING"
            )
        )
        if status != expected_status:
            raise ValueError(f"Source marker {marker_id} status is inconsistent.")
        reason_codes = marker.get("reasonCodes")
        if (
            not isinstance(reason_codes, list)
            or len(reason_codes) != 1
            or reason_codes[0] not in SOURCE_MARKER_REASON_CODES
        ):
            raise ValueError(f"Source marker {marker_id} reason code is invalid.")
        expected_reason = {
            "EXACT": "EXACT_REFERENCE_MATCH",
            "WORDING": "NON_IDENTICAL_WORDING",
            "PARTIAL": "ACCEPTED_PARTIAL",
        }[status]
        if reason_codes != [expected_reason]:
            raise ValueError(f"Source marker {marker_id} reason/status mismatch.")
        start = marker.get("sourceStartMs")
        duration = marker.get("durationMs")
        if (
            not isinstance(start, int)
            or not isinstance(duration, int)
            or duration < 0
            or start < occurrence["sourceInMs"]
            or start + duration > occurrence["sourceOutMs"]
        ):
            raise ValueError(f"Source marker {marker_id} timing is invalid.")
        expected_color = {"EXACT": 2, "WORDING": 3, "PARTIAL": 1}[status]
        if marker.get("colorIndex") != expected_color:
            raise ValueError(f"Source marker {marker_id} color is inconsistent.")
        if (
            not isinstance(marker.get("markerName"), str)
            or not marker["markerName"].startswith("AA-RT · ")
            or not isinstance(marker.get("markerComment"), str)
            or "automation-agent:repeated-takes" not in marker["markerComment"]
        ):
            raise ValueError(f"Source marker {marker_id} ownership text is invalid.")
        _validate_safe_marker_text(
            marker["markerName"], f"Source marker {marker_id} markerName"
        )
        _validate_safe_marker_text(
            marker["markerComment"], f"Source marker {marker_id} markerComment"
        )
    expected_source_marker_count = (
        len(occurrences_by_id) if analysis.get("mode") == "REFERENCE_GUIDED" else 0
    )
    if len(source_markers) != expected_source_marker_count:
        raise ValueError("Reference mode requires one source marker per occurrence.")
    source_marker_summary = {
        "sourceMarkerCount": len(source_markers),
        "exactTakeMarkerCount": sum(
            item["status"] == "EXACT" for item in source_markers
        ),
        "wordingTakeMarkerCount": sum(
            item["status"] == "WORDING" for item in source_markers
        ),
        "partialTakeMarkerCount": sum(
            item["status"] == "PARTIAL" for item in source_markers
        ),
    }
    for field, value in source_marker_summary.items():
        if summary.get(field) != value:
            raise ValueError(f"Analysis summary field {field} is inconsistent.")


def validate_placements_tsv(
    path: Path, expected_columns: tuple[str, ...], analysis: dict[str, Any]
) -> int:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, dialect="excel-tab")
        if tuple(reader.fieldnames or ()) != expected_columns:
            raise ValueError("placements.tsv header does not match the frozen contract.")
        rows = list(reader)
    expected_rows = [
        _expected_row(group, occurrence)
        for group in analysis["groups"]
        for occurrence in sorted(
            group["occurrences"],
            key=lambda item: (
                item["videoTrackIndex"],
                item["audioTrackIndex"],
                item["recordingOrder"],
            ),
        )
    ]
    if len(rows) != len(expected_rows):
        raise ValueError("placements.tsv row count does not match analysis.json.")
    for row_index, (row, expected) in enumerate(zip(rows, expected_rows), start=2):
        group_order = _non_negative_int(row["groupOrder"], f"row {row_index} groupOrder")
        recording_order = _non_negative_int(
            row["recordingOrder"], f"row {row_index} recordingOrder"
        )
        video_track = _non_negative_int(
            row["videoTrackIndex"], f"row {row_index} videoTrackIndex"
        )
        audio_track = _non_negative_int(
            row["audioTrackIndex"], f"row {row_index} audioTrackIndex"
        )
        source_order = _non_negative_int(row["sourceOrder"], f"row {row_index} sourceOrder")
        sequence_start = _finite_float(
            row["sequenceStartSeconds"],
            f"row {row_index} sequenceStartSeconds",
            non_negative=True,
        )
        source_in = _finite_float(
            row["sourceInSeconds"], f"row {row_index} sourceInSeconds", non_negative=True
        )
        source_out = _finite_float(
            row["sourceOutSeconds"], f"row {row_index} sourceOutSeconds", non_negative=True
        )
        speech_in = _finite_float(
            row["speechInSeconds"], f"row {row_index} speechInSeconds", non_negative=True
        )
        speech_out = _finite_float(
            row["speechOutSeconds"], f"row {row_index} speechOutSeconds", non_negative=True
        )
        similarity = _finite_float(row["similarity"], f"row {row_index} similarity")
        reference_coverage = _finite_float(
            row["referenceCoverage"], f"row {row_index} referenceCoverage"
        )
        occurrence_coverage = _finite_float(
            row["occurrenceCoverage"], f"row {row_index} occurrenceCoverage"
        )
        if source_out <= source_in:
            raise ValueError(f"row {row_index} sourceOutSeconds must exceed sourceInSeconds.")
        if speech_out <= speech_in or speech_in < source_in or speech_out > source_out:
            raise ValueError(f"row {row_index} speech range must be inside the source range.")
        if video_track != audio_track:
            raise ValueError(f"row {row_index} video/audio track indices must match.")
        if group_order < 0 or recording_order < 0 or source_order < 0 or sequence_start < 0:
            raise ValueError(f"row {row_index} contains a negative index or timeline value.")
        if not 0 <= similarity <= 1:
            raise ValueError(f"row {row_index} similarity must be between zero and one.")
        if not 0 <= reference_coverage <= 1 or not 0 <= occurrence_coverage <= 1:
            raise ValueError(f"row {row_index} coverage must be between zero and one.")
        if row["isPartial"] not in {"true", "false"}:
            raise ValueError(f"row {row_index} isPartial must be true or false.")
        for column in expected_columns:
            if row[column] != expected[column]:
                raise ValueError(
                    f"placements.tsv row {row_index} column {column} "
                    "does not match analysis.json."
                )
    return len(rows)


def validate_markers_tsv(
    path: Path, expected_columns: tuple[str, ...], analysis: dict[str, Any]
) -> int:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, dialect="excel-tab")
        if tuple(reader.fieldnames or ()) != expected_columns:
            raise ValueError("markers.tsv header does not match the frozen contract.")
        rows = list(reader)
    expected_rows = [
        _expected_marker_row(diagnostic) for diagnostic in analysis["diagnostics"]
    ]
    if len(rows) != len(expected_rows):
        raise ValueError("markers.tsv row count does not match analysis.json.")
    for row_index, (row, expected) in enumerate(zip(rows, expected_rows), start=2):
        start = _finite_float(
            row["sequenceStartSeconds"],
            f"row {row_index} sequenceStartSeconds",
            non_negative=True,
        )
        duration = _finite_float(
            row["durationSeconds"],
            f"row {row_index} durationSeconds",
            non_negative=True,
        )
        if duration <= 0:
            raise ValueError(f"row {row_index} durationSeconds must be positive.")
        if row["severity"] not in DIAGNOSTIC_SEVERITIES:
            raise ValueError(f"row {row_index} severity is unsupported.")
        if not row["diagnosticId"] or not row["markerName"] or not row["markerComment"]:
            raise ValueError(f"row {row_index} has an empty required marker field.")
        if not row["groupId"] and not row["referenceUnitId"]:
            raise ValueError(f"row {row_index} has no marker target.")
        if start < 0:
            raise ValueError(f"row {row_index} contains a negative timeline value.")
        for column in expected_columns:
            if row[column] != expected[column]:
                raise ValueError(
                    f"markers.tsv row {row_index} column {column} "
                    "does not match analysis.json."
                )
    return len(rows)


def validate_source_markers_tsv(
    path: Path, expected_columns: tuple[str, ...], analysis: dict[str, Any]
) -> int:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, dialect="excel-tab")
        if tuple(reader.fieldnames or ()) != expected_columns:
            raise ValueError(
                "source-markers.tsv header does not match the frozen contract."
            )
        rows = list(reader)
    expected_rows = [
        _expected_source_marker_row(marker) for marker in analysis["sourceMarkers"]
    ]
    if len(rows) != len(expected_rows):
        raise ValueError(
            "source-markers.tsv row count does not match analysis.json."
        )
    for row_index, (row, expected) in enumerate(zip(rows, expected_rows), start=2):
        start = _finite_float(
            row["sourceStartSeconds"],
            f"row {row_index} sourceStartSeconds",
            non_negative=True,
        )
        duration = _finite_float(
            row["durationSeconds"],
            f"row {row_index} durationSeconds",
            non_negative=True,
        )
        color_index = _non_negative_int(
            row["colorIndex"], f"row {row_index} colorIndex"
        )
        if row["status"] not in SOURCE_MARKER_STATUSES:
            raise ValueError(f"row {row_index} source marker status is unsupported.")
        if (
            not row["sourceMarkerId"]
            or not row["sourceId"]
            or not row["occurrenceId"]
            or not row["groupId"]
            or not row["referenceUnitId"]
            or not row["markerName"]
            or not row["markerComment"]
        ):
            raise ValueError(
                f"row {row_index} has an empty required source marker field."
            )
        if start < 0 or duration < 0 or color_index < 0:
            raise ValueError(
                f"row {row_index} contains a negative source marker value."
            )
        for column in expected_columns:
            if row[column] != expected[column]:
                raise ValueError(
                    f"source-markers.tsv row {row_index} column {column} "
                    "does not match analysis.json."
                )
    return len(rows)
