from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping

from caption_profile import CaptionProfile
from operations import Finding
from portable_profile import (
    SETTING_LABELS,
    PortableCaptionProfile,
    ResolvedProfile,
    resolve_profile,
)
from timed_text import TimedTextDocument
from timebase import COMMON_FRAME_RATES, RationalTimebase


ANALYZE_TOOL_DEFAULTS = {
    "layout.maxUnitsPerLine": 42,
    "layout.maxLines": 2,
    "readability.maxCps": 17.0,
    "timing.minDurationMs": 1000,
    "timing.maxDurationMs": 7000,
    "timing.minGapMs": 80,
}
ANALYZE_APPLICABLE_KEYS = frozenset(
    {
        *ANALYZE_TOOL_DEFAULTS,
        "timing.timebase",
        "timing.minTimestampGapFrames",
        "timing.alignToFrameGrid",
    }
)
SOURCE_LABELS = {
    "TOOL_DEFAULT": "Standard setting",
    "PROFILE": "Selected profile",
    "RUN_OVERRIDE": "Changed for this run",
}


@dataclass(frozen=True)
class ResolvedAnalysis:
    selected_profile: PortableCaptionProfile
    resolved_profile: ResolvedProfile
    processing_profile: CaptionProfile
    timebase: RationalTimebase | None
    gap_key: str

    @property
    def explicit_gap_limit(self) -> bool:
        return self.resolved_profile.sources[self.gap_key] != "TOOL_DEFAULT"

    @property
    def required_gap_frames(self) -> int | None:
        if self.gap_key != "timing.minTimestampGapFrames":
            return None
        return int(self.resolved_profile.effective_settings[self.gap_key])


def standard_analysis_profile() -> PortableCaptionProfile:
    return PortableCaptionProfile(name="Standard settings", settings={})


def resolve_analysis(
    selected_profile: PortableCaptionProfile,
    *,
    reviewed_values: Mapping[str, Any] | None = None,
    suppressed_profile_keys: set[str] | frozenset[str] = frozenset(),
) -> ResolvedAnalysis:
    unknown_suppressions = set(suppressed_profile_keys) - ANALYZE_APPLICABLE_KEYS
    if unknown_suppressions:
        raise ValueError(
            "Analyzer cannot suppress unknown caption setting(s): "
            + ", ".join(sorted(unknown_suppressions))
            + "."
        )
    effective_profile = PortableCaptionProfile(
        version=selected_profile.version,
        name=selected_profile.name,
        notes=selected_profile.notes,
        settings={
            key: value
            for key, value in selected_profile.settings.items()
            if key not in suppressed_profile_keys
        },
    )
    initial = resolve_profile(
        effective_profile,
        tool_defaults=ANALYZE_TOOL_DEFAULTS,
        applicable_keys=ANALYZE_APPLICABLE_KEYS,
    )
    run_overrides = {
        key: value
        for key, value in (reviewed_values or {}).items()
        if value is not None
        and not (
            key == "timing.alignToFrameGrid"
            and value is False
            and key not in initial.effective_settings
        )
        and initial.effective_settings.get(key) != value
    }
    resolved = resolve_profile(
        effective_profile,
        tool_defaults=ANALYZE_TOOL_DEFAULTS,
        run_overrides=run_overrides,
        applicable_keys=ANALYZE_APPLICABLE_KEYS,
    )
    suppressed_stored_keys = set(selected_profile.settings).intersection(
        suppressed_profile_keys
    )
    if suppressed_stored_keys:
        resolved = ResolvedProfile(
            effective_settings=resolved.effective_settings,
            sources=resolved.sources,
            applied_profile_keys=resolved.applied_profile_keys,
            overridden_profile_keys=tuple(
                sorted(
                    set(resolved.overridden_profile_keys)
                    | suppressed_stored_keys
                )
            ),
            unsupported_profile_keys=resolved.unsupported_profile_keys,
        )
    settings = resolved.effective_settings
    gap_key = (
        "timing.minTimestampGapFrames"
        if "timing.minTimestampGapFrames" in settings
        else "timing.minGapMs"
    )
    timebase = (
        RationalTimebase.from_json(settings["timing.timebase"])
        if "timing.timebase" in settings
        else None
    )
    if gap_key == "timing.minTimestampGapFrames":
        if timebase is None:
            raise ValueError(
                "A frame-based minimum caption gap requires a delivery frame rate."
            )
        gap_ms = timebase.minimum_timestamp_gap_ms(settings[gap_key])
    else:
        gap_ms = settings[gap_key]
    processing_profile = CaptionProfile(
        name=selected_profile.name,
        max_units_per_line=settings["layout.maxUnitsPerLine"],
        max_lines=settings["layout.maxLines"],
        max_cps=settings["readability.maxCps"],
        min_duration_ms=settings["timing.minDurationMs"],
        max_duration_ms=settings["timing.maxDurationMs"],
        preferred_gap_ms=gap_ms,
    )
    processing_profile.validate()
    return ResolvedAnalysis(
        selected_profile=selected_profile,
        resolved_profile=resolved,
        processing_profile=processing_profile,
        timebase=timebase,
        gap_key=gap_key,
    )


