from __future__ import annotations

import hashlib
import json
import shutil
from pathlib import Path
from typing import Any

from caption_profile import normalize_visible_spaces
from srt_io import parse_srt, serialize_srt
from timed_text import (
    TimedTextDocument,
    TimedWord,
    TimingFidelity,
    WordTimingSource,
)
from transcript import join_transcript_word_text


LEGACY_SIDECAR_VERSION = 1
SIDECAR_VERSION = 2


def canonical_srt_bytes(document: TimedTextDocument) -> bytes:
    return serialize_srt(document).encode("utf-8")


def sha256_bytes(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def canonical_hash(document: TimedTextDocument) -> str:
    return sha256_bytes(canonical_srt_bytes(document))


def expected_sidecar_path(srt_path: Path) -> Path:
    return srt_path.parent / "automation-agent-timing" / f"{srt_path.name}.timing.json"


def create_sidecar(document: TimedTextDocument, raw_srt_bytes: bytes) -> dict[str, Any]:
    if document.timing_fidelity not in {
        TimingFidelity.WORD_ALIGNED_EXACT,
        TimingFidelity.WORD_ALIGNED_ESTIMATED,
    }:
        raise ValueError(
            "Word timing sidecars require word-aligned exact or estimated timing."
        )
    document.validate()
    return {
        "version": SIDECAR_VERSION,
        "canonicalSrtSha256": canonical_hash(document),
        "rawSrtSha256": sha256_bytes(raw_srt_bytes),
        "language": document.language,
        "source": {"kind": document.source_kind, "name": document.source_name},
        "timingFidelity": document.timing_fidelity.value,
        "cues": [
            {
                "srtIndex": cue.srt_index,
                "startMs": cue.start_ms,
                "endMs": cue.end_ms,
                "text": cue.text,
                "speaker": cue.speaker,
                "sourceTrack": cue.source_track,
                "sourceItem": cue.source_item,
                "words": [
                    {
                        "text": word.text,
                        "startMs": word.start_ms,
                        "endMs": word.end_ms,
                        "timingSource": word.timing_source.value,
                    }
                    for word in cue.words
                ],
            }
            for cue in document.cues
        ],
    }


def validate_sidecar(
    srt_bytes: bytes, sidecar: dict[str, Any], *, expected_language: str | None = None
) -> tuple[bool, str]:
    try:
        document, diagnostics = parse_srt(srt_bytes.decode("utf-8-sig"))
    except (UnicodeDecodeError, ValueError) as exc:
        return False, f"SRT could not be parsed: {exc}"
    if any(item.severity == "ERROR" for item in diagnostics):
        return False, "SRT contains structural errors."
    version = sidecar.get("version")
    if version not in {LEGACY_SIDECAR_VERSION, SIDECAR_VERSION}:
        return False, "Unsupported timing-sidecar version."
    if sidecar.get("canonicalSrtSha256") != canonical_hash(document):
        return False, "Canonical SRT hash does not match."
    if expected_language is not None and sidecar.get("language") != expected_language:
        return False, "Language metadata does not match."
    fidelity_value = sidecar.get("timingFidelity")
    if version == LEGACY_SIDECAR_VERSION:
        if fidelity_value != TimingFidelity.WORD_ALIGNED_EXACT.value:
            return False, "Version-1 sidecars require exact word alignment."
        fidelity = TimingFidelity.WORD_ALIGNED_EXACT
    elif fidelity_value in {
        TimingFidelity.WORD_ALIGNED_EXACT.value,
        TimingFidelity.WORD_ALIGNED_ESTIMATED.value,
    }:
        fidelity = TimingFidelity(str(fidelity_value))
    else:
        return False, "Version-2 sidecar has unsupported word timing fidelity."
    entries = sidecar.get("cues")
    if not isinstance(entries, list) or len(entries) != len(document.cues):
        return False, "Sidecar cue count does not match."
    for index, (entry, cue) in enumerate(zip(entries, document.cues), start=1):
        if not isinstance(entry, dict):
            return False, f"Sidecar cue {index} is not an object."
        if (
            entry.get("srtIndex") != index
            or entry.get("startMs") != cue.start_ms
            or entry.get("endMs") != cue.end_ms
            or entry.get("text") != cue.text
        ):
            return False, f"Sidecar cue {index} does not match the SRT."
        words = entry.get("words")
        if not isinstance(words, list):
            return False, f"Sidecar cue {index} has no words array."
        previous_word: dict[str, Any] | None = None
        for word in words:
            timing_source_value = (
                WordTimingSource.TRANSCRIPT_EXACT.value
                if version == LEGACY_SIDECAR_VERSION
                else word.get("timingSource") if isinstance(word, dict) else None
            )
            try:
                timing_source = WordTimingSource(str(timing_source_value))
            except ValueError:
                return False, f"Sidecar cue {index} has an unknown word timing source."
            if (
                not isinstance(word, dict)
                or not isinstance(word.get("text"), str)
                or not word["text"].strip()
                or not isinstance(word.get("startMs"), int)
                or not isinstance(word.get("endMs"), int)
                or word["endMs"] <= word["startMs"]
                or word["startMs"] < cue.start_ms
                or word["endMs"] > cue.end_ms
            ):
                return False, f"Sidecar cue {index} has invalid word timing."
            if previous_word is not None:
                if word["startMs"] < previous_word["startMs"]:
                    return False, f"Sidecar cue {index} has non-monotonic word timing."
                if word["startMs"] < previous_word["endMs"]:
                    previous_source_value = (
                        WordTimingSource.TRANSCRIPT_EXACT.value
                        if version == LEGACY_SIDECAR_VERSION
                        else previous_word.get("timingSource")
                    )
                    if not (
                        fidelity == TimingFidelity.WORD_ALIGNED_ESTIMATED
                        and previous_source_value
                        == WordTimingSource.SHARED_ANCHOR_WINDOW.value
                        and timing_source == WordTimingSource.SHARED_ANCHOR_WINDOW
                    ):
                        return False, (
                            f"Sidecar cue {index} has overlapping word timing outside "
                            "a shared anchor-window run."
                        )
            if (
                fidelity == TimingFidelity.WORD_ALIGNED_EXACT
                and timing_source != WordTimingSource.TRANSCRIPT_EXACT
            ):
                return False, (
                    "Exact word-aligned sidecars require TRANSCRIPT_EXACT timing sources."
                )
            previous_word = word
        joined_words = join_transcript_word_text(words)
        if normalize_visible_spaces(joined_words) != normalize_visible_spaces(cue.text):
            return False, f"Sidecar cue {index} word text does not reconstruct the SRT cue."
    if (
        fidelity == TimingFidelity.WORD_ALIGNED_ESTIMATED
        and not any(
            WordTimingSource(str(word.get("timingSource")))
            != WordTimingSource.TRANSCRIPT_EXACT
            for entry in entries
            for word in entry["words"]
        )
    ):
        return False, "Estimated word alignment requires an estimated timing source."
    return (
        True,
        "Exact timing sidecar accepted."
        if fidelity == TimingFidelity.WORD_ALIGNED_EXACT
        else "Estimated word timing sidecar accepted.",
    )


def load_and_validate_sidecar(
    srt_path: Path, *, expected_language: str | None = None
) -> tuple[dict[str, Any] | None, str]:
    sidecar_path = expected_sidecar_path(srt_path)
    if not sidecar_path.exists():
        return None, "No timing sidecar found."
    try:
        sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return None, f"Timing sidecar could not be read: {exc}"
    valid, reason = validate_sidecar(
        srt_path.read_bytes(), sidecar, expected_language=expected_language
    )
    return (sidecar if valid else None), reason


def apply_sidecar(document: TimedTextDocument, sidecar: dict[str, Any]) -> TimedTextDocument:
    version = int(sidecar["version"])
    entries = sidecar["cues"]
    for cue, entry in zip(document.cues, entries):
        cue.speaker = str(entry.get("speaker", ""))
        cue.source_track = str(entry.get("sourceTrack", ""))
        cue.source_item = str(entry.get("sourceItem", ""))
        cue.words = [
            TimedWord(
                text=str(word["text"]),
                start_ms=int(word["startMs"]),
                end_ms=int(word["endMs"]),
                timing_source=(
                    WordTimingSource.TRANSCRIPT_EXACT
                    if version == LEGACY_SIDECAR_VERSION
                    else WordTimingSource(str(word["timingSource"]))
                ),
            )
            for word in entry["words"]
        ]
    document.language = str(sidecar.get("language", ""))
    source = sidecar.get("source")
    if isinstance(source, dict):
        document.source_kind = str(source.get("kind", document.source_kind))
        document.source_name = str(source.get("name", document.source_name))
    document.timing_fidelity = TimingFidelity(str(sidecar["timingFidelity"]))
    document.validate()
    return document


def install_output_pair(
    prepared_srt: Path,
    output_srt: Path,
    *,
    prepared_sidecar: Path | None = None,
    overwrite: bool = False,
) -> dict[str, Any]:
    sidecar_output = expected_sidecar_path(output_srt)
    if output_srt.exists() and not overwrite:
        return {"status": "SKIPPED_EXISTS", "srt": str(output_srt), "sidecarTouched": False}
    if prepared_sidecar is not None:
        value = json.loads(prepared_sidecar.read_text(encoding="utf-8"))
        valid, reason = validate_sidecar(prepared_srt.read_bytes(), value)
        if not valid:
            raise ValueError(f"Prepared timing sidecar is invalid: {reason}")
    output_srt.parent.mkdir(parents=True, exist_ok=True)
    sidecar_output.unlink(missing_ok=True)
    shutil.copyfile(prepared_srt, output_srt)
    if prepared_sidecar is None:
        return {"status": "SRT_WRITTEN", "srt": str(output_srt), "sidecar": None}
    try:
        sidecar_output.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(prepared_sidecar, sidecar_output)
    except OSError as exc:
        sidecar_output.unlink(missing_ok=True)
        return {
            "status": "SRT_WRITTEN_TIMING_METADATA_FAILED",
            "srt": str(output_srt),
            "sidecar": None,
            "error": str(exc),
        }
    return {
        "status": "SRT_AND_TIMING_WRITTEN",
        "srt": str(output_srt),
        "sidecar": str(sidecar_output),
    }
