"""v0.52.0 Part C — Knowledge Distillation schema helpers.

Schema-only support for ``task='distill'`` — teacher/student training.
Four divergence options are recognised, mirroring axolotl's distillation
plugin:

* ``kl`` (forward KL — student KL teacher, standard distillation)
* ``forward_kl`` (alias for ``kl``)
* ``reverse_kl`` (teacher KL student)
* ``js`` (Jensen-Shannon, symmetric)

The live distillation trainer lands in v0.52.1; this module exposes pure
validators so the schema gate can fail fast on misconfiguration.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping

_DIVERGENCE_ALIASES: Mapping[str, str] = MappingProxyType({
    "kl": "forward_kl",
    "forward_kl": "forward_kl",
    "reverse_kl": "reverse_kl",
    "js": "js",
})

# Public, derived from the alias map so adding a new alias updates both the
# accepted-input set and the error message in lockstep.
DIVERGENCES: frozenset[str] = frozenset(_DIVERGENCE_ALIASES)

_MAX_TEACHER_LEN: int = 512
_MAX_DIVERGENCE_LEN: int = 16
_MIN_TEMPERATURE: float = 0.05
_MAX_TEMPERATURE: float = 100.0


@dataclass(frozen=True)
class DivergenceSpec:
    """Metadata for a divergence kernel. Frozen so callers cannot mutate."""

    name: str
    description: str
    symmetric: bool
    live_wired: bool


_DIVERGENCE_METADATA: Mapping[str, DivergenceSpec] = MappingProxyType({
    "forward_kl": DivergenceSpec(
        name="forward_kl",
        description="Forward KL (standard distillation)",
        symmetric=False,
        live_wired=False,
    ),
    "reverse_kl": DivergenceSpec(
        name="reverse_kl",
        description="Reverse KL (mode-seeking)",
        symmetric=False,
        live_wired=False,
    ),
    "js": DivergenceSpec(
        name="js",
        description="Jensen-Shannon (symmetric KL)",
        symmetric=True,
        live_wired=False,
    ),
})


def validate_divergence(name: object) -> str:
    """Validate a divergence name and return the canonical form.

    Accepts ``kl`` as an alias for ``forward_kl``. Mirrors v0.41.0
    ``validate_optimizer_name`` policy.
    """
    if isinstance(name, bool):
        raise TypeError(f"distill_divergence must not be bool, got {name!r}")
    if not isinstance(name, str):
        raise TypeError(
            f"distill_divergence must be str, got {type(name).__name__}"
        )
    if not name:
        raise ValueError("distill_divergence must be non-empty")
    if "\x00" in name:
        raise ValueError("distill_divergence must not contain null bytes")
    if len(name) > _MAX_DIVERGENCE_LEN:
        raise ValueError(
            f"distill_divergence too long (max {_MAX_DIVERGENCE_LEN} chars)"
        )
    canonical = name.lower()
    if canonical not in _DIVERGENCE_ALIASES:
        supported = ", ".join(sorted(DIVERGENCES))
        raise ValueError(
            f"distill_divergence {name!r} not supported. Supported: {supported}"
        )
    return _DIVERGENCE_ALIASES[canonical]


def get_divergence_spec(name: str) -> DivergenceSpec:
    """Return the frozen :class:`DivergenceSpec` for ``name`` or raise."""
    canonical = validate_divergence(name)
    return _DIVERGENCE_METADATA[canonical]


def validate_distill_temperature(value: object) -> float:
    """Validate a distillation temperature scalar.

    Bounds [0.05, 100.0]. Rejects bool, NaN, ±inf.
    """
    if isinstance(value, bool):
        raise TypeError(
            f"distill_temperature must not be bool, got {value!r}"
        )
    if not isinstance(value, (int, float)):
        raise TypeError(
            f"distill_temperature must be float, got {type(value).__name__}"
        )
    fval = float(value)
    if not math.isfinite(fval):
        raise ValueError(
            f"distill_temperature must be finite, got {value!r}"
        )
    if fval < _MIN_TEMPERATURE:
        raise ValueError(
            f"distill_temperature must be >= {_MIN_TEMPERATURE}, got {fval}"
        )
    if fval > _MAX_TEMPERATURE:
        raise ValueError(
            f"distill_temperature must be <= {_MAX_TEMPERATURE}, got {fval}"
        )
    return fval


def validate_teacher_model(value: object) -> str:
    """Validate a teacher model string (HF repo id or local path).

    Mirrors the v0.40.5 ``reward_model`` field validator: null-byte
    rejection + 512-char cap.
    """
    if isinstance(value, bool):
        raise TypeError(f"teacher_model must not be bool, got {value!r}")
    if not isinstance(value, str):
        raise TypeError(
            f"teacher_model must be str, got {type(value).__name__}"
        )
    if not value:
        raise ValueError("teacher_model must be non-empty")
    if "\x00" in value:
        raise ValueError("teacher_model must not contain null bytes")
    if len(value) > _MAX_TEACHER_LEN:
        raise ValueError(
            f"teacher_model too long (max {_MAX_TEACHER_LEN} chars)"
        )
    return value


def validate_distill_compat(
    *,
    task: str,
    backend: str,
    teacher_model: object,
) -> None:
    """Schema-time gate for ``task='distill'``.

    Rejects:
    - non-distill task.
    - ``backend == 'mlx'`` (no MLX teacher-load path yet).
    - missing teacher_model — distillation is meaningless without one.
    """
    for name, value in (("task", task), ("backend", backend)):
        if isinstance(value, bool):
            raise TypeError(f"{name} must not be bool, got {value!r}")
        if not isinstance(value, str) or not value:
            raise ValueError(f"{name} must be a non-empty string")
    if task != "distill":
        raise ValueError(
            f"validate_distill_compat called with task={task!r} "
            "(expected 'distill')"
        )
    if backend == "mlx":
        raise ValueError(
            "task='distill' is not supported on backend=mlx in v0.52.0"
        )
    if teacher_model is None:
        raise ValueError(
            "task='distill' requires training.teacher_model to be set"
        )
    # Reuse the standard validator — null-byte / oversize / type check.
    validate_teacher_model(teacher_model)


def build_distill_trainer(
    config: object, **kwargs: object
) -> object:
    """Live distillation trainer factory (v0.53.2 #133).

    Returns a :class:`DistillTrainerWrapper`. Lazy import keeps the heavy
    transformers/peft surface out of schema-only import paths.
    """
    from soup_cli.trainer.distill import DistillTrainerWrapper

    return DistillTrainerWrapper(config, **kwargs)  # type: ignore[arg-type]
