from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any

from caption_profile import CaptionProfile
from operations import Finding
from timed_text import TimedTextDocument, TimedWord
from transcript import (
    detect_transcript_language,
    iter_timed_transcript_word_records,
    transcript_to_document,
)


@dataclass(frozen=True)
class MappedWord:
    word: TimedWord
    item_id: str
    track_index: int
    track_name: str
    item_name: str
    project_item_ref: str
    source_start_ms: int
    source_end_ms: int
    segment_index: int
    word_index: int
    speaker_id: str
    speaker_name: str
    end_of_sentence: bool


def _finite_number(value: Any, label: str) -> float:
    if not isinstance(value, (int, float)) or not math.isfinite(value):
        raise ValueError(f"{label} must be a finite number.")
    return float(value)


def _milliseconds(value: Any, label: str) -> int:
    return int(round(_finite_number(value, label) * 1000.0))


def _interval_duration(intervals: list[tuple[int, int]]) -> int:
    if not intervals:
        return 0
    total = 0
    current_start, current_end = sorted(intervals)[0]
    for start, end in sorted(intervals)[1:]:
        if start <= current_end:
            current_end = max(current_end, end)
        else:
            total += current_end - current_start
            current_start, current_end = start, end
    return total + current_end - current_start


def _deduplication_key(item: dict[str, Any], words: list[MappedWord]) -> tuple[Any, ...]:
    return (
        str(item.get("projectItemRef", "")),
        _milliseconds(item.get("timelineStartSeconds"), "timelineStartSeconds"),
        _milliseconds(item.get("timelineEndSeconds"), "timelineEndSeconds"),
        _milliseconds(item.get("sourceInSeconds"), "sourceInSeconds"),
        _milliseconds(item.get("sourceOutSeconds"), "sourceOutSeconds"),
        tuple(
            (
                mapped.word.text,
                mapped.source_start_ms,
                mapped.source_end_ms,
                mapped.word.start_ms,
                mapped.word.end_ms,
            )
            for mapped in words
        ),
    )


def map_sequence_item_words(item: dict[str, Any]) -> tuple[list[MappedWord], list[str]]:
    """Map one host-neutral normal-forward sequence item into sequence time.

    This is the additive public mapping seam shared by sequence-level workflows.
    Eligibility and deduplication remain caller policy; this function validates
    and clips the item's embedded transcript while preserving useful transcript
    boundary metadata.
    """
    source_in_ms = _milliseconds(item.get("sourceInSeconds"), "sourceInSeconds")
    source_out_ms = _milliseconds(item.get("sourceOutSeconds"), "sourceOutSeconds")
    timeline_start_ms = _milliseconds(item.get("timelineStartSeconds"), "timelineStartSeconds")
    timeline_end_ms = _milliseconds(item.get("timelineEndSeconds"), "timelineEndSeconds")
    transcript = item.get("transcript")
    if not isinstance(transcript, dict):
        raise ValueError("Item transcript must be an object.")
    speaker_names = {
        str(value.get("id", "")): str(value.get("name", "")).strip()
        for value in transcript.get("speakers", [])
        if isinstance(value, dict) and str(value.get("id", "")).strip()
    }
    mapped: list[MappedWord] = []
    timing_warnings: list[str] = []
    segments = transcript.get("segments")
    for segment_index, word_index, raw_word, source_word in iter_timed_transcript_word_records(
        transcript,
        warnings=timing_warnings,
    ):
        if source_word.end_ms <= source_in_ms or source_word.start_ms >= source_out_ms:
            continue
        clipped_source_start = max(source_word.start_ms, source_in_ms)
        clipped_source_end = min(source_word.end_ms, source_out_ms)
        mapped_start = timeline_start_ms + clipped_source_start - source_in_ms
        mapped_end = timeline_start_ms + clipped_source_end - source_in_ms
        mapped_start = max(timeline_start_ms, mapped_start)
        mapped_end = min(timeline_end_ms, mapped_end)
        if mapped_end <= mapped_start:
            continue
        segment = (
            segments[segment_index]
            if isinstance(segments, list)
            and segment_index < len(segments)
            and isinstance(segments[segment_index], dict)
            else {}
        )
        raw_speaker = raw_word.get("speaker", segment.get("speaker", ""))
        speaker_id = str(raw_speaker).strip() if raw_speaker is not None else ""
        mapped.append(
            MappedWord(
                word=TimedWord(source_word.text, mapped_start, mapped_end),
                item_id=str(item.get("id", "")),
                track_index=int(item.get("trackIndex", 0)),
                track_name=str(item.get("trackName", "")),
                item_name=str(item.get("itemName", "")),
                project_item_ref=str(item.get("projectItemRef", "")),
                source_start_ms=clipped_source_start,
                source_end_ms=clipped_source_end,
                segment_index=segment_index,
                word_index=word_index,
                speaker_id=speaker_id,
                speaker_name=speaker_names.get(speaker_id, ""),
                end_of_sentence=raw_word.get("eos") is True,
            )
        )
    return mapped, timing_warnings


