"""Per-type factories for ValueSource create / update flows.

``ValueSourceStore.create_source`` used to be a ~260-line ``if/elif``
chain over 14 ``source_type`` strings; ``update_source`` did the same
thing again with 14 ``isinstance`` branches (audit finding C7). Adding
a new value-source kind meant editing both chains and remembering to
keep them aligned.

This module replaces both ladders with a single ``CREATE_BUILDERS``
plus a single ``UPDATE_APPLIERS`` dict, both keyed by ``source_type``.
Each builder is a small free function that knows the per-type
construction rules (defaults, validation, sub-mode dispatch for
``adaptive_*``); each applier mutates an existing instance in-place
with the same defaulting / validation rules.

An import-time coverage assertion guarantees both registries stay in
sync with ``storage.value_source._VALUE_SOURCE_MAP`` so a new
``source_type`` cannot ship without both a builder and an applier.
"""

from __future__ import annotations

from datetime import datetime
from typing import Any, Callable, Dict, List

from ledgrab.storage.utils import resolve_ref
from ledgrab.storage.value_source import (
    VALID_SYSTEM_METRICS,
    AdaptiveValueSource,
    AdaptiveTimeColorValueSource,
    AnimatedColorValueSource,
    AnimatedValueSource,
    AudioValueSource,
    CSSExtractValueSource,
    DaylightValueSource,
    GameEventValueSource,
    GradientMapValueSource,
    HAEntityValueSource,
    HTTPValueSource,
    StaticColorValueSource,
    StaticValueSource,
    SystemMetricsValueSource,
    ValueSource,
    _VALUE_SOURCE_MAP,
)

CreateBuilder = Callable[..., ValueSource]
UpdateApplier = Callable[..., None]


# ---------------------------------------------------------------------------
# Common kwargs scaffold
# ---------------------------------------------------------------------------


def _common(
    sid: str,
    name: str,
    source_type: str,
    now: datetime,
    description: str | None,
    tags: List[str] | None,
    icon: str | None,
    icon_color: str | None,
) -> dict:
    """Build the kwargs dict shared by every ValueSource subclass __init__."""
    return dict(
        id=sid,
        name=name,
        source_type=source_type,
        created_at=now,
        updated_at=now,
        description=description,
        tags=tags or [],
        icon=icon or "",
        icon_color=icon_color or "",
    )


# ---------------------------------------------------------------------------
# Create builders — one per source_type
# Each builder takes the common scaffold plus the per-type kwargs from the
# store's CRUD method. Validation that's specific to one type lives here.
# ---------------------------------------------------------------------------


def _build_static(*, common: dict, value: float | None = None, **_) -> ValueSource:
    return StaticValueSource(**common, value=value if value is not None else 1.0)


def _build_animated(
    *,
    common: dict,
    waveform: str | None = None,
    speed: float | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    **_,
) -> ValueSource:
    return AnimatedValueSource(
        **common,
        waveform=waveform or "sine",
        speed=speed if speed is not None else 10.0,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 1.0,
    )


def _build_audio(
    *,
    common: dict,
    audio_source_id: str | None = None,
    mode: str | None = None,
    sensitivity: float | None = None,
    smoothing: float | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    auto_gain: bool | None = None,
    **_,
) -> ValueSource:
    return AudioValueSource(
        **common,
        audio_source_id=audio_source_id or "",
        mode=mode or "rms",
        sensitivity=sensitivity if sensitivity is not None else 1.0,
        smoothing=smoothing if smoothing is not None else 0.3,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 1.0,
        auto_gain=bool(auto_gain) if auto_gain is not None else False,
    )


def _build_adaptive_time(
    *,
    common: dict,
    schedule: list | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    **_,
) -> ValueSource:
    schedule_data = schedule or []
    if len(schedule_data) < 2:
        raise ValueError("Time of day schedule requires at least 2 points")
    return AdaptiveValueSource(
        **common,
        schedule=schedule_data,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 1.0,
    )


