from __future__ import annotations

import json
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping

from timebase import RationalTimebase


PROFILE_VERSION = 1
PROFILE_SUFFIX = ".caption-profile.json"
PROFILE_TOP_LEVEL_KEYS = {"version", "name", "notes", "settings"}
SETTING_KEYS = {
    "layout.maxUnitsPerLine",
    "layout.maxLines",
    "readability.maxCps",
    "timing.minDurationMs",
    "timing.maxDurationMs",
    "timing.minGapMs",
    "timing.timebase",
    "timing.minTimestampGapFrames",
    "timing.alignToFrameGrid",
}
SETTING_LABELS = {
    "layout.maxUnitsPerLine": "Maximum text length per line",
    "layout.maxLines": "Maximum lines per caption",
    "readability.maxCps": "Maximum reading speed",
    "timing.minDurationMs": "Minimum caption duration",
    "timing.maxDurationMs": "Maximum caption duration",
    "timing.minGapMs": "Minimum caption gap in milliseconds",
    "timing.timebase": "Delivery frame rate",
    "timing.minTimestampGapFrames": "Minimum caption gap in frames",
    "timing.alignToFrameGrid": "Frame alignment",
}
GAP_SETTING_KEYS = {
    "timing.minGapMs",
    "timing.minTimestampGapFrames",
}


class ProfileError(ValueError):
    pass


class ProfileVersionError(ProfileError):
    pass


class ProfileResolutionError(ProfileError):
    pass


def _require_integer(
    value: Any,
    field: str,
    *,
    minimum: int,
) -> int:
    if isinstance(value, bool) or not isinstance(value, int):
        raise ProfileError(f"{field} must be an integer.")
    if value < minimum:
        raise ProfileError(f"{field} must be at least {minimum}.")
    return value


def _require_number(value: Any, field: str, *, minimum_exclusive: float) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ProfileError(f"{field} must be a number.")
    numeric = float(value)
    if numeric <= minimum_exclusive:
        raise ProfileError(f"{field} must be greater than {minimum_exclusive:g}.")
    return numeric


def normalize_setting(key: str, value: Any) -> Any:
    if key not in SETTING_KEYS:
        raise ProfileError(f"Unknown caption-profile setting: {key}.")
    label = SETTING_LABELS[key]
    if key in {"layout.maxUnitsPerLine", "layout.maxLines"}:
        return _require_integer(value, label, minimum=1)
    if key == "readability.maxCps":
        return _require_number(value, label, minimum_exclusive=0)
    if key in {"timing.minDurationMs", "timing.maxDurationMs"}:
        return _require_integer(value, label, minimum=1)
    if key in {"timing.minGapMs", "timing.minTimestampGapFrames"}:
        return _require_integer(value, label, minimum=0)
    if key == "timing.timebase":
        try:
            return RationalTimebase.from_json(value).to_json()
        except ValueError as exc:
            raise ProfileError(str(exc)) from exc
    if key == "timing.alignToFrameGrid":
        if not isinstance(value, bool):
            raise ProfileError(f"{label} must be true or false.")
        return value
    raise AssertionError(f"Unhandled caption-profile setting: {key}")


def normalize_settings(value: Any) -> dict[str, Any]:
    if value is None:
        return {}
    if not isinstance(value, Mapping):
        raise ProfileError("Caption-profile settings must be an object.")
    normalized: dict[str, Any] = {}
    for raw_key, raw_value in value.items():
        if not isinstance(raw_key, str):
            raise ProfileError("Caption-profile setting names must be strings.")
        normalized[raw_key] = normalize_setting(raw_key, raw_value)

    minimum = normalized.get("timing.minDurationMs")
    maximum = normalized.get("timing.maxDurationMs")
    if minimum is not None and maximum is not None and maximum < minimum:
        raise ProfileError(
            "Maximum caption duration must be greater than or equal to "
            "minimum caption duration."
        )
    has_timebase = "timing.timebase" in normalized
    frame_dependents = {
        "timing.alignToFrameGrid",
        "timing.minTimestampGapFrames",
    }.intersection(normalized)
    if frame_dependents and not has_timebase:
        raise ProfileError(
            "Frame alignment and a frame-based minimum caption gap require "
            "a delivery frame rate."
        )
    if (
        "timing.minGapMs" in normalized
        and "timing.minTimestampGapFrames" in normalized
    ):
        raise ProfileError(
            "Choose only one minimum caption gap: milliseconds or frames."
        )
    return normalized


def _gap_setting_for_source(
    settings: Mapping[str, Any],
    source_label: str,
) -> str | None:
    selected = sorted(GAP_SETTING_KEYS.intersection(settings))
    if len(selected) > 1:
        raise ProfileResolutionError(
            f"{source_label} provides both a millisecond and a frame-based "
            "minimum caption gap. Choose only one."
        )
    return selected[0] if selected else None


def _source_description(source: str, profile_name: str) -> str:
    if source == "TOOL_DEFAULT":
        return "Standard settings"
    if source == "PROFILE":
        return f'the selected profile "{profile_name}"'
    if source == "RUN_OVERRIDE":
        return "the settings chosen for this run"
    return source


