"""Shared `activeHours` quiet-hours gate for heartbeat / idle_tick / curiosity.

Webhooks and cron are NOT gated by this module — see plan §2.

Pref shape in ``~/.mugi/app_prefs.json``::

    "activeHoursV1": [
        {"start": "08:00", "end": "22:00", "weekdays": [0,1,2,3,4]},
        {"start": "10:00", "end": "20:00", "weekdays": [5,6]}
    ]

Empty list (or missing key) means "always active" — the historical default,
so the gate is opt-in per-pref.

The gate is evaluated at tick *entry* only; a heartbeat that started at
21:59 with a 22:00 boundary will run to completion. The next tick at 22:00:xx
is skipped.
"""

from __future__ import annotations

import datetime as _dt
import logging
from dataclasses import dataclass
from typing import Any

_log = logging.getLogger(__name__)

PREF_KEY = "activeHoursV1"


@dataclass(frozen=True)
class ActiveWindow:
    start: _dt.time
    end: _dt.time
    weekdays: frozenset[int]  # Monday=0 .. Sunday=6

    def matches(self, now: _dt.datetime) -> bool:
        if now.weekday() not in self.weekdays:
            return False
        t = now.time()
        if self.start == self.end:
            return False
        if self.start < self.end:
            return self.start <= t < self.end
        # Overnight window e.g. 22:00 -> 06:00 — split logic.
        return t >= self.start or t < self.end


def _parse_time(s: str) -> _dt.time | None:
    try:
        h, m = str(s).split(":", 1)
        hi = int(h)
        mi = int(m)
        if 0 <= hi <= 23 and 0 <= mi <= 59:
            return _dt.time(hour=hi, minute=mi)
    except Exception:
        pass
    return None


def _parse_window(raw: Any) -> ActiveWindow | None:
    if not isinstance(raw, dict):
        return None
    start = _parse_time(raw.get("start", ""))
    end = _parse_time(raw.get("end", ""))
    if start is None or end is None or start == end:
        return None
    wd_in = raw.get("weekdays") or []
    weekdays: set[int] = set()
    if isinstance(wd_in, (list, tuple)):
        for w in wd_in:
            try:
                wi = int(w)
            except (TypeError, ValueError):
                continue
            if 0 <= wi <= 6:
                weekdays.add(wi)
    if not weekdays:
        return None
    return ActiveWindow(start=start, end=end, weekdays=frozenset(weekdays))


def _read_pref(default: list | None = None):
    """Best-effort load of ``activeHoursV1`` from ``app_prefs.json``."""
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        prefs = load_app_prefs_cached()
        return prefs.get(PREF_KEY, default)
    except Exception:
        return default


def load_active_hours_from_prefs() -> list[ActiveWindow]:
    """Return parsed windows; empty list = always active (default)."""
    raw = _read_pref(default=[])
    if not isinstance(raw, list) or not raw:
        return []
    out: list[ActiveWindow] = []
    for item in raw:
        win = _parse_window(item)
        if win is not None:
            out.append(win)
    return out


def is_active_now(
    windows: list[ActiveWindow] | None = None,
    *,
    now: _dt.datetime | None = None,
    force_active: bool = False,
) -> bool:
    """Return True iff the local time is inside any active window.

    Empty / missing windows → True (always active, historical default).
    ``force_active=True`` short-circuits to True; used by webhook-wake.
    """
    if force_active:
        return True
    if windows is None:
        windows = load_active_hours_from_prefs()
    if not windows:
        return True
    n = now or _dt.datetime.now()
    return any(w.matches(n) for w in windows)


def next_active_at(
    windows: list[ActiveWindow] | None = None,
    *,
    now: _dt.datetime | None = None,
) -> _dt.datetime | None:
    """Return the next datetime when the gate transitions to active.

    Returns None when always-active (no windows). Coarse minute resolution.
    """
    if windows is None:
        windows = load_active_hours_from_prefs()
    if not windows:
        return None
    n = now or _dt.datetime.now()
    if any(w.matches(n) for w in windows):
        return n
    # Walk forward in 1-minute steps up to 8 days; cheap and covers DST oddities.
    cursor = n.replace(second=0, microsecond=0) + _dt.timedelta(minutes=1)
    horizon = n + _dt.timedelta(days=8)
    while cursor < horizon:
        if any(w.matches(cursor) for w in windows):
            return cursor
        cursor += _dt.timedelta(minutes=1)
    return None


# ---------------------------------------------------------------------------
# One-shot bypass (webhook wake)
# ---------------------------------------------------------------------------
#
# When an inbound webhook arrives during quiet hours we want the *next*
# standard heartbeat to run regardless of activeHours. Webhook handler
# calls ``arm_force_active()`` after enqueueing the proactive item and
# pinging the runner. The heartbeat path consumes the flag exactly once.

import threading as _threading

_force_active_lock = _threading.Lock()
_force_active_armed = False


def arm_force_active() -> None:
    """Mark the next ``consume_force_active()`` call as True (one-shot)."""
    global _force_active_armed
    with _force_active_lock:
        _force_active_armed = True


def consume_force_active() -> bool:
    """Return True iff a force-active bypass was armed; clears the flag."""
    global _force_active_armed
    with _force_active_lock:
        if _force_active_armed:
            _force_active_armed = False
            return True
        return False


def status_snapshot() -> dict[str, Any]:
    """Compact JSON-friendly status for MCP / debug routes."""
    windows = load_active_hours_from_prefs()
    n = _dt.datetime.now()
    active = is_active_now(windows, now=n)
    nxt = next_active_at(windows, now=n)
    return {
        "active": bool(active),
        "windows": [
            {
                "start": w.start.strftime("%H:%M"),
                "end": w.end.strftime("%H:%M"),
                "weekdays": sorted(w.weekdays),
            }
            for w in windows
        ],
        "now": n.isoformat(timespec="seconds"),
        "next_active_at": nxt.isoformat(timespec="seconds") if nxt else None,
        "always_active": not windows,
    }