def _conflict_regions(words: list[MappedWord]) -> list[dict[str, Any]]:
    seeds: list[tuple[int, int, set[str]]] = []
    ordered = sorted(words, key=lambda value: (value.word.start_ms, value.word.end_ms, value.item_id))
    active: list[MappedWord] = []
    for current in ordered:
        active = [candidate for candidate in active if candidate.word.end_ms > current.word.start_ms]
        for candidate in active:
            if candidate.item_id == current.item_id:
                continue
            start = max(candidate.word.start_ms, current.word.start_ms)
            end = min(candidate.word.end_ms, current.word.end_ms)
            if end > start:
                seeds.append((start, end, {candidate.item_id, current.item_id}))
        active.append(current)
    regions: list[tuple[int, int, set[str]]] = []
    for start, end, item_ids in sorted(seeds):
        if regions and start <= regions[-1][1]:
            previous_start, previous_end, previous_ids = regions[-1]
            regions[-1] = (previous_start, max(previous_end, end), previous_ids | item_ids)
        else:
            regions.append((start, end, set(item_ids)))
    result: list[dict[str, Any]] = []
    for index, (start, end, item_ids) in enumerate(regions, start=1):
        candidates = []
        for item_id in sorted(item_ids):
            item_words = [
                value
                for value in ordered
                if value.item_id == item_id
                and value.word.end_ms > start
                and value.word.start_ms < end
            ]
            if not item_words:
                continue
            first = item_words[0]
            candidates.append(
                {
                    "itemId": item_id,
                    "trackIndex": first.track_index,
                    "trackName": first.track_name,
                    "itemName": first.item_name,
                    "projectItemRef": first.project_item_ref,
                    "text": " ".join(value.word.text for value in item_words),
                    "mappedStartMs": min(value.word.start_ms for value in item_words),
                    "mappedEndMs": max(value.word.end_ms for value in item_words),
                    "sourceStartMs": min(value.source_start_ms for value in item_words),
                    "sourceEndMs": max(value.source_end_ms for value in item_words),
                }
            )
        result.append(
            {
                "id": f"conflict_{index:06d}",
                "startMs": start,
                "endMs": end,
                "candidateItemIds": sorted(item_ids),
                "candidates": candidates,
            }
        )
    return result