def _validate_effective_settings(
    settings: Mapping[str, Any],
    sources: Mapping[str, str],
    *,
    profile_name: str,
) -> None:
    minimum_key = "timing.minDurationMs"
    maximum_key = "timing.maxDurationMs"
    minimum = settings.get(minimum_key)
    maximum = settings.get(maximum_key)
    if minimum is not None and maximum is not None and maximum < minimum:
        minimum_source = _source_description(sources[minimum_key], profile_name)
        maximum_source = _source_description(sources[maximum_key], profile_name)
        raise ProfileResolutionError(
            f"Minimum caption duration is {minimum} ms from {minimum_source}, "
            f"but maximum caption duration is {maximum} ms from "
            f"{maximum_source}. Increase the maximum or reduce the minimum."
        )
    frame_dependents = {
        "timing.alignToFrameGrid",
        "timing.minTimestampGapFrames",
    }.intersection(settings)
    if frame_dependents and "timing.timebase" not in settings:
        descriptions = ", ".join(
            f"{SETTING_LABELS[key]} from "
            f"{_source_description(sources[key], profile_name)}"
            for key in sorted(frame_dependents)
        )
        raise ProfileResolutionError(
            f"{descriptions} requires a delivery frame rate, but no delivery "
            "frame rate is active."
        )
    try:
        normalize_settings(settings)
    except ProfileError as exc:
        raise ProfileResolutionError(
            f"The combined caption settings are incompatible: {exc}"
        ) from exc


@dataclass(frozen=True)
class PortableCaptionProfile:
    name: str
    settings: dict[str, Any]
    notes: str = ""
    version: int = PROFILE_VERSION

    @classmethod
    def from_json(cls, value: Any) -> PortableCaptionProfile:
        if not isinstance(value, Mapping):
            raise ProfileError("Caption profile must be a JSON object.")
        unknown_top_level = set(value) - PROFILE_TOP_LEVEL_KEYS
        if unknown_top_level:
            raise ProfileError(
                "Caption profile contains unknown field(s): "
                + ", ".join(sorted(str(item) for item in unknown_top_level))
                + "."
            )
        version = value.get("version")
        if isinstance(version, bool) or not isinstance(version, int):
            raise ProfileError("Caption-profile version must be an integer.")
        if version != PROFILE_VERSION:
            if version > PROFILE_VERSION:
                raise ProfileVersionError(
                    f"Caption-profile version {version} requires a newer "
                    "Automation Agent version."
                )
            raise ProfileVersionError(
                f"Caption-profile version {version} is not supported."
            )
        name_value = value.get("name")
        if not isinstance(name_value, str) or not name_value.strip():
            raise ProfileError("Caption-profile name is required.")
        notes_value = value.get("notes", "")
        if not isinstance(notes_value, str):
            raise ProfileError("Caption-profile notes must be text.")
        return cls(
            version=version,
            name=name_value.strip(),
            notes=notes_value.strip(),
            settings=normalize_settings(value.get("settings", {})),
        )

    def to_json(self) -> dict[str, Any]:
        result: dict[str, Any] = {
            "version": self.version,
            "name": self.name,
        }
        if self.notes:
            result["notes"] = self.notes
        result["settings"] = {
            key: self.settings[key] for key in sorted(self.settings)
        }
        return result


@dataclass(frozen=True)
class ResolvedProfile:
    effective_settings: dict[str, Any]
    sources: dict[str, str]
    applied_profile_keys: tuple[str, ...]
    overridden_profile_keys: tuple[str, ...]
    unsupported_profile_keys: tuple[str, ...]

    def pass_message(self) -> str:
        if not self.applied_profile_keys:
            if self.overridden_profile_keys:
                return (
                    "No stored profile rules were checked because the applicable "
                    "values were overridden for this run."
                )
            return "No stored rules in this profile were applicable to this tool."
        if self.overridden_profile_keys:
            return (
                "The SRT passes all supported rules from the selected profile "
                "that were used for this run. Values changed or disabled for "
                "this run are excluded."
            )
        return "The SRT passes all supported rules stored in the selected profile."


@dataclass(frozen=True)
class DiscoveredProfile:
    path: Path
    profile: PortableCaptionProfile


@dataclass(frozen=True)
class ProfileDiscoveryIssue:
    path: Path
    message: str


def parse_portable_profile_text(text: str) -> PortableCaptionProfile:
    try:
        value = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ProfileError(f"Caption profile is not valid JSON: {exc.msg}.") from exc
    return PortableCaptionProfile.from_json(value)


def read_portable_profile(path: Path) -> PortableCaptionProfile:
    try:
        text = path.read_text(encoding="utf-8-sig")
    except OSError as exc:
        raise ProfileError(f"Could not read caption profile: {exc}.") from exc
    return parse_portable_profile_text(text)


def serialize_portable_profile(profile: PortableCaptionProfile) -> str:
    canonical = PortableCaptionProfile.from_json(profile.to_json())
    return json.dumps(
        canonical.to_json(),
        ensure_ascii=False,
        indent=2,
        sort_keys=False,
    ) + "\n"