def validate_frame_alignment(
    document: TimedTextDocument,
    timebase: RationalTimebase,
) -> list[Finding]:
    findings: list[Finding] = []
    for cue in document.cues:
        if not timebase.is_frame_aligned(cue.start_ms):
            error = float(timebase.frame_grid_error_ms(cue.start_ms))
            findings.append(
                Finding(
                    "ERROR",
                    "START_NOT_FRAME_ALIGNED",
                    f"Caption start is {error:.3f} ms away from the nearest "
                    "delivery-frame boundary.",
                    (cue.id,),
                )
            )
        if not timebase.is_frame_aligned(cue.end_ms):
            error = float(timebase.frame_grid_error_ms(cue.end_ms))
            findings.append(
                Finding(
                    "ERROR",
                    "END_NOT_FRAME_ALIGNED",
                    f"Caption end is {error:.3f} ms away from the nearest "
                    "delivery-frame boundary.",
                    (cue.id,),
                )
            )
    return findings


def _display_setting(
    key: str,
    value: Any,
    *,
    effective_settings: Mapping[str, Any],
) -> str:
    if key == "readability.maxCps":
        return f"{value:g} characters per second"
    if key in {"timing.minDurationMs", "timing.maxDurationMs", "timing.minGapMs"}:
        return f"{value} ms"
    if key == "timing.timebase":
        timebase = RationalTimebase.from_json(value)
        label = next(
            (
                candidate
                for candidate, values in COMMON_FRAME_RATES.items()
                if timebase == RationalTimebase(*values)
            ),
            None,
        )
        return (
            f"{label} fps"
            if label is not None
            else f"{timebase.numerator}/{timebase.denominator} fps"
        )
    if key == "timing.minTimestampGapFrames":
        timebase = RationalTimebase.from_json(effective_settings["timing.timebase"])
        return (
            f"{value} frame(s), at least "
            f"{timebase.minimum_timestamp_gap_ms(value)} ms in SRT timestamps"
        )
    if key == "timing.alignToFrameGrid":
        return (
            "Report caption timings that fall between frames"
            if value
            else "Do not check frame boundaries"
        )
    return str(value)