def _sequence_manifest_to_document_with_words(
    manifest: dict[str, Any],
    *,
    profile: CaptionProfile,
    chosen_track_index: int | None = None,
) -> tuple[TimedTextDocument | None, list[Finding], dict[str, Any], list[MappedWord]]:
    """Map supported source-transcript words into active-sequence time.

    The manifest is deliberately host-neutral. Premiere discovery owns the facts
    in it; this function owns deterministic validation, mapping, deduplication,
    conflict detection, cue generation, and coverage accounting.
    """
    raw_items = manifest.get("items")
    if not isinstance(raw_items, list):
        raise ValueError("Sequence manifest does not contain an items array.")
    frame_duration_ms = max(
        1,
        _milliseconds(manifest.get("frameDurationSeconds", 1 / 30), "frameDurationSeconds"),
    )
    findings: list[Finding] = []
    item_reports: list[dict[str, Any]] = []
    all_words: list[MappedWord] = []
    seen_keys: set[tuple[Any, ...]] = set()
    eligible_timeline_intervals: list[tuple[int, int]] = []
    eligible_tracks: dict[int, str] = {}
    contributing_languages: list[str] = []
    contributing_language_issues: list[dict[str, str]] = []

    for position, raw_item in enumerate(raw_items):
        if not isinstance(raw_item, dict):
            findings.append(Finding("ERROR", "INVALID_SEQUENCE_ITEM", f"Item {position} is not an object."))
            continue
        item_id = str(raw_item.get("id") or f"item_{position + 1:06d}")
        report: dict[str, Any] = {
            "itemId": item_id,
            "itemName": str(raw_item.get("itemName", "")),
            "trackName": str(raw_item.get("trackName", "")),
            "status": "UNSUPPORTED",
        }
        try:
            discovery_warning = raw_item.get("discoveryWarning")
            if isinstance(discovery_warning, str) and discovery_warning.strip():
                findings.append(
                    Finding(
                        "WARNING",
                        "SEQUENCE_ITEM_DISCOVERY_WARNING",
                        f"{item_id}: {discovery_warning.strip()}",
                    )
                )
            track_index = int(raw_item.get("trackIndex", 0))
            report["trackIndex"] = track_index
            if chosen_track_index is not None and track_index != chosen_track_index:
                report.update(status="OUT_OF_SCOPE", reason="Item is not on the chosen dialogue track.")
                item_reports.append(report)
                continue
            if bool(raw_item.get("trackMuted", False)):
                report.update(status="SKIPPED_MUTED", reason="Audio track is muted.")
                item_reports.append(report)
                continue
            if bool(raw_item.get("disabled", False)):
                report.update(status="SKIPPED_DISABLED", reason="Track item is disabled.")
                item_reports.append(report)
                continue
            transcript = raw_item.get("transcript")
            if transcript is None:
                try:
                    report.update(
                        timelineStartMs=_milliseconds(
                            raw_item.get("timelineStartSeconds"),
                            "timelineStartSeconds",
                        ),
                        timelineEndMs=_milliseconds(
                            raw_item.get("timelineEndSeconds"),
                            "timelineEndSeconds",
                        ),
                    )
                except (TypeError, ValueError):
                    pass
                reason = "No usable transcript is available."
                findings.append(
                    Finding(
                        "WARNING",
                        "NO_USABLE_TRANSCRIPT",
                        f"{item_id}: {reason}",
                    )
                )
                report.update(status="SKIPPED_NO_TRANSCRIPT", reason=reason)
                item_reports.append(report)
                continue
            if not isinstance(transcript, dict):
                raise ValueError("Item transcript must be an object.")
            timeline_start_ms = _milliseconds(raw_item.get("timelineStartSeconds"), "timelineStartSeconds")
            timeline_end_ms = _milliseconds(raw_item.get("timelineEndSeconds"), "timelineEndSeconds")
            report.update(
                timelineStartMs=timeline_start_ms,
                timelineEndMs=timeline_end_ms,
            )
            speed = _finite_number(raw_item.get("speed"), "speed")
            reversed_value = raw_item.get("reversed")
            if not isinstance(reversed_value, bool):
                raise ValueError("reversed must be a normalized boolean.")
            if reversed_value or abs(speed - 1.0) > 1e-6:
                raise ValueError("Only normal forward 1x items are supported.")
            source_in_ms = _milliseconds(raw_item.get("sourceInSeconds"), "sourceInSeconds")
            source_out_ms = _milliseconds(raw_item.get("sourceOutSeconds"), "sourceOutSeconds")
            if timeline_end_ms <= timeline_start_ms or source_out_ms <= source_in_ms:
                raise ValueError("Timeline and source ranges must have positive duration.")
            duration_delta = abs(
                (timeline_end_ms - timeline_start_ms) - (source_out_ms - source_in_ms)
            )
            if duration_delta > frame_duration_ms:
                raise ValueError(
                    "Timeline duration and source range differ by more than one sequence frame."
                )
            mapped, timing_warnings = map_sequence_item_words({**raw_item, "id": item_id})
            if not mapped:
                reason = "The transcript contains no usable words in this Timeline clip range."
                findings.append(
                    Finding(
                        "WARNING",
                        "NO_USABLE_TRANSCRIPT",
                        f"{item_id}: {reason}",
                    )
                )
                report.update(status="SKIPPED_NO_TRANSCRIPT", reason=reason)
                item_reports.append(report)
                continue
            findings.extend(
                Finding(
                    "WARNING",
                    "TRANSCRIPT_TIMING_ADJUSTED",
                    f"{item_id}: {warning}",
                )
                for warning in timing_warnings
            )
            eligible_tracks[track_index] = str(raw_item.get("trackName", ""))
            key = _deduplication_key(raw_item, mapped)
            if key in seen_keys:
                report.update(status="DEDUPLICATED", mappedWordCount=len(mapped))
                item_reports.append(report)
                continue
            seen_keys.add(key)
            all_words.extend(mapped)
            eligible_timeline_intervals.append((timeline_start_ms, timeline_end_ms))
            transcript_language, language_issue = detect_transcript_language(transcript)
            if language_issue is None:
                contributing_languages.append(transcript_language)
                report["transcriptLanguage"] = transcript_language
            else:
                contributing_language_issues.append(
                    {
                        "itemId": item_id,
                        "itemName": str(raw_item.get("itemName", "")),
                        "issue": language_issue,
                    }
                )
                report["transcriptLanguage"] = ""
                report["transcriptLanguageIssue"] = language_issue
            report.update(
                status="MAPPED",
                mappedWordCount=len(mapped),
                timelineStartMs=timeline_start_ms,
                timelineEndMs=timeline_end_ms,
            )
        except (TypeError, ValueError) as exc:
            findings.append(Finding("WARNING", "UNSUPPORTED_SEQUENCE_ITEM", f"{item_id}: {exc}"))
            report["reason"] = str(exc)
        item_reports.append(report)

    detected_languages = sorted(set(contributing_languages))
    if contributing_language_issues:
        language_status = (
            "MIXED_OR_INCOMPLETE" if len(detected_languages) > 1 else "INCOMPLETE"
        )
        findings.append(
            Finding(
                "WARNING",
                "TRANSCRIPT_LANGUAGE_UNAVAILABLE",
                (
                    f"{len(contributing_language_issues)} contributing clip(s) have missing, "
                    "invalid, or inconsistent transcript language metadata. "
                    "The output filename will not claim a language."
                ),
            )
        )
    elif len(detected_languages) > 1:
        language_status = "MIXED"
    elif len(detected_languages) == 1:
        language_status = "SINGLE"
    else:
        language_status = "NONE"
    if len(detected_languages) > 1:
        findings.append(
            Finding(
                "WARNING",
                "MIXED_TRANSCRIPT_LANGUAGES",
                (
                    "Contributing clips use multiple transcript languages "
                    f"({', '.join(detected_languages)}). "
                    "The output filename will not claim a language."
                ),
            )
        )
    detected_language = (
        detected_languages[0]
        if language_status == "SINGLE"
        else ""
    )

    conflicts = _conflict_regions(all_words)
    if conflicts:
        findings.append(
            Finding(
                "ERROR",
                "UNRESOLVED_SPEECH_CONFLICTS",
                f"{len(conflicts)} mapped spoken overlap region(s) require editorial resolution.",
            )
        )
    document: TimedTextDocument | None = None
    if all_words and not conflicts:
        ordered_words = sorted(
            all_words,
            key=lambda value: (value.word.start_ms, value.word.end_ms, value.item_id),
        )
        transcript = {
            "language": detected_language,
            "segments": [
                {
                    "words": [
                        {
                            "text": value.word.text,
                            "start": value.word.start_ms / 1000.0,
                            "duration": (value.word.end_ms - value.word.start_ms) / 1000.0,
                        }
                        for value in ordered_words
                    ]
                }
            ],
        }
        document, warnings = transcript_to_document(
            transcript,
            profile=profile,
            language=detected_language,
            source_name=str(manifest.get("sequenceName", "")),
        )
        document.source_kind = "premiere-active-sequence-source-transcripts"
        for warning in warnings:
            findings.append(Finding("WARNING", "CAPTION_GENERATION_WARNING", warning))

    sequence_start_ms = _milliseconds(manifest.get("sequenceStartSeconds", 0), "sequenceStartSeconds")
    sequence_end_ms = _milliseconds(manifest.get("sequenceEndSeconds", 0), "sequenceEndSeconds")
    sequence_duration_ms = max(0, sequence_end_ms - sequence_start_ms)
    mapped_speech_duration_ms = _interval_duration(
        [(value.word.start_ms, value.word.end_ms) for value in all_words]
    )
    eligible_timeline_duration_ms = _interval_duration(eligible_timeline_intervals)
    has_unsupported_items = any(
        item.get("status") == "UNSUPPORTED" for item in item_reports
    )
    skipped_no_transcript_count = sum(
        item.get("status") == "SKIPPED_NO_TRANSCRIPT" for item in item_reports
    )
    report = {
        "version": 1,
        "status": (
            "UNRESOLVED_CONFLICTS"
            if conflicts
            else "UNSUPPORTED_ITEMS"
            if has_unsupported_items
            else "SUCCEEDED_WITH_WARNINGS"
            if document is not None
            and (skipped_no_transcript_count or language_status != "SINGLE")
            else "SUCCEEDED"
            if document is not None
            else "NO_MAPPED_SPEECH"
        ),
        "sequenceName": str(manifest.get("sequenceName", "")),
        "sequenceStartMs": sequence_start_ms,
        "sequenceEndMs": sequence_end_ms,
        "sequenceDurationMs": sequence_duration_ms,
        "mappedSpeechDurationMs": mapped_speech_duration_ms,
        "mappedSpeechPercentOfSequence": (
            round(mapped_speech_duration_ms * 100 / sequence_duration_ms, 3)
            if sequence_duration_ms
            else 0
        ),
        "eligibleItemTimelineDurationMs": eligible_timeline_duration_ms,
        "eligibleItemTimelinePercentOfSequence": (
            round(eligible_timeline_duration_ms * 100 / sequence_duration_ms, 3)
            if sequence_duration_ms
            else 0
        ),
        "includedTracks": [
            {"index": index, "name": eligible_tracks[index]}
            for index in sorted(eligible_tracks)
        ],
        "includedTrackIndices": sorted(eligible_tracks),
        "contributingTrackIndicesAfterDeduplication": sorted(
            {value.track_index for value in all_words}
        ),
        "skippedNoTranscriptCount": skipped_no_transcript_count,
        "languageStatus": language_status,
        "detectedLanguage": detected_language,
        "transcriptLanguages": detected_languages,
        "transcriptLanguageIssues": contributing_language_issues,
        "itemReports": item_reports,
        "conflicts": conflicts,
    }
    return document, findings, report, all_words