def _build_adaptive_scene(
    *,
    common: dict,
    picture_source_id: str | None = None,
    scene_behavior: str | None = None,
    sensitivity: float | None = None,
    smoothing: float | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    **_,
) -> ValueSource:
    return AdaptiveValueSource(
        **common,
        picture_source_id=picture_source_id or "",
        scene_behavior=scene_behavior or "complement",
        sensitivity=sensitivity if sensitivity is not None else 1.0,
        smoothing=smoothing if smoothing is not None else 0.3,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 1.0,
    )


def _build_daylight(
    *,
    common: dict,
    speed: float | None = None,
    use_real_time: bool | None = None,
    latitude: float | None = None,
    longitude: float | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    **_,
) -> ValueSource:
    return DaylightValueSource(
        **common,
        speed=speed if speed is not None else 1.0,
        use_real_time=bool(use_real_time) if use_real_time is not None else False,
        latitude=latitude if latitude is not None else 50.0,
        longitude=longitude if longitude is not None else 0.0,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 1.0,
    )


def _build_static_color(*, common: dict, color: list | None = None, **_) -> ValueSource:
    rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255]
    return StaticColorValueSource(**common, color=rgb)


def _build_animated_color(
    *,
    common: dict,
    colors: list | None = None,
    speed: float | None = None,
    easing: str | None = None,
    clock_id: str | None = None,
    **_,
) -> ValueSource:
    seed = (
        colors
        if isinstance(colors, list) and len(colors) >= 2
        else [[255, 0, 0], [0, 255, 0], [0, 0, 255]]
    )
    return AnimatedColorValueSource(
        **common,
        colors=seed,
        speed=speed if speed is not None else 10.0,
        easing=easing or "linear",
        clock_id=clock_id or None,
    )


def _build_adaptive_time_color(*, common: dict, schedule: list | None = None, **_) -> ValueSource:
    schedule_data = schedule or []
    if len(schedule_data) < 2:
        raise ValueError("Color schedule requires at least 2 points")
    return AdaptiveTimeColorValueSource(**common, schedule=schedule_data)


def _build_ha_entity(
    *,
    common: dict,
    ha_source_id: str | None = None,
    entity_id: str | None = None,
    attribute: str | None = None,
    min_ha_value: float | None = None,
    max_ha_value: float | None = None,
    smoothing: float | None = None,
    **_,
) -> ValueSource:
    if not ha_source_id:
        raise ValueError("HA source ID is required for ha_entity type")
    if not entity_id:
        raise ValueError("Entity ID is required for ha_entity type")
    return HAEntityValueSource(
        **common,
        ha_source_id=ha_source_id,
        entity_id=entity_id,
        attribute=attribute or "",
        min_ha_value=min_ha_value if min_ha_value is not None else 0.0,
        max_ha_value=max_ha_value if max_ha_value is not None else 100.0,
        smoothing=smoothing if smoothing is not None else 0.0,
    )


def _build_gradient_map(
    *,
    common: dict,
    value_source_id: str | None = None,
    gradient_id: str | None = None,
    easing: str | None = None,
    **_,
) -> ValueSource:
    if not value_source_id:
        raise ValueError("Value source ID is required for gradient_map type")
    return GradientMapValueSource(
        **common,
        value_source_id=value_source_id,
        gradient_id=gradient_id or "",
        easing=easing or "linear",
    )


def _build_css_extract(
    *,
    common: dict,
    color_strip_source_id: str | None = None,
    led_start: int | None = None,
    led_end: int | None = None,
    **_,
) -> ValueSource:
    if not color_strip_source_id:
        raise ValueError("Color strip source ID is required for css_extract type")
    return CSSExtractValueSource(
        **common,
        color_strip_source_id=color_strip_source_id,
        led_start=led_start if led_start is not None else 0,
        led_end=led_end if led_end is not None else -1,
    )


