"""Calendar proximity poller (Phase 2b §5).

Every ``poll_interval_seconds`` (default 300), query the macOS bridge for
upcoming events and emit proactive notifications at three thresholds:

    * T-10min
    * T-1min
    * T=0 (event start)

Each (event_id, threshold) pair fires exactly once per event. State lives in
``~/.mugi/calendar_proximity_state.json`` so proximity triggers survive
restarts.

This poller does NOT honor ``activeHours`` — calendar events are considered
time-critical. It does respect the master ``calendar_proximity.enabled`` pref
and can be disabled per-threshold via configuration.
"""

from __future__ import annotations

import json
import logging
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Threshold schema
# ---------------------------------------------------------------------------

THRESHOLDS = ("t_minus_10m", "t_minus_1m", "t_zero")
_THRESHOLD_SECONDS = {
    "t_minus_10m": 10 * 60,
    "t_minus_1m": 60,
    "t_zero": 0,
}

_DEFAULT_POLL_INTERVAL_SECONDS = 300.0
_STATE_FILENAME = "calendar_proximity_state.json"
_MAX_STATE_ROWS = 500
_PREWARM_THRESHOLD_SEC = 15 * 60  # run prewarm if event within 15 min

_stop_event = threading.Event()
_thread: threading.Thread | None = None


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------

def is_enabled() -> bool:
    try:
        from core.config import get_calendar_proximity_config
        return bool((get_calendar_proximity_config() or {}).get("enabled", False))
    except Exception:
        return False


def _poll_interval() -> float:
    try:
        from core.config import get_calendar_proximity_config
        cfg = get_calendar_proximity_config() or {}
        raw = float(cfg.get("poll_interval_seconds") or _DEFAULT_POLL_INTERVAL_SECONDS)
        return max(60.0, min(raw, 3600.0))
    except Exception:
        return _DEFAULT_POLL_INTERVAL_SECONDS


def _lookahead_window() -> float:
    """How far ahead to fetch events (in hours). Must cover T-10m threshold."""
    try:
        from core.config import get_calendar_proximity_config
        cfg = get_calendar_proximity_config() or {}
        return max(1.0, min(float(cfg.get("lookahead_hours") or 1.0), 4.0))
    except Exception:
        return 1.0


# ---------------------------------------------------------------------------
# State persistence
# ---------------------------------------------------------------------------

def _state_path() -> str:
    from persistence import MUGI_DIR
    return os.path.join(os.path.expanduser(MUGI_DIR), _STATE_FILENAME)


def _load_state() -> dict[str, dict[str, Any]]:
    p = _state_path()
    if not os.path.isfile(p):
        return {}
    try:
        with open(p, "r", encoding="utf-8") as f:
            data = json.load(f)
        if isinstance(data, dict):
            return {str(k): dict(v) for k, v in data.items() if isinstance(v, dict)}
    except Exception:
        _log.debug("calendar_proximity state load failed", exc_info=True)
    return {}


def _save_state(state: dict[str, dict[str, Any]]) -> None:
    p = _state_path()
    try:
        os.makedirs(os.path.dirname(p), exist_ok=True, mode=0o700)
        # Cap size to avoid unbounded growth across restarts.
        trimmed = dict(list(state.items())[-_MAX_STATE_ROWS:])
        tmp = p + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(trimmed, f)
        os.replace(tmp, p)
        try:
            os.chmod(p, 0o600)
        except OSError:
            pass
    except Exception:
        _log.debug("calendar_proximity state save failed", exc_info=True)


# ---------------------------------------------------------------------------
# Bridge integration
# ---------------------------------------------------------------------------

def _fetch_upcoming_events(hours: float) -> tuple[list[dict[str, Any]], str]:
    """Return (events, error). Empty list with empty error means no events."""
    try:
        from tools._macos_bridge import bridge_request, get_bridge_config
    except Exception as e:
        return [], f"bridge import failed: {e}"
    cfg, err = get_bridge_config()
    if cfg is None:
        return [], str(err or "bridge config missing")
    status, raw, http_err = bridge_request(
        cfg, f"/calendar/events?hours={int(max(1, hours))}", timeout=15,
    )
    if http_err:
        return [], f"bridge error: {http_err}"
    if status >= 400:
        return [], f"bridge http {status}"
    try:
        data = json.loads(raw.decode("utf-8") or "{}")
    except Exception:
        return [], "bridge returned non-JSON"
    events = data.get("events") if isinstance(data, dict) else data
    if not isinstance(events, list):
        return [], "bridge payload missing events"
    return events, ""


def _parse_start(raw: Any) -> datetime | None:
    if not raw:
        return None
    try:
        if isinstance(raw, (int, float)):
            return datetime.fromtimestamp(float(raw), tz=timezone.utc)
        s = str(raw)
        s = s.replace("Z", "+00:00") if s.endswith("Z") else s
        dt = datetime.fromisoformat(s)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Emission
