"""Parse and apply control.command payloads."""

from __future__ import annotations

import logging
from typing import Any

from .config import ControlConfig
from .state import FILTER_MODES, HEAT_MODES, ControlState

logger = logging.getLogger("hot_tub.control")


class CommandError(ValueError):
    pass


def _clamp_target(target: float, cfg: ControlConfig) -> float:
    return max(cfg.target_f_min, min(cfg.target_f_max, target))


def apply_command(state: ControlState, cfg: ControlConfig, envelope: dict[str, Any]) -> bool:
    """Apply one command. Returns True if intents changed (persist recommended)."""
    op = envelope.get("op")
    if not op or not isinstance(op, str):
        raise CommandError("missing or invalid op")

    cmd_id = envelope.get("id")
    if cmd_id is not None:
        state.last_command_id = str(cmd_id)

    args = envelope.get("args")
    if args is not None and not isinstance(args, dict):
        raise CommandError("args must be an object")
    args = args or {}

    changed = False

    if op == "set_heat_mode":
        mode = args.get("mode")
        if mode not in HEAT_MODES:
            raise CommandError(f"invalid heat mode: {mode!r}")
        if state.heat_mode != mode:
            logger.info("heat_mode %s -> %s", state.heat_mode, mode)
            state.heat_mode = mode  # type: ignore[assignment]
            changed = True
        if mode == "sleep" and state.heat_phase == "error_startup":
            state.error = None
            state.startup_attempt = 0
            state.heat_phase = "idle"
            logger.info("error_startup cleared via sleep")
            changed = True
        return changed

    if op == "set_target_temp":
        raw = args.get("target_f")
        if raw is None:
            raise CommandError("target_f required")
        target = _clamp_target(float(raw), cfg)
        if state.target_f != target:
            logger.info("target_f %.1f -> %.1f", state.target_f, target)
            state.target_f = target
            changed = True
        return changed

    if op == "target_temp_increment":
        delta = float(args.get("delta_f", 1))
        target = _clamp_target(state.target_f + delta, cfg)
        if state.target_f != target:
            logger.info("target_f %.1f -> %.1f (+%s)", state.target_f, target, delta)
            state.target_f = target
            changed = True
        return changed

    if op == "target_temp_decrement":
        delta = float(args.get("delta_f", 1))
        target = _clamp_target(state.target_f - delta, cfg)
        if state.target_f != target:
            logger.info("target_f %.1f -> %.1f (-%s)", state.target_f, target, delta)
            state.target_f = target
            changed = True
        return changed

    if op == "set_filter_mode":
        mode = args.get("mode")
        if mode not in FILTER_MODES:
            raise CommandError(f"invalid filter mode: {mode!r}")
        if state.filter_mode != mode:
            logger.info("filter_mode %s -> %s", state.filter_mode, mode)
            state.filter_mode = mode  # type: ignore[assignment]
            changed = True
        return changed

    raise CommandError(f"unknown op: {op!r}")


def parse_command_message(raw: str) -> dict[str, Any]:
    import json

    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise CommandError("invalid JSON") from exc
    if not isinstance(data, dict):
        raise CommandError("command must be a JSON object")
    return data