def _build_system_metrics(
    *,
    common: dict,
    metric: str | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    max_rate: float | None = None,
    disk_path: str | None = None,
    sensor_label: str | None = None,
    poll_interval: float | None = None,
    smoothing: float | None = None,
    **_,
) -> ValueSource:
    m = metric or "cpu_load"
    if m not in VALID_SYSTEM_METRICS:
        raise ValueError(f"Invalid metric: {m}")
    return SystemMetricsValueSource(
        **common,
        metric=m,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 100.0,
        max_rate=max_rate if max_rate is not None else 125_000_000.0,
        disk_path=disk_path or "",
        sensor_label=sensor_label or "",
        poll_interval=poll_interval if poll_interval is not None else 1.0,
        smoothing=smoothing if smoothing is not None else 0.0,
    )


def _build_game_event(
    *,
    common: dict,
    **_,
) -> ValueSource:
    # The store's pre-refactor ``create_source`` never accepted ``game_event``
    # (it wasn't in the ``_VALID`` tuple, and there's no Pydantic create
    # schema for it in ``api/schemas/value_sources.py``). GameEventValueSource
    # instances are constructed by other code paths (game integration setup,
    # data migration). The builder exists purely so the coverage assertion
    # stays symmetric with ``_VALUE_SOURCE_MAP`` — invoking it raises so the
    # historical "you cannot create a game_event source through the store"
    # contract is preserved.
    raise NotImplementedError(
        "game_event value sources cannot be created through ValueSourceStore; "
        "construct GameEventValueSource directly via the game-integration path"
    )


def _build_http(
    *,
    common: dict,
    http_endpoint_id: str | None = None,
    json_path: str | None = None,
    interval_s: int | None = None,
    min_value: float | None = None,
    max_value: float | None = None,
    smoothing: float | None = None,
    **_,
) -> ValueSource:
    if not http_endpoint_id:
        raise ValueError("http_endpoint_id is required for http type")
    iv = interval_s if interval_s is not None else 60
    if iv < 1:
        raise ValueError("interval_s must be >= 1")
    return HTTPValueSource(
        **common,
        http_endpoint_id=http_endpoint_id,
        json_path=json_path or "",
        interval_s=iv,
        min_value=min_value if min_value is not None else 0.0,
        max_value=max_value if max_value is not None else 100.0,
        smoothing=smoothing if smoothing is not None else 0.0,
    )


CREATE_BUILDERS: Dict[str, CreateBuilder] = {
    "static": _build_static,
    "animated": _build_animated,
    "audio": _build_audio,
    "adaptive_time": _build_adaptive_time,
    "adaptive_scene": _build_adaptive_scene,
    "daylight": _build_daylight,
    "static_color": _build_static_color,
    "animated_color": _build_animated_color,
    "adaptive_time_color": _build_adaptive_time_color,
    "ha_entity": _build_ha_entity,
    "gradient_map": _build_gradient_map,
    "css_extract": _build_css_extract,
    "system_metrics": _build_system_metrics,
    "game_event": _build_game_event,
    "http": _build_http,
}


def build_source(
    *,
    source_type: str,
    sid: str,
    name: str,
    now: datetime,
    description: str | None,
    tags: List[str] | None,
    icon: str | None,
    icon_color: str | None,
    **type_specific: Any,
) -> ValueSource:
    """Look up the per-type builder and produce a fresh ValueSource."""
    builder = CREATE_BUILDERS.get(source_type)
    if builder is None:
        raise ValueError(f"Invalid source type: {source_type}")
    common = _common(sid, name, source_type, now, description, tags, icon, icon_color)
    return builder(common=common, **type_specific)


# ---------------------------------------------------------------------------
# Update appliers — one per source_type (mutates the existing instance)
# ---------------------------------------------------------------------------


def _apply_static(source: StaticValueSource, *, value=None, **_) -> None:
    if value is not None:
        source.value = value


def _apply_animated(
    source: AnimatedValueSource,
    *,
    waveform=None,
    speed=None,
    min_value=None,
    max_value=None,
    **_,
) -> None:
    if waveform is not None:
        source.waveform = waveform
    if speed is not None:
        source.speed = speed
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value


