#!/usr/bin/env python3
"""
Agent Inbox Listener — the IMAP IDLE daemon
============================================
A persistent daemon that watches the agent mailbox's INBOX over IMAP IDLE (RFC 2177) and
processes inbound mail in near-real-time, instead of waiting for the scheduled poll. This
is the low-latency front door to the agent's inbox — the foundation for email becoming a
primary interface (team replies and the owner's own higher-trust feedback/edits).

DESIGN — the daemon decides WHEN to process, never HOW:
  - It holds ONE long-lived IMAP connection parked in IDLE on INBOX (the "doorbell"). At
    rest it is asleep on the socket — ~0 CPU until the server pushes a change.
  - On a mailbox change (new mail), on every ~25-min keepalive, and on every (re)connect,
    it triggers the SAME inbox processors the scheduled poll uses — each opens its own
    short-lived connection to search/gate/apply/confirm. So there is ONE processing path
    and one set of gates; the daemon only changes latency. The processors are re-imported
    each trigger, so a synced code change is picked up without restarting the daemon.
  - Safety nets, in layers: (1) a catch-up process() on every reconnect reconciles anything
    that arrived while disconnected (IDLE alone can miss mail during a drop); (2) the
    ~25-min keepalive doubles as a poll floor; (3) the scheduled poll's inbox task remains
    the guaranteed backstop if this daemon is ever down. A cross-process flock in each
    processor keeps the daemon and the poll from racing.
  - Reconnect with exponential backoff on any error — residential/satellite links drop
    connections; the daemon must treat that as normal and re-establish, never wedge.

ROUTING: each processor (team_inbox / principal_inbox / federation_inbox) routes its own
messages by sender and enforces its own trust gates. This daemon is sender-agnostic — it
just rings the doorbell and lets whichever processors are installed do their gated work.
A processor module that isn't installed (its module disabled) is simply skipped.

Run: launchd `com.alexandria.agent-listener` (KeepAlive, RunAtLoad), or the wrapper
agent-listener.sh, or `python3 agent_listener.py`. Exits immediately (no respawn churn)
when INBOX_LISTENER_ENABLE != 1. Needs `imapclient` (pip install --user imapclient);
without it the daemon stays out of the way and the scheduled poll still covers replies.
Env: IMAP_HOST, IMAP_USER, IMAP_PASS (the agent mailbox's app password), plus each
processor's own config, plus IDLE_KEEPALIVE_SECS (default 1500 = 25 min) /
IDLE_BACKOFF_MAX_SECS (300).
"""
import os
import sys
import time
import json
import datetime as dt
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, load_fresh

# `--vault` must land in VAULT_DIR before any processor freezes its config at import.
consume_vault_arg()

KEEPALIVE   = int(os.environ.get("IDLE_KEEPALIVE_SECS", "1500"))     # < Gmail's ~30-min IDLE drop
BACKOFF_MAX = int(os.environ.get("IDLE_BACKOFF_MAX_SECS", "300"))

# The inbox processors this daemon triggers. Each is OPTIONAL — a processor whose
# Alexandria module isn't enabled for this vault is skipped, not an error. Each
# exposes process(mode, sink). (The gate used to be "processor file rendered into the
# vault"; the engine files always exist app-side now, so the config carries it.)
PROCESSORS = ("team_inbox", "principal_inbox", "federation_inbox")
PROCESSOR_MODULE = {"team_inbox": "team-digests",
                    "principal_inbox": "principal-inbox",
                    "federation_inbox": "federation"}

# Liveness heartbeat, co-located with the sweep's runner-status.json so `alexandria doctor`
# can tell whether the listener is actually watching (vs. crashed / throttle-looping).
STATUS_PATH = Path(os.environ.get(
    "SWEEP_STATE", str(Path.home() / "Obsidian" / "secrets" / "sweep-state.json"))
).parent / "listener-status.json"


def log(msg):
    print(f"[{dt.datetime.now().isoformat(timespec='seconds')}] agent_listener: {msg}", flush=True)


