from __future__ import annotations

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

from lexical_alignment import intervals_overlap


CONTRACT_VERSION = 1
PROFILE_VERSION = "offline-transcript-assembly-v0.1"
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]:[\\/]"
)
INTEGER_RE = re.compile(r"^-?(?:0|[1-9][0-9]*)$")

PLACEMENT_COLUMNS = (
    "targetUnitId",
    "blockOrder",
    "occurrenceId",
    "candidateRank",
    "videoTrackIndex",
    "audioTrackIndex",
    "sequenceStartSeconds",
    "sourceInSeconds",
    "sourceOutSeconds",
    "matchedSourceInSeconds",
    "matchedSourceOutSeconds",
    "sourceId",
    "matchClass",
    "reasonCodes",
)
SEQUENCE_MARKER_COLUMNS = (
    "markerId",
    "targetUnitId",
    "severity",
    "reasonCodes",
    "markerName",
    "markerComment",
    "sequenceStartSeconds",
    "durationSeconds",
)
SOURCE_MARKER_COLUMNS = (
    "sourceMarkerId",
    "sourceId",
    "targetUnitId",
    "occurrenceId",
    "status",
    "reasonCodes",
    "markerName",
    "markerComment",
    "sourceStartSeconds",
    "durationSeconds",
    "colorIndex",
)
SUMMARY_COLUMNS = (
    "targetUnitCount",
    "matchedUnitCount",
    "missingUnitCount",
    "partialUnitCount",
    "placementCount",
    "alternateCount",
    "sequenceMarkerCount",
    "sourceMarkerCount",
    "sourceReuseCount",
    "exactPlacementCount",
    "nearPlacementCount",
    "partialPlacementCount",
    "factualConflictUnitCount",
    "maxVideoTrackIndex",
    "maxAudioTrackIndex",
    "totalTimelineDurationSeconds",
    "orderMode",
    "orderWarningCodes",
    "sourceOrder",
    "sourceOrderIds",
)


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


def _safe_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 path.")


def _validate_int(value: Any, label: str, *, minimum: int | None = None) -> int:
    if not isinstance(value, int):
        raise ValueError(f"{label} must be an integer.")
    if minimum is not None and value < minimum:
        raise ValueError(f"{label} must be at least {minimum}.")
    return value


def _source_map(analysis: dict[str, Any], issued_source_ids: Iterable[str]) -> dict[str, dict[str, Any]]:
    issued = list(issued_source_ids)
    if not issued or len(set(issued)) != len(issued):
        raise ValueError("Issued source IDs must be non-empty and unique.")
    if analysis.get("sourceOrder") != issued:
        raise ValueError("Analysis sourceOrder is not the exact issued order.")
    sources = analysis.get("sources")
    if not isinstance(sources, list) or len(sources) != len(issued):
        raise ValueError("Analysis sources are inconsistent.")
    result: dict[str, dict[str, Any]] = {}
    for index, source in enumerate(sources):
        if not isinstance(source, dict) or source.get("sourceId") != issued[index]:
            raise ValueError("Analysis source identity/order is inconsistent.")
        if source.get("sourceOrder") != index:
            raise ValueError("Analysis sourceOrder values must be contiguous.")
        if not isinstance(source.get("hasVideo"), bool) or not isinstance(source.get("hasAudio"), bool):
            raise ValueError("Analysis media-kind flags must be boolean.")
        if not source["hasVideo"] and not source["hasAudio"]:
            raise ValueError("Every source must contain audio or video.")
        _validate_int(source.get("durationMs"), "source duration", minimum=1)
        if any(key.lower().endswith("path") or "ref" in key.lower() for key in source):
            raise ValueError("Analysis source metadata must not contain paths or refs.")
        result[source["sourceId"]] = source
    return result