def _apply_audio(
    source: AudioValueSource,
    *,
    audio_source_id=None,
    mode=None,
    sensitivity=None,
    smoothing=None,
    min_value=None,
    max_value=None,
    auto_gain=None,
    **_,
) -> None:
    if audio_source_id is not None:
        source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id)
    if mode is not None:
        source.mode = mode
    if sensitivity is not None:
        source.sensitivity = sensitivity
    if smoothing is not None:
        source.smoothing = smoothing
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value
    if auto_gain is not None:
        source.auto_gain = auto_gain


def _apply_adaptive_time(
    source: AdaptiveValueSource,
    *,
    schedule=None,
    min_value=None,
    max_value=None,
    **_,
) -> None:
    if schedule is not None:
        if len(schedule) < 2:
            raise ValueError("Time of day schedule requires at least 2 points")
        source.schedule = schedule
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value


def _apply_adaptive_scene(
    source: AdaptiveValueSource,
    *,
    picture_source_id=None,
    scene_behavior=None,
    sensitivity=None,
    smoothing=None,
    min_value=None,
    max_value=None,
    **_,
) -> None:
    if picture_source_id is not None:
        source.picture_source_id = resolve_ref(picture_source_id, source.picture_source_id)
    if scene_behavior is not None:
        source.scene_behavior = scene_behavior
    if sensitivity is not None:
        source.sensitivity = sensitivity
    if smoothing is not None:
        source.smoothing = smoothing
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value


def _apply_daylight(
    source: DaylightValueSource,
    *,
    speed=None,
    use_real_time=None,
    latitude=None,
    longitude=None,
    min_value=None,
    max_value=None,
    **_,
) -> None:
    if speed is not None:
        source.speed = speed
    if use_real_time is not None:
        source.use_real_time = use_real_time
    if latitude is not None:
        source.latitude = latitude
    if longitude is not None:
        source.longitude = longitude
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value


def _apply_static_color(source: StaticColorValueSource, *, color=None, **_) -> None:
    if color is not None and isinstance(color, list) and len(color) == 3:
        source.color = color


def _apply_animated_color(
    source: AnimatedColorValueSource,
    *,
    colors=None,
    speed=None,
    easing=None,
    clock_id=None,
    **_,
) -> None:
    if colors is not None and isinstance(colors, list):
        source.colors = colors
    if speed is not None:
        source.speed = speed
    if easing is not None:
        source.easing = easing
    if clock_id is not None:
        source.clock_id = resolve_ref(clock_id, source.clock_id)


def _apply_adaptive_time_color(source: AdaptiveTimeColorValueSource, *, schedule=None, **_) -> None:
    if schedule is not None:
        if len(schedule) < 2:
            raise ValueError("Color schedule requires at least 2 points")
        source.schedule = schedule


def _apply_ha_entity(
    source: HAEntityValueSource,
    *,
    ha_source_id=None,
    entity_id=None,
    attribute=None,
    min_ha_value=None,
    max_ha_value=None,
    smoothing=None,
    **_,
) -> None:
    if ha_source_id is not None:
        source.ha_source_id = resolve_ref(ha_source_id, source.ha_source_id)
    if entity_id is not None:
        source.entity_id = entity_id
    if attribute is not None:
        source.attribute = attribute
    if min_ha_value is not None:
        source.min_ha_value = min_ha_value
    if max_ha_value is not None:
        source.max_ha_value = max_ha_value
    if smoothing is not None:
        source.smoothing = smoothing


def _apply_gradient_map(
    source: GradientMapValueSource,
    *,
    value_source_id=None,
    gradient_id=None,
    easing=None,
    **_,
) -> None:
    if value_source_id is not None:
        source.value_source_id = resolve_ref(value_source_id, source.value_source_id)
    if gradient_id is not None:
        source.gradient_id = resolve_ref(gradient_id, source.gradient_id)
    if easing is not None:
        source.easing = easing


def _apply_css_extract(
    source: CSSExtractValueSource,
    *,
    color_strip_source_id=None,
    led_start=None,
    led_end=None,
    **_,
) -> None:
    if color_strip_source_id is not None:
        source.color_strip_source_id = resolve_ref(
            color_strip_source_id, source.color_strip_source_id
        )
    if led_start is not None:
        source.led_start = led_start
    if led_end is not None:
        source.led_end = led_end