def _enabled_modules():
    """The vault's modules.enabled list, resolved through the digest engine's own
    config load (so VAULT_DIR/--vault applies uniformly). Re-read on each trigger,
    like the processors themselves."""
    try:
        cfg = load_fresh("digest").CONFIG
        return ((cfg.get("modules") or {}).get("enabled")) or []
    except Exception:
        return []


def _load(name):
    """Import an inbox processor fresh, or None when its module isn't enabled for
    this vault — the config-driven replacement for the old file-absent skip."""
    if PROCESSOR_MODULE.get(name) not in _enabled_modules():
        return None
    return load_fresh(name)


def _process(reason):
    """Trigger every installed inbox processor — each opens its own short-lived connection
    and shares the same cross-process lock, so they run sequentially and never race.
    Re-imported each call so synced code updates land without a restart. A processor whose
    module isn't enabled is silently skipped."""
    acted = []
    for name in PROCESSORS:
        try:
            mod = _load(name)
            if mod is None:
                continue
            results = mod.process(mode="apply", sink=None)
            acted += [r for r in (results or [])
                      if "processed" in r or "failed" in r or "skipping" in r]
        except Exception as e:
            log(f"{name} error ({reason}): {type(e).__name__}: {e}")
    log(f"trigger ({reason}): {'; '.join(acted) if acted else 'no new mail'}")
    try:
        STATUS_PATH.parent.mkdir(parents=True, exist_ok=True)
        STATUS_PATH.write_text(json.dumps({
            "at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
            "alive": True, "note": reason}))
    except Exception as e:
        log(f"heartbeat write failed: {e}")


def run():
    enable = os.environ.get("INBOX_LISTENER_ENABLE", "0")
    if enable in ("0", "false", ""):
        log("disabled (INBOX_LISTENER_ENABLE != 1) — exiting")
        return
    # The listener authenticates as the agent mailbox over IMAP. Prefer IMAP_USER/IMAP_PASS;
    # fall back to SMTP_USER/SMTP_PASS when the same mailbox/app-password serves both.
    user = os.environ.get("IMAP_USER") or os.environ.get("SMTP_USER")
    pw = os.environ.get("IMAP_PASS") or os.environ.get("SMTP_PASS")
    host = os.environ.get("IMAP_HOST", "imap.gmail.com")
    if not user or not pw:
        log("IMAP_USER/IMAP_PASS (or SMTP_USER/SMTP_PASS) not set — exiting")
        return
    try:
        from imapclient import IMAPClient
    except ImportError:
        # Don't crash-loop: the scheduled poll still covers replies. Sleep, then exit so
        # launchd's throttle keeps respawns slow until imapclient is installed.
        log("imapclient not installed (pip install --user imapclient) — scheduled poll "
            "still covers replies; idling 1h then exiting")
        time.sleep(3600)
        return

    backoff = 5
    while True:
        try:
            with IMAPClient(host, ssl=True) as server:
                server.login(user, pw)
                server.select_folder("INBOX")
                log(f"connected as {user}; watching INBOX via IDLE (keepalive {KEEPALIVE}s)")
                backoff = 5
                _process("connect catch-up")          # reconcile anything missed while down
                while True:
                    server.idle()
                    try:
                        responses = server.idle_check(timeout=KEEPALIVE)
                    finally:
                        server.idle_done()             # must exit IDLE before any other cmd
                    if responses:
                        log(f"INBOX activity {responses!r}")
                        _process("idle push")
                    else:
                        _process("keepalive")          # timeout — cheap poll floor + keeps conn warm
        except KeyboardInterrupt:
            log("interrupted — exiting")
            return
        except Exception as e:
            log(f"connection error: {type(e).__name__}: {e} — reconnecting in {backoff}s")
            time.sleep(backoff)
            backoff = min(BACKOFF_MAX, backoff * 2)


if __name__ == "__main__":
    run()