def validate_analysis(analysis: dict[str, Any], issued_source_ids: Iterable[str]) -> None:
    if analysis.get("contractVersion") != CONTRACT_VERSION:
        raise ValueError("Analysis contractVersion is unsupported.")
    if analysis.get("profileVersion") != PROFILE_VERSION:
        raise ValueError("Analysis profileVersion is unsupported.")
    sources = _source_map(analysis, issued_source_ids)
    session_order = analysis.get("sessionOrder")
    if not isinstance(session_order, dict):
        raise ValueError("Analysis sessionOrder is missing.")
    if session_order.get("mode") not in {
        "CAPTURE_DATE",
        "PREMIERE_SELECTION_API",
        "MANIFEST_ORDER",
    }:
        raise ValueError("Analysis sessionOrder mode is unsupported.")
    warning_codes = session_order.get("warningCodes")
    display_names = session_order.get("displayNames")
    if not isinstance(warning_codes, list) or not all(
        isinstance(code, str) for code in warning_codes
    ):
        raise ValueError("Analysis sessionOrder warning codes are invalid.")
    if not isinstance(display_names, list) or len(display_names) != len(sources):
        raise ValueError("Analysis sessionOrder display names are inconsistent.")
    for display_name in display_names:
        if not isinstance(display_name, str):
            raise ValueError("Analysis sessionOrder display names must be strings.")
        _safe_text(display_name, "source display name")
    if display_names != [source["displayName"] for source in sources.values()]:
        raise ValueError("Analysis sessionOrder display names do not match its sources.")
    target_units = analysis.get("targetUnits")
    blocks = analysis.get("blocks")
    if not isinstance(target_units, list) or not target_units:
        raise ValueError("Analysis targetUnits must be a non-empty list.")
    if not isinstance(blocks, list) or len(blocks) != len(target_units):
        raise ValueError("Analysis blocks must correspond one-to-one with targetUnits.")
    settings = analysis.get("settings")
    if not isinstance(settings, dict):
        raise ValueError("Analysis settings are missing.")
    gap_ms = _validate_int(settings.get("blockGapMs"), "block gap", minimum=250)
    if settings.get("missingPlaceholderMs") != 1000:
        raise ValueError("Missing placeholder must use the frozen one-second value.")

    target_ids: list[str] = []
    for target in target_units:
        target_id = target.get("targetUnitId") if isinstance(target, dict) else None
        if not isinstance(target_id, str) or not target_id or target_id in target_ids:
            raise ValueError("Target unit IDs must be non-empty and unique.")
        target_ids.append(target_id)

    occurrence_ids: set[str] = set()
    occurrence_map: dict[str, dict[str, Any]] = {}
    occurrence_target_map: dict[str, str] = {}
    occurrences_by_block: list[list[dict[str, Any]]] = []
    media_track_intervals: dict[tuple[str, int], list[tuple[int, int, str]]] = {}
    expected_start = 0
    for block_index, (target, block) in enumerate(zip(target_units, blocks, strict=True)):
        if not isinstance(block, dict) or block.get("blockOrder") != block_index:
            raise ValueError("Block order must be contiguous and zero-based.")
        if block.get("targetUnitId") != target["targetUnitId"]:
            raise ValueError("Block targetUnitId does not match target order.")
        start_ms = _validate_int(block.get("timelineStartMs"), "block start", minimum=0)
        duration_ms = _validate_int(block.get("durationMs"), "block duration", minimum=1)
        if start_ms != expected_start:
            raise ValueError("Block timeline starts do not follow duration plus configured gap.")
        expected_start = start_ms + duration_ms + gap_ms
        occurrences = block.get("occurrences")
        if not isinstance(occurrences, list):
            raise ValueError("Block occurrences must be a list.")
        if block.get("status") == "MISSING":
            if occurrences or duration_ms != 1000:
                raise ValueError("Missing blocks must be empty one-second placeholders.")
        elif not occurrences:
            raise ValueError("Non-missing block has no occurrences.")
        if [item.get("candidateRank") for item in occurrences] != list(range(len(occurrences))):
            raise ValueError("Candidate ranks must be contiguous and zero-based per block.")
        for occurrence in occurrences:
            occurrence_id = occurrence.get("occurrenceId")
            if not isinstance(occurrence_id, str) or not occurrence_id or occurrence_id in occurrence_ids:
                raise ValueError("Occurrence IDs must be non-empty and unique.")
            occurrence_ids.add(occurrence_id)
            occurrence_map[occurrence_id] = occurrence
            occurrence_target_map[occurrence_id] = block["targetUnitId"]
            source_id = occurrence.get("sourceId")
            if source_id not in sources:
                raise ValueError(f"Unknown source ID: {source_id!r}.")
            source = sources[source_id]
            if occurrence.get("sourceOrder") != source["sourceOrder"]:
                raise ValueError("Occurrence sourceOrder is inconsistent.")
            rank = occurrence["candidateRank"]
            expected_video = rank if source["hasVideo"] else -1
            expected_audio = rank if source["hasAudio"] else -1
            if occurrence.get("videoTrackIndex") != expected_video or occurrence.get("audioTrackIndex") != expected_audio:
                raise ValueError(
                    "Candidate media must stay on candidateRank; an unavailable "
                    "media kind uses -1 and intentionally leaves that lane empty."
                )
            source_in = _validate_int(occurrence.get("sourceInMs"), "source in", minimum=0)
            source_out = _validate_int(occurrence.get("sourceOutMs"), "source out", minimum=1)
            matched_in = _validate_int(occurrence.get("matchedSourceInMs"), "matched source in", minimum=0)
            matched_out = _validate_int(occurrence.get("matchedSourceOutMs"), "matched source out", minimum=1)
            if not (source_in <= matched_in < matched_out <= source_out <= source["durationMs"]):
                raise ValueError("Occurrence source/matched timing is inconsistent.")
            if occurrence.get("sequenceStartMs") != start_ms:
                raise ValueError("Every block candidate must start at the block start.")
            if occurrence.get("matchClass") not in {"EXACT", "NEAR", "PARTIAL"}:
                raise ValueError("Occurrence matchClass is not placeable.")
            expected_reasons = {
                "EXACT": ["EXACT_TARGET_WORDING"],
                "NEAR": ["MINOR_WORDING_DIFFERENCE"],
                "PARTIAL": ["PARTIAL_TARGET_WORDING"],
            }[occurrence["matchClass"]]
            if occurrence.get("reasonCodes") != expected_reasons:
                raise ValueError("Occurrence reasonCodes do not match its class.")
            sequence_end = start_ms + source_out - source_in
            if sequence_end > start_ms + duration_ms:
                raise ValueError("Occurrence exceeds its block duration.")
            for media_kind, track_index in (
                ("VIDEO", occurrence["videoTrackIndex"]),
                ("AUDIO", occurrence["audioTrackIndex"]),
            ):
                if track_index >= 0:
                    media_track_intervals.setdefault((media_kind, track_index), []).append(
                        (start_ms, sequence_end, occurrence_id)
                    )
        occurrences_by_block.append(occurrences)

    for intervals in media_track_intervals.values():
        intervals.sort()
        for previous, current in zip(intervals, intervals[1:]):
            if current[0] < previous[1]:
                raise ValueError("Same-media-track timeline overlap detected.")

    for first_index in range(len(occurrences_by_block) - 1):
        for first in occurrences_by_block[first_index]:
            for second in occurrences_by_block[first_index + 1]:
                if first["sourceId"] == second["sourceId"] and intervals_overlap(
                    first["matchedSourceInMs"], first["matchedSourceOutMs"],
                    second["matchedSourceInMs"], second["matchedSourceOutMs"],
                ):
                    raise ValueError("Adjacent target units reuse overlapping source words.")

    expected_reuse: set[tuple[str, str, str]] = set()
    for first_index in range(len(occurrences_by_block)):
        for second_index in range(first_index + 2, len(occurrences_by_block)):
            for first in occurrences_by_block[first_index]:
                for second in occurrences_by_block[second_index]:
                    if first["sourceId"] == second["sourceId"] and intervals_overlap(
                        first["matchedSourceInMs"], first["matchedSourceOutMs"],
                        second["matchedSourceInMs"], second["matchedSourceOutMs"],
                    ):
                        expected_reuse.add((first["sourceId"], first["occurrenceId"], second["occurrenceId"]))
    actual_reuse = analysis.get("sourceReuse")
    if not isinstance(actual_reuse, list):
        raise ValueError("Analysis sourceReuse must be a list.")
    actual_reuse_keys: set[tuple[str, str, str]] = set()
    for record in actual_reuse:
        if not isinstance(record, dict) or record.get("reasonCode") != "REUSED_SOURCE_RANGE":
            raise ValueError("Source reuse record is invalid.")
        occurrence_pair = record.get("occurrenceIds")
        if not isinstance(occurrence_pair, list) or len(occurrence_pair) != 2:
            raise ValueError("Source reuse occurrenceIds must contain one pair.")
        actual_reuse_keys.add((record.get("sourceId"), occurrence_pair[0], occurrence_pair[1]))
    if actual_reuse_keys != expected_reuse:
        raise ValueError("Source reuse audit does not exactly match non-adjacent overlap.")

    source_markers = analysis.get("sourceMarkers")
    sequence_markers = analysis.get("sequenceMarkers")
    if not isinstance(source_markers, list) or not isinstance(sequence_markers, list):
        raise ValueError("Marker collections must be lists.")
    if {marker.get("occurrenceId") for marker in source_markers} != occurrence_ids:
        raise ValueError("Every occurrence must own exactly one source marker.")
    if len(source_markers) != len(occurrence_ids):
        raise ValueError("Source marker occurrence ownership is not one-to-one.")
    source_marker_ids: set[str] = set()
    for marker in source_markers:
        marker_id = marker.get("sourceMarkerId") if isinstance(marker, dict) else None
        if not isinstance(marker_id, str) or not marker_id or marker_id in source_marker_ids:
            raise ValueError("Source marker IDs must be non-empty and unique.")
        source_marker_ids.add(marker_id)
        occurrence = occurrence_map.get(marker.get("occurrenceId"))
        if occurrence is None:
            raise ValueError("Source marker owns an unknown occurrence.")
        if marker.get("sourceId") != occurrence["sourceId"] or marker.get("targetUnitId") != occurrence_target_map[occurrence["occurrenceId"]]:
            raise ValueError("Source marker ownership is inconsistent.")
        marker_start = _validate_int(marker.get("sourceStartMs"), "source marker start", minimum=0)
        marker_duration = _validate_int(marker.get("durationMs"), "source marker duration", minimum=1)
        if marker_start != occurrence["matchedSourceInMs"] or marker_start + marker_duration != occurrence["matchedSourceOutMs"]:
            raise ValueError("Source marker timing does not match its occurrence.")

    sequence_marker_ids: set[str] = set()
    for marker in sequence_markers:
        marker_id = marker.get("markerId") if isinstance(marker, dict) else None
        if not isinstance(marker_id, str) or not marker_id or marker_id in sequence_marker_ids:
            raise ValueError("Sequence marker IDs must be non-empty and unique.")
        sequence_marker_ids.add(marker_id)
        if marker.get("targetUnitId") not in target_ids:
            raise ValueError("Sequence marker owns an unknown target unit.")
        marker_start = _validate_int(marker.get("sequenceStartMs"), "sequence marker start", minimum=0)
        marker_duration = _validate_int(marker.get("durationMs"), "sequence marker duration", minimum=1)
        owning_block = blocks[target_ids.index(marker["targetUnitId"])]
        if marker_start != owning_block["timelineStartMs"] or marker_duration > owning_block["durationMs"]:
            raise ValueError("Sequence marker timing is outside its target block.")

    for marker in [*source_markers, *sequence_markers]:
        if not isinstance(marker, dict):
            raise ValueError("Marker record must be an object.")
        _safe_text(str(marker.get("markerName", "")), "marker name")
        _safe_text(str(marker.get("markerComment", "")), "marker comment")

    summary = analysis.get("summary")
    if not isinstance(summary, dict):
        raise ValueError("Analysis summary is missing.")
    expected_summary = {
        "targetUnitCount": len(target_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": len(occurrence_ids),
        "alternateCount": sum(max(len(block["occurrences"]) - 1, 0) for block in blocks),
        "sequenceMarkerCount": len(sequence_markers),
        "sourceMarkerCount": len(source_markers),
        "sourceReuseCount": len(actual_reuse),
        "exactPlacementCount": sum(
            item["matchClass"] == "EXACT"
            for block in blocks
            for item in block["occurrences"]
        ),
        "nearPlacementCount": sum(
            item["matchClass"] == "NEAR"
            for block in blocks
            for item in block["occurrences"]
        ),
        "partialPlacementCount": sum(
            item["matchClass"] == "PARTIAL"
            for block in blocks
            for item in block["occurrences"]
        ),
        "factualConflictUnitCount": sum(
            "AMBIGUOUS_FACTUAL_CONFLICT" in marker["reasonCodes"]
            for marker in sequence_markers
        ),
        "maxVideoTrackIndex": max((item["videoTrackIndex"] for block in blocks for item in block["occurrences"]), default=-1),
        "maxAudioTrackIndex": max((item["audioTrackIndex"] for block in blocks for item in block["occurrences"]), default=-1),
        "totalTimelineDurationMs": max(expected_start - gap_ms, 0),
    }
    if summary != expected_summary:
        raise ValueError("Analysis summary does not match validated contents.")


def expected_placement_rows(analysis: dict[str, Any]) -> list[dict[str, str]]:
    return [
        {
            "targetUnitId": block["targetUnitId"],
            "blockOrder": str(block["blockOrder"]),
            "occurrenceId": occurrence["occurrenceId"],
            "candidateRank": str(occurrence["candidateRank"]),
            "videoTrackIndex": str(occurrence["videoTrackIndex"]),
            "audioTrackIndex": str(occurrence["audioTrackIndex"]),
            "sequenceStartSeconds": _seconds(occurrence["sequenceStartMs"]),
            "sourceInSeconds": _seconds(occurrence["sourceInMs"]),
            "sourceOutSeconds": _seconds(occurrence["sourceOutMs"]),
            "matchedSourceInSeconds": _seconds(occurrence["matchedSourceInMs"]),
            "matchedSourceOutSeconds": _seconds(occurrence["matchedSourceOutMs"]),
            "sourceId": occurrence["sourceId"],
            "matchClass": occurrence["matchClass"],
            "reasonCodes": ",".join(occurrence["reasonCodes"]),
        }
        for block in analysis["blocks"]
        for occurrence in block["occurrences"]
    ]


def expected_sequence_marker_rows(analysis: dict[str, Any]) -> list[dict[str, str]]:
    return [
        {
            "markerId": marker["markerId"],
            "targetUnitId": marker["targetUnitId"],
            "severity": marker["severity"],
            "reasonCodes": ",".join(marker["reasonCodes"]),
            "markerName": marker["markerName"],
            "markerComment": marker["markerComment"],
            "sequenceStartSeconds": _seconds(marker["sequenceStartMs"]),
            "durationSeconds": _seconds(marker["durationMs"]),
        }
        for marker in analysis["sequenceMarkers"]
    ]


def expected_source_marker_rows(analysis: dict[str, Any]) -> list[dict[str, str]]:
    return [
        {
            "sourceMarkerId": marker["sourceMarkerId"],
            "sourceId": marker["sourceId"],
            "targetUnitId": marker["targetUnitId"],
            "occurrenceId": marker["occurrenceId"],
            "status": marker["status"],
            "reasonCodes": ",".join(marker["reasonCodes"]),
            "markerName": marker["markerName"],
            "markerComment": marker["markerComment"],
            "sourceStartSeconds": _seconds(marker["sourceStartMs"]),
            "durationSeconds": _seconds(marker["durationMs"]),
            "colorIndex": str(marker["colorIndex"]),
        }
        for marker in analysis["sourceMarkers"]
    ]


def expected_summary_rows(analysis: dict[str, Any]) -> list[dict[str, str]]:
    summary = analysis["summary"]
    session_order = analysis["sessionOrder"]
    return [{
        **{
            key: str(summary[key])
            for key in SUMMARY_COLUMNS
            if key
            not in {
                "totalTimelineDurationSeconds",
                "orderMode",
                "orderWarningCodes",
                "sourceOrder",
                "sourceOrderIds",
            }
        },
        "totalTimelineDurationSeconds": _seconds(summary["totalTimelineDurationMs"]),
        "orderMode": session_order["mode"],
        "orderWarningCodes": ",".join(session_order["warningCodes"]),
        "sourceOrder": " > ".join(
            " ".join(str(name).split()) for name in session_order["displayNames"]
        ),
        "sourceOrderIds": ",".join(analysis["sourceOrder"]),
    }]


def validate_tsv(path: Path, columns: tuple[str, ...], expected_rows: list[dict[str, str]]) -> int:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, dialect="excel-tab")
        if tuple(reader.fieldnames or ()) != columns:
            raise ValueError(f"{path.name} header does not match the frozen contract.")
        rows = list(reader)
    if rows != expected_rows:
        raise ValueError(f"{path.name} does not exactly match analysis.json.")
    for row_index, row in enumerate(rows, 2):
        for key, value in row.items():
            if key.endswith("Seconds"):
                try:
                    parsed = float(value)
                except ValueError as error:
                    raise ValueError(f"{path.name} row {row_index} {key} is not numeric.") from error
                if not math.isfinite(parsed):
                    raise ValueError(f"{path.name} row {row_index} {key} is not finite.")
            if key.endswith("TrackIndex") or key in {"blockOrder", "candidateRank", "colorIndex"}:
                if not INTEGER_RE.fullmatch(value):
                    raise ValueError(f"{path.name} row {row_index} {key} is not an integer.")
    return len(rows)
