"""Control daemon main loop."""

from __future__ import annotations

import logging
import os
import signal
import time
from datetime import datetime, timezone
from typing import Any

from .commands import CommandError, apply_command, parse_command_message
from .config import ControlConfig
from .filter_policy import desired_filter_pump
from .gpio_out import RelayOutputs
from .heat_fsm import tick_heat
from .redis_io import CHAN_COMMAND, CHAN_TEMPS_READING, ControlRedis
from .safety import temps_fresh
from .state import ControlState, FsmContext, load_persisted_state, save_persisted_state, state_snapshot

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

_stop = False


def _handle_signal(signum: int, _frame: object) -> None:
    global _stop
    logger.info("Received signal %s, shutting down", signum)
    _stop = True


def _utc_now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _health_status(state: ControlState, cfg: ControlConfig, reading: dict[str, Any] | None) -> tuple[str, str]:
    if state.heat_phase.startswith("error_"):
        return "degraded", state.heat_phase
    if state.heat_mode == "maintain" and not temps_fresh(reading, cfg):
        return "degraded", "stale temps while maintain"
    return "ok", "loop running"


def _apply_gpio(relays: RelayOutputs, state: ControlState) -> None:
    relays.sync(
        heat=state.heat_pump == "on",
        filter_pump=state.filter_pump == "on",
    )
    state.heat_pump = "on" if relays.heat_on else "off"
    state.filter_pump = "on" if relays.filter_on else "off"


class ControlEngine:
    def __init__(self, cfg: ControlConfig, *, dry_run: bool = False) -> None:
        self.cfg = cfg
        self.dry_run = dry_run
        self.state = load_persisted_state(cfg.state_path, default_target=cfg.target_f_default)
        self.ctx = FsmContext()
        self.reading: dict[str, Any] | None = None
        self.relays = RelayOutputs(cfg.gpio, dry_run=dry_run)
        self.relays.all_off()
        self._last_persist_hash: str | None = None
        self._last_publish_mono = 0.0
        self._last_snapshot_json: str | None = None

    def close(self) -> None:
        self.relays.all_off()
        self.relays.close()

    def _maybe_persist(self) -> None:
        body = str(self.state.to_persist())
        if body == self._last_persist_hash:
            return
        if not self.dry_run:
            save_persisted_state(self.cfg.state_path, self.state)
        self._last_persist_hash = body

    def ingest_temps(self, payload: dict[str, Any]) -> None:
        self.reading = payload

    def handle_command_raw(self, raw: str) -> None:
        try:
            envelope = parse_command_message(raw)
            changed = apply_command(self.state, self.cfg, envelope)
            if changed:
                self._maybe_persist()
        except CommandError as exc:
            logger.warning("command rejected: %s", exc)
        except Exception:
            logger.exception("command handling failed")

    def tick(self) -> dict[str, Any]:
        tick_heat(self.state, self.ctx, self.cfg, self.reading)
        filt = desired_filter_pump(self.state, self.cfg, self.ctx)
        self.state.filter_pump = filt
        _apply_gpio(self.relays, self.state)
        return state_snapshot(self.state, ts=_utc_now())

    def should_publish(self, snapshot: dict[str, Any], now_mono: float) -> bool:
        body = str(sorted(snapshot.items()))
        if body != self._last_snapshot_json:
            return True
        return (now_mono - self._last_publish_mono) >= self.cfg.heartbeat_s

    def mark_published(self, snapshot: dict[str, Any], now_mono: float) -> None:
        self._last_snapshot_json = str(sorted(snapshot.items()))
        self._last_publish_mono = now_mono


def run_once(
    cfg: ControlConfig,
    *,
    dry_run: bool = False,
    engine: ControlEngine | None = None,
    redis_io: ControlRedis | None = None,
) -> dict[str, Any]:
    eng = engine or ControlEngine(cfg, dry_run=dry_run)
    if eng.reading is None and not dry_run:
        r = redis_io or ControlRedis(cfg.redis_url)
        try:
            latest = r.get_temps_latest()
            if latest:
                eng.ingest_temps(latest)
        finally:
            if redis_io is None:
                r.close()
    snapshot = eng.tick()
    return snapshot


def run_loop(cfg: ControlConfig, *, dry_run: bool = False) -> None:
    global _stop
    _stop = False
    signal.signal(signal.SIGTERM, _handle_signal)
    signal.signal(signal.SIGINT, _handle_signal)

    logger.info("Control daemon starting (dry_run=%s)", dry_run)
    engine = ControlEngine(cfg, dry_run=dry_run)
    redis_io: ControlRedis | None = None
    if not dry_run:
        redis_io = ControlRedis(cfg.redis_url)
        latest = redis_io.get_temps_latest()
        if latest:
            engine.ingest_temps(latest)

    try:
        while not _stop:
            loop_start = time.monotonic()
            if redis_io is not None:
                for channel, data in redis_io.poll_messages(timeout_s=min(0.5, cfg.loop_interval_s)):
                    if channel == CHAN_COMMAND:
                        engine.handle_command_raw(data)
                    elif channel == CHAN_TEMPS_READING:
                        try:
                            import json

                            payload = json.loads(data)
                            if isinstance(payload, dict):
                                engine.ingest_temps(payload)
                        except json.JSONDecodeError:
                            logger.warning("invalid temps.reading JSON")

            snapshot = engine.tick()
            now_mono = time.monotonic()

            if engine.should_publish(snapshot, now_mono):
                status, detail = _health_status(engine.state, cfg, engine.reading)
                health = {
                    "service": "control",
                    "ts": snapshot["ts"],
                    "status": status,
                    "detail": detail,
                    "pid": os.getpid(),
                }
                if dry_run:
                    logger.debug("state %s", snapshot)
                elif redis_io is not None:
                    redis_io.publish_state(snapshot)
                    redis_io.publish_health(health)
                engine.mark_published(snapshot, now_mono)

            elapsed = time.monotonic() - loop_start
            sleep_s = max(0.0, cfg.loop_interval_s - elapsed)
            if sleep_s > 0 and not _stop:
                time.sleep(sleep_s)
    finally:
        if redis_io is not None:
            redis_io.close()
        engine.close()
        logger.info("Control daemon stopped")