def write_portable_profile_atomic(
    path: Path,
    profile: PortableCaptionProfile,
    *,
    replace: bool,
) -> None:
    path = Path(path)
    if path.exists() and not replace:
        raise ProfileError(f"Caption profile already exists: {path}.")
    path.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=f".{path.name}.",
        suffix=".tmp",
        dir=path.parent,
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
            handle.write(serialize_portable_profile(profile))
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temporary, path)
    except Exception:
        temporary.unlink(missing_ok=True)
        raise


def resolve_profile(
    profile: PortableCaptionProfile,
    *,
    tool_defaults: Mapping[str, Any],
    run_overrides: Mapping[str, Any] | None = None,
    applicable_keys: set[str] | frozenset[str],
) -> ResolvedProfile:
    profile = PortableCaptionProfile.from_json(profile.to_json())
    unknown_applicable = set(applicable_keys) - SETTING_KEYS
    if unknown_applicable:
        raise ProfileError(
            "Consumer declares unknown caption-profile setting(s): "
            + ", ".join(sorted(unknown_applicable))
            + "."
        )
    unknown_defaults = set(tool_defaults) - SETTING_KEYS
    if unknown_defaults:
        raise ProfileError(
            "Consumer provides unknown tool-default setting(s): "
            + ", ".join(sorted(unknown_defaults))
            + "."
        )
    unknown_overrides = set(run_overrides or {}) - SETTING_KEYS
    if unknown_overrides:
        raise ProfileError(
            "Consumer provides unknown run-override setting(s): "
            + ", ".join(sorted(unknown_overrides))
            + "."
        )
    normalized_defaults = {
        key: normalize_setting(key, value)
        for key, value in tool_defaults.items()
        if key in applicable_keys
    }
    normalized_overrides = {
        key: normalize_setting(key, value)
        for key, value in (run_overrides or {}).items()
        if key in applicable_keys
    }
    default_gap = _gap_setting_for_source(normalized_defaults, "Standard settings")
    profile_gap = _gap_setting_for_source(
        {
            key: value
            for key, value in profile.settings.items()
            if key in applicable_keys
        },
        f'The selected profile "{profile.name}"',
    )
    override_gap = _gap_setting_for_source(
        normalized_overrides,
        "The settings chosen for this run",
    )
    winning_gap = override_gap or profile_gap or default_gap
    effective = dict(normalized_defaults)
    sources = {key: "TOOL_DEFAULT" for key in effective}
    unsupported_profile_keys: list[str] = []
    for key, value in profile.settings.items():
        if key not in applicable_keys:
            unsupported_profile_keys.append(key)
            continue
        effective[key] = value
        sources[key] = "PROFILE"
    for key, value in normalized_overrides.items():
        effective[key] = value
        sources[key] = "RUN_OVERRIDE"
    for gap_key in GAP_SETTING_KEYS:
        if gap_key != winning_gap:
            effective.pop(gap_key, None)
            sources.pop(gap_key, None)
    _validate_effective_settings(
        effective,
        sources,
        profile_name=profile.name,
    )
    applied_profile_keys = sorted(
        key for key in profile.settings if sources.get(key) == "PROFILE"
    )
    overridden_profile_keys = sorted(
        key
        for key in profile.settings
        if key in applicable_keys and sources.get(key) != "PROFILE"
    )
    return ResolvedProfile(
        effective_settings=effective,
        sources=sources,
        applied_profile_keys=tuple(applied_profile_keys),
        overridden_profile_keys=tuple(overridden_profile_keys),
        unsupported_profile_keys=tuple(sorted(unsupported_profile_keys)),
    )


def discover_profiles(
    folder: Path,
) -> tuple[list[DiscoveredProfile], list[ProfileDiscoveryIssue]]:
    folder = Path(folder)
    if not folder.exists():
        return [], []
    if not folder.is_dir():
        return [], [
            ProfileDiscoveryIssue(folder, "Caption profile location is not a folder.")
        ]
    profiles: list[DiscoveredProfile] = []
    issues: list[ProfileDiscoveryIssue] = []
    for path in sorted(folder.glob(f"*{PROFILE_SUFFIX}"), key=lambda item: item.name.casefold()):
        try:
            profiles.append(DiscoveredProfile(path, read_portable_profile(path)))
        except ProfileError as exc:
            issues.append(ProfileDiscoveryIssue(path, str(exc)))
    profiles.sort(key=lambda item: (item.profile.name.casefold(), item.path.name.casefold()))
    return profiles, issues


INVALID_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')


def profile_filename(name: str) -> str:
    cleaned = INVALID_FILENAME_RE.sub("_", str(name)).strip().rstrip(". ")
    if not cleaned:
        cleaned = "Caption Profile"
    if cleaned.casefold() in {
        "con",
        "prn",
        "aux",
        "nul",
        *(f"com{index}" for index in range(1, 10)),
        *(f"lpt{index}" for index in range(1, 10)),
    }:
        cleaned = f"_{cleaned}"
    return cleaned[:120].rstrip(". ") + PROFILE_SUFFIX