def _apply_system_metrics(
    source: SystemMetricsValueSource,
    *,
    metric=None,
    min_value=None,
    max_value=None,
    max_rate=None,
    disk_path=None,
    sensor_label=None,
    poll_interval=None,
    smoothing=None,
    **_,
) -> None:
    if metric is not None:
        if metric not in VALID_SYSTEM_METRICS:
            raise ValueError(f"Invalid metric: {metric}")
        source.metric = metric
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value
    if max_rate is not None:
        source.max_rate = max_rate
    if disk_path is not None:
        source.disk_path = disk_path
    if sensor_label is not None:
        source.sensor_label = sensor_label
    if poll_interval is not None:
        source.poll_interval = poll_interval
    if smoothing is not None:
        source.smoothing = smoothing


def _apply_game_event(source: GameEventValueSource, **_) -> None:
    # No update fields are exposed through the store for game-event yet;
    # registered for symmetry with CREATE_BUILDERS.
    return None


def _apply_http(
    source: HTTPValueSource,
    *,
    http_endpoint_id=None,
    json_path=None,
    interval_s=None,
    min_value=None,
    max_value=None,
    smoothing=None,
    **_,
) -> None:
    if http_endpoint_id is not None:
        source.http_endpoint_id = resolve_ref(http_endpoint_id, source.http_endpoint_id)
    if json_path is not None:
        source.json_path = json_path
    if interval_s is not None:
        if interval_s < 1:
            raise ValueError("interval_s must be >= 1")
        source.interval_s = interval_s
    if min_value is not None:
        source.min_value = min_value
    if max_value is not None:
        source.max_value = max_value
    if smoothing is not None:
        source.smoothing = smoothing


UPDATE_APPLIERS: Dict[str, UpdateApplier] = {
    "static": _apply_static,
    "animated": _apply_animated,
    "audio": _apply_audio,
    "adaptive_time": _apply_adaptive_time,
    "adaptive_scene": _apply_adaptive_scene,
    "daylight": _apply_daylight,
    "static_color": _apply_static_color,
    "animated_color": _apply_animated_color,
    "adaptive_time_color": _apply_adaptive_time_color,
    "ha_entity": _apply_ha_entity,
    "gradient_map": _apply_gradient_map,
    "css_extract": _apply_css_extract,
    "system_metrics": _apply_system_metrics,
    "game_event": _apply_game_event,
    "http": _apply_http,
}


def apply_update(source: ValueSource, **kwargs: Any) -> None:
    """Dispatch to the per-type applier; no-op for unknown source_type."""
    applier = UPDATE_APPLIERS.get(source.source_type)
    if applier is None:
        # Unknown source_type means the registry drifted from storage; the
        # coverage assertion at import time should have caught this.
        return
    applier(source, **kwargs)


# ---------------------------------------------------------------------------
# Coverage assertion — both registries stay in sync with storage's map
# ---------------------------------------------------------------------------


def _assert_factory_coverage() -> None:
    storage_kinds = set(_VALUE_SOURCE_MAP.keys())
    create_kinds = set(CREATE_BUILDERS.keys())
    update_kinds = set(UPDATE_APPLIERS.keys())

    problems: list[str] = []
    if create_kinds != storage_kinds:
        if missing := storage_kinds - create_kinds:
            problems.append(f"CREATE_BUILDERS missing: {sorted(missing)}")
        if extra := create_kinds - storage_kinds:
            problems.append(f"CREATE_BUILDERS extra: {sorted(extra)}")
    if update_kinds != storage_kinds:
        if missing := storage_kinds - update_kinds:
            problems.append(f"UPDATE_APPLIERS missing: {sorted(missing)}")
        if extra := update_kinds - storage_kinds:
            problems.append(f"UPDATE_APPLIERS extra: {sorted(extra)}")
    if problems:
        raise RuntimeError(
            "value_source_factories registries are out of sync with "
            "storage._VALUE_SOURCE_MAP: " + "; ".join(problems)
        )


_assert_factory_coverage()