# ---------------------------------------------------------------------------

@dataclass
class _Emission:
    event_id: str
    title: str
    threshold: str
    start_iso: str


def _emit_proximity(emission: _Emission) -> None:
    try:
        from persistence import PROACTIVE_QUEUE_PATH
        from utils.proactive_queue import append_proactive_queue
    except Exception:
        return

    summary = (
        f"[calendar:{emission.threshold}] {emission.title or '(untitled)'} "
        f"at {emission.start_iso}"
    )
    append_proactive_queue(
        PROACTIVE_QUEUE_PATH,
        summary[:400],
        source=f"calendar:{emission.threshold}",
        priority="high" if emission.threshold != "t_minus_10m" else "normal",
        dedupe_key=f"{emission.event_id}:{emission.threshold}",
        kind="calendar",
    )
    try:
        from utils.notifications import send_mac_notification
        send_mac_notification(
            title=f"Mugi: {emission.threshold.replace('_', ' ')}",
            subtitle=emission.title[:80] if emission.title else "calendar",
            body=f"Starts at {emission.start_iso}",
        )
    except Exception:
        _log.debug("calendar_proximity notification failed", exc_info=True)

    try:
        from utils import task_ledger as _tl
        _tl.record_run(
            _tl.KIND_CALENDAR,
            f"calendar_{emission.threshold}",
            status=_tl.STATUS_OK,
            meta={
                "event_id": emission.event_id,
                "threshold": emission.threshold,
                "start": emission.start_iso,
            },
        )
    except Exception:
        _log.debug("calendar_proximity ledger failed", exc_info=True)


# ---------------------------------------------------------------------------
# Tick logic
# ---------------------------------------------------------------------------

def run_tick(now: datetime | None = None) -> int:
    """Run a single proximity check. Returns the number of emissions fired.

    Exposed for tests; the loop calls it in a cadence driven by poll_interval.
    """
    ref = now or datetime.now(timezone.utc)
    hours = _lookahead_window()
    events, err = _fetch_upcoming_events(hours)
    if err and not events:
        _log.debug("calendar_proximity fetch error: %s", err)
        return 0

    state = _load_state()
    fired = 0
    prewarm_due = False

    for raw in events:
        if not isinstance(raw, dict):
            continue
        event_id = str(raw.get("id") or raw.get("uid") or raw.get("uuid") or "").strip()
        if not event_id:
            continue
        start = _parse_start(raw.get("start") or raw.get("startDate") or raw.get("start_iso"))
        if start is None:
            continue

        seconds_until = (start - ref).total_seconds()
        if seconds_until < -120:  # event started more than 2 min ago
            continue

        if 0 <= seconds_until <= _PREWARM_THRESHOLD_SEC:
            prewarm_due = True

        fired_set = state.setdefault(event_id, {})
        for threshold in THRESHOLDS:
            window = _THRESHOLD_SECONDS[threshold]
            # Fire when the scheduled fire time falls into a past-or-present window.
            # For t_minus_10m, fire when seconds_until <= 600 and > 60 (we also
            # coalesce on dedup). For t_zero, fire when seconds_until <= 0 & > -120.
            lower = -120 if threshold == "t_zero" else (
                _THRESHOLD_SECONDS["t_minus_1m"] if threshold == "t_minus_10m" else
                0
            )
            upper = window
            if lower < seconds_until <= upper and not fired_set.get(threshold):
                _emit_proximity(_Emission(
                    event_id=event_id,
                    title=str(raw.get("title") or raw.get("summary") or ""),
                    threshold=threshold,
                    start_iso=start.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
                ))
                fired_set[threshold] = ref.isoformat()
                fired += 1

    _save_state(state)

    if prewarm_due:
        try:
            from utils.active_memory import run_active_memory_prewarm
            run_active_memory_prewarm()
        except Exception:
            _log.debug("calendar_proximity prewarm failed", exc_info=True)

    return fired


# ---------------------------------------------------------------------------
# Loop
# ---------------------------------------------------------------------------

def run_calendar_proximity_loop() -> None:
    """Long-running loop. Sleep=poll_interval between ticks."""
    _log.info("Calendar proximity loop started (poll=%ss)", _poll_interval())
    while not _stop_event.is_set():
        try:
            run_tick()
        except Exception:
            _log.debug("calendar_proximity tick failed", exc_info=True)
        _stop_event.wait(timeout=_poll_interval())
    _log.info("Calendar proximity loop exiting")


def stop() -> None:
    _stop_event.set()


def _reset_for_tests() -> None:
    _stop_event.clear()
    p = _state_path()
    try:
        if os.path.isfile(p):
            os.remove(p)
    except OSError:
        pass
