"""Adaptive heartbeat pacing — pure functions over (config, time, signals).

The heartbeat has a BASE interval (default 900s/15min). Pacing is an opt-in
layer that multiplies the base interval by context-sensitive factors so the
loop slows down in quiet hours and speeds up when there's active work.

This module is deliberately pure: no I/O, no clocks, no globals. The engine
in ``heartbeat.py`` loads the YAML config + reads live signals, calls
``compute_sleep()`` with those inputs, and acts on the returned decision.

Rule 12 compliance: every tuning knob (quiet-hours window, multipliers,
thresholds, clamps) lives in ``_meta/heartbeat-pulse/config/pacing.yaml``.
Missing/malformed config falls through to base behavior — identical to
pre-pacing (pacing is off-by-default on any broken input).
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from datetime import time as dtime


@dataclass(frozen=True)
class PacingDecision:
    """The engine's decision for the next heartbeat sleep interval.

    Fields:
      - ``seconds``: how long to sleep before the next pulse.
      - ``reason``: short token for activity/status surfaces
        (``base`` / ``quiet_hours`` / ``busy_boost`` / ``clamp_min``
        / ``clamp_max`` / ``disabled``). Stable strings — UI/CLI may
        display them verbatim.
      - ``multiplier``: effective scaling applied to base interval
        (1.0 means no change). Useful for dashboards.
    """

    seconds: float
    reason: str
    multiplier: float


def _parse_hhmm(value: str) -> dtime | None:
    """Parse ``"HH:MM"`` -> ``datetime.time``. Returns None on any failure."""
    if not isinstance(value, str) or not value.strip():
        return None
    try:
        parts = value.strip().split(":")
        if len(parts) != 2:
            return None
        h = int(parts[0])
        m = int(parts[1])
        if not (0 <= h < 24 and 0 <= m < 60):
            return None
        return dtime(hour=h, minute=m)
    except (ValueError, TypeError):
        return None


def _in_quiet_hours(now: datetime, start: dtime, end: dtime) -> bool:
    """True when ``now`` falls inside the [start, end) window.

    Handles windows that cross midnight (e.g. 22:00 -> 07:00).
    ``now`` is interpreted as a naive local time — the caller is
    responsible for timezone conversion before passing it in.
    """
    t = now.time()
    if start == end:
        return False
    if start < end:
        return start <= t < end
    # Window crosses midnight: in if AFTER start OR BEFORE end.
    return t >= start or t < end


def compute_sleep(
    base_seconds: float,
    now_local: datetime,
    cfg: dict | None,
    signals: dict | None = None,
) -> PacingDecision:
    """Return the adaptive sleep duration for the next heartbeat tick.

    Arguments:
      - ``base_seconds``: the heartbeat's base interval.
      - ``now_local``: the current time in the user's local timezone
        (naive datetime). Used to decide quiet-hours membership.
      - ``cfg``: the ``pacing`` block from the skill YAML. When None,
        empty, or ``enabled=False``, returns base with reason ``disabled``.
      - ``signals``: live signals from the engine. Currently:
        ``{"pending_queue": int, "active_goals": int}``. None == {}.

    Decision order:
      1. If pacing is disabled, return base unchanged.
      2. If busy-boost is enabled and a threshold trips, apply the
         busy multiplier (typically < 1.0 — tick faster).
      3. Else if currently in quiet hours, apply the quiet multiplier
         (typically > 1.0 — tick slower).
      4. Else base.
      5. Clamp the result to [min_seconds, max_seconds].

    Busy-boost wins over quiet hours on purpose: if there are pending
    jobs or active goal pursuits, slowing down at night is the wrong
    move. The user explicitly asked the agent to work on those.
    """
    signals = signals or {}
    if not cfg or not cfg.get("enabled"):
        return PacingDecision(
            seconds=float(base_seconds),
            reason="disabled",
            multiplier=1.0,
        )

    clamp = cfg.get("clamp") or {}
    min_seconds = float(clamp.get("min_seconds", 60))
    max_seconds = float(clamp.get("max_seconds", 3600))

    # ── Priority 1: busy-boost when there's active work ──
    busy = cfg.get("busy_boost") or {}
    if busy.get("enabled"):
        pending = int(signals.get("pending_queue", 0) or 0)
        active_goals = int(signals.get("active_goals", 0) or 0)
        pending_threshold = int(busy.get("pending_queue_threshold", 3))
        goal_threshold = int(busy.get("active_goal_threshold", 1))
        if (
            pending >= pending_threshold
            or active_goals >= goal_threshold
        ):
            multiplier = float(busy.get("multiplier", 0.5))
            effective = base_seconds * multiplier
            clamped = max(min_seconds, min(max_seconds, effective))
            if clamped == min_seconds and effective < min_seconds:
                reason = "clamp_min"
            elif clamped == max_seconds and effective > max_seconds:
                reason = "clamp_max"
            else:
                reason = "busy_boost"
            return PacingDecision(
                seconds=clamped,
                reason=reason,
                multiplier=clamped / base_seconds if base_seconds else 1.0,
            )

    # ── Priority 2: quiet hours ──
    quiet = cfg.get("quiet_hours") or {}
    start = _parse_hhmm(quiet.get("start", ""))
    end = _parse_hhmm(quiet.get("end", ""))
    if start and end and _in_quiet_hours(now_local, start, end):
        multiplier = float(quiet.get("multiplier", 4.0))
        effective = base_seconds * multiplier
        clamped = max(min_seconds, min(max_seconds, effective))
        if clamped == min_seconds and effective < min_seconds:
            reason = "clamp_min"
        elif clamped == max_seconds and effective > max_seconds:
            reason = "clamp_max"
        else:
            reason = "quiet_hours"
        return PacingDecision(
            seconds=clamped,
            reason=reason,
            multiplier=clamped / base_seconds if base_seconds else 1.0,
        )

    # ── Priority 3: base ──
    clamped = max(min_seconds, min(max_seconds, float(base_seconds)))
    if clamped == min_seconds and base_seconds < min_seconds:
        reason = "clamp_min"
    elif clamped == max_seconds and base_seconds > max_seconds:
        reason = "clamp_max"
    else:
        reason = "base"
    return PacingDecision(
        seconds=clamped,
        reason=reason,
        multiplier=clamped / base_seconds if base_seconds else 1.0,
    )