def analysis_settings_report(
    analysis: ResolvedAnalysis,
    findings: list[Finding],
    *,
    profile_path: Path | None = None,
) -> dict[str, Any]:
    resolved = analysis.resolved_profile
    profile_rule_codes = {
        "layout.maxUnitsPerLine": {"LINE_TOO_LONG"},
        "layout.maxLines": {"TOO_MANY_LINES"},
        "readability.maxCps": {"CPS_TOO_HIGH"},
        "timing.minDurationMs": {"CUE_TOO_SHORT"},
        "timing.maxDurationMs": {"CUE_TOO_LONG"},
        "timing.minGapMs": {"GAP_BELOW_PROFILE_LIMIT"},
        "timing.minTimestampGapFrames": {"GAP_BELOW_PROFILE_LIMIT"},
        "timing.alignToFrameGrid": {
            "START_NOT_FRAME_ALIGNED",
            "END_NOT_FRAME_ALIGNED",
        },
    }
    violated_codes = {finding.code for finding in findings}
    applied_violations = [
        key
        for key in resolved.applied_profile_keys
        if profile_rule_codes.get(key, set()).intersection(violated_codes)
    ]
    is_standard_run = (
        profile_path is None
        and analysis.selected_profile.name == "Standard settings"
        and not analysis.selected_profile.settings
    )
    if is_standard_run:
        profile_result = (
            "No caption profile was selected; Standard settings are general "
            "editing aids."
        )
    elif applied_violations:
        profile_result = (
            "The SRT does not pass every supported rule stored in the selected "
            "profile."
        )
    else:
        profile_result = resolved.pass_message()
    return {
        "profileName": analysis.selected_profile.name,
        "profilePath": str(profile_path) if profile_path is not None else "",
        "effective": [
            {
                "key": key,
                "label": SETTING_LABELS[key],
                "value": value,
                "displayValue": _display_setting(
                    key,
                    value,
                    effective_settings=resolved.effective_settings,
                ),
                "source": resolved.sources[key],
                "sourceLabel": SOURCE_LABELS[resolved.sources[key]],
            }
            for key, value in resolved.effective_settings.items()
        ],
        "appliedProfileKeys": list(resolved.applied_profile_keys),
        "overriddenProfileKeys": list(resolved.overridden_profile_keys),
        "notChecked": [
            {"key": key, "label": SETTING_LABELS[key]}
            for key in resolved.unsupported_profile_keys
        ],
        "profileRuleResult": profile_result,
    }


def analysis_review_form(analysis: ResolvedAnalysis) -> dict[str, Any]:
    settings = analysis.resolved_profile.effective_settings
    timebase = analysis.timebase
    frame_rate = "Not specified"
    custom_numerator: int | str = ""
    custom_denominator: int | str = ""
    if timebase is not None:
        frame_rate = next(
            (
                label
                for label, values in COMMON_FRAME_RATES.items()
                if timebase == RationalTimebase(*values)
            ),
            "Custom",
        )
        if frame_rate == "Custom":
            custom_numerator = timebase.numerator
            custom_denominator = timebase.denominator
    if analysis.gap_key == "timing.minTimestampGapFrames":
        gap_mode = "Required gap in frames (enter value next)"
        min_gap_ms: int | str = ""
        min_gap_frames: int | str = settings[analysis.gap_key]
    elif analysis.resolved_profile.sources[analysis.gap_key] == "TOOL_DEFAULT":
        gap_mode = "General editing preference (80 ms)"
        min_gap_ms = ""
        min_gap_frames = ""
    else:
        gap_mode = "Required gap in milliseconds (enter value next)"
        min_gap_ms = settings[analysis.gap_key]
        min_gap_frames = ""
    return {
        "profileName": analysis.selected_profile.name,
        "maxUnitsPerLine": settings["layout.maxUnitsPerLine"],
        "maxLines": settings["layout.maxLines"],
        "maxCps": settings["readability.maxCps"],
        "minDurationMs": settings["timing.minDurationMs"],
        "maxDurationMs": settings["timing.maxDurationMs"],
        "frameRate": frame_rate,
        "customFrameRateNumerator": custom_numerator,
        "customFrameRateDenominator": custom_denominator,
        "gapMode": gap_mode,
        "minGapMs": min_gap_ms,
        "minTimestampGapFrames": min_gap_frames,
        "alignmentChoice": (
            "Report caption timings that fall between frames"
            if settings.get("timing.alignToFrameGrid") is True
            else "Do not check frame boundaries"
        ),
    }