def sequence_manifest_to_document(
    manifest: dict[str, Any],
    *,
    profile: CaptionProfile,
    chosen_track_index: int | None = None,
) -> tuple[TimedTextDocument | None, list[Finding], dict[str, Any]]:
    document, findings, report, _ = _sequence_manifest_to_document_with_words(
        manifest,
        profile=profile,
        chosen_track_index=chosen_track_index,
    )
    return document, findings, report


def sequence_manifest_to_mapped_words(
    manifest: dict[str, Any],
    *,
    profile: CaptionProfile,
    chosen_track_index: int | None = None,
) -> tuple[list[MappedWord], list[Finding], dict[str, Any]]:
    """Return the exact mapped-word selection used by sequence SRT generation.

    This public seam lets adjacent workflows reuse the existing eligibility,
    deduplication, overlap, language, and coverage policy without reconstructing
    it from a generated caption document. Callers must still honor report.status;
    words can exist in a report that is blocked by an unsupported item or speech
    conflict.
    """
    _, findings, report, words = _sequence_manifest_to_document_with_words(
        manifest,
        profile=profile,
        chosen_track_index=chosen_track_index,
    )
    return words, findings, report


def sequence_report_markdown(report: dict[str, Any]) -> str:
    track_names = {
        int(track["index"]): str(track.get("name") or f"A{int(track['index']) + 1}")
        for track in report["includedTracks"]
    }
    included_track_labels = [
        track_names.get(int(index), f"A{int(index) + 1}")
        for index in report["includedTrackIndices"]
    ]
    contributing_track_labels = [
        track_names.get(int(index), f"A{int(index) + 1}")
        for index in report["contributingTrackIndicesAfterDeduplication"]
    ]
    status_labels = {
        "MAPPED": "Used",
        "DEDUPLICATED": "Ignored — duplicate spoken material",
        "SKIPPED_MUTED": "Ignored — muted track",
        "SKIPPED_DISABLED": "Ignored — disabled clip",
        "SKIPPED_NO_TRANSCRIPT": "Ignored — no usable transcript",
        "OUT_OF_SCOPE": "Ignored — outside the chosen dialogue track",
        "UNSUPPORTED": "Needs attention",
    }

    def format_time(milliseconds: Any) -> str:
        try:
            total_seconds = max(0, int(milliseconds)) / 1000
        except (TypeError, ValueError):
            return ""
        hours = int(total_seconds // 3600)
        minutes = int(total_seconds % 3600 // 60)
        seconds = total_seconds % 60
        return f"{hours:02d}:{minutes:02d}:{seconds:06.3f}"

    lines = [
        "## Sequence summary",
        "",
        (
            f"- Dialogue included in the SRT: `{report['mappedSpeechDurationMs']} ms` "
            f"(`{report['mappedSpeechPercentOfSequence']}%` of the sequence window)"
        ),
        (
            f"- Timeline covered by clips with usable transcripts: `{report['eligibleItemTimelineDurationMs']} ms` "
            f"(`{report['eligibleItemTimelinePercentOfSequence']}%` of the sequence window)"
        ),
        f"- Included unmuted audio tracks: `{', '.join(included_track_labels)}`",
        (
            "- Audio tracks contributing after duplicate material was removed: "
            f"`{', '.join(contributing_track_labels)}`"
        ),
        (
            f"- Transcript language: `{report['detectedLanguage']}`"
            if report.get("languageStatus") == "SINGLE"
            else (
                "- Transcript language: no language suffix used "
                f"(`{report.get('languageStatus', 'NONE')}`; detected: "
                f"`{', '.join(report.get('transcriptLanguages', [])) or 'none'}`)"
            )
        ),
        "",
        "## Timeline items",
        "",
    ]
    for item in report["itemReports"]:
        detail = item.get("reason") or f"{item.get('mappedWordCount', 0)} transcript word(s) included"
        if item.get("transcriptLanguageIssue"):
            detail += f"; language: {item['transcriptLanguageIssue']}"
        elif item.get("transcriptLanguage"):
            detail += f"; language: {item['transcriptLanguage']}"
        track = item.get("trackName") or (
            f"A{int(item['trackIndex']) + 1}" if "trackIndex" in item else "Unknown track"
        )
        item_name = item.get("itemName") or item["itemId"]
        start = format_time(item.get("timelineStartMs"))
        end = format_time(item.get("timelineEndMs"))
        time_range = f" — `{start}–{end}`" if start and end else ""
        status = status_labels.get(str(item["status"]), str(item["status"]))
        lines.append(
            f"- **{status}** — `{track}` / `{item_name}`{time_range}: {detail}"
        )
    lines.extend(["", "## Overlapping dialogue", ""])
    if not report["conflicts"]:
        lines.append("No overlapping dialogue conflicts found.")
    else:
        for conflict in report["conflicts"]:
            lines.append(
                f"### `{conflict['id']}` — {conflict['startMs']}–{conflict['endMs']} ms"
            )
            lines.append("")
            for candidate in conflict["candidates"]:
                track = candidate["trackName"] or f"A{int(candidate['trackIndex']) + 1}"
                lines.append(
                    f"- `{track}` / `{candidate['itemName']}`: {candidate['text']}"
                )
            lines.append("")
    return "\n".join(lines).rstrip() + "\n"
