#!/usr/bin/env python3
"""federation_inbox.py — inter-vault federation: receive structured pushes from PEER
Alexandria vaults over authenticated email, and send pushes to peers.

This is the third sender-class of the vault inbox (alongside the owner and team paths).
A peer's vault agent emails this vault's ingress address an "alexandria-message" envelope;
this receiver validates it hard and files its intents as DATA.

SECURITY MODEL (the whole point — read before editing):
  1. ALLOWLIST     — the sender must be a known peer in config.peers[] (matched on
                     agent_email). Nothing else is ever processed.
  2. AUTHENTIC     — Gmail Authentication-Results must show DKIM=pass and SPF=pass aligned
                     to the From domain (fails CLOSED). So a spoofed allowlisted address is
                     rejected. Disable only via FEDERATION_REQUIRE_AUTH=0 (not recommended).
  3. TOKEN (opt)   — if a peer declares a token (auth.token_ref -> a *_TOKEN_SHA256 env
                     var), the body must carry a token whose sha256 matches. A second factor.
  4. DATA, NEVER INSTRUCTIONS — the body is parsed into a FIXED intent schema
                     (todo / note / knowledge / event). The applier ONLY files content. It
                     never executes anything in the email: no sending mail, no reading or
                     exfiltrating other notes, no shell, no tools. This boundary is what
                     makes accepting outside email safe.
  5. PER-PEER SCOPE — a peer may write only the intent types + areas its
                     permissions allow; CONFIDENTIAL areas are NEVER peer-writable; an
                     out-of-scope intent is parked in a review note for the owner, never
                     applied silently.
  6. WRITE-ONLY    — peers can push, never read. Every applied/parked/rejected message is
                     appended to the off-repo audit trail with provenance.

Idempotency + audit live in federation_state (off-repo). Config (peers, ingress address,
peer permissions) comes from Agent/config.json; IMAP creds from the environment.
"""
import hashlib
import imaplib
import json
import os
import re
import ssl
import sys
import datetime as dt
from email import message_from_bytes
from email.utils import parseaddr
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, load_fresh
# The applier + intent schema now live in the shared federation package (V4 P2,
# docs/V4-FEDERATION.md §5.1) so the relay transport reuses this exact code path.
# Imported back here so every existing reference (and the V1 characterization
# tests) sees the same objects — a behavior-preserving move, not a rewrite.
from alexandria_vault.federation.apply import (  # noqa: F401  (re-exported)
    ALLOWED_INTENTS, apply_intents, validate_intents, _permits)

# `--vault` must land in VAULT_DIR before any import-time config/vault resolution.
consume_vault_arg()

ENVELOPE_RE = re.compile(r"```alexandria-message\s*(.*?)```", re.S)
REQUIRE_AUTH = os.environ.get("FEDERATION_REQUIRE_AUTH", "1") not in ("0", "false", "")
LOOKBACK_DAYS = int(os.environ.get("FEDERATION_LOOKBACK_DAYS", "14"))


def _digest():
    return load_fresh("digest")            # core engine: CONFIG, send_email, VAULT_DIR


def _email_body():
    return load_fresh("email_body")        # core: robust inbound body extraction


def _state():
    return load_fresh("federation_state")


# ---- config helpers --------------------------------------------------------
def _cfg(d):
    return d.CONFIG or {}


def _peers(cfg):
    return cfg.get("peers") or []


def _peer_for(addr, cfg):
    a = (addr or "").strip().lower()
    for p in _peers(cfg):
        if (p.get("agent_email") or "").strip().lower() == a:
            return p
    return None



def _ingress_addr(cfg, d):
    return ((cfg.get("agent") or {}).get("ingress_email")
            or (cfg.get("agent") or {}).get("email") or d._agent_email())


# ---- gates -----------------------------------------------------------------
def _auth_ok(msg):
    """Aligned DKIM + SPF pass, per the receiving provider's Authentication-Results."""
    ar = " ".join(msg.get_all("Authentication-Results", []) or []).lower()
    if not ar:
        return False
    return "dkim=pass" in ar and ("spf=pass" in ar or "spf=bestguesspass" in ar)


def _token_ok(body, peer):
    """If the peer declares a token (auth.token_ref naming a *_TOKEN_SHA256 env var), the
    body must contain a token whose sha256 matches. No token declared => this gate passes
    (auth+allowlist already gate it); fails CLOSED if a ref is declared but unset."""
    ref = ((peer.get("auth") or {}).get("token_ref") or "").strip()
    if not ref:
        return True
    want = os.environ.get(ref, "").strip().lower()
    if not want:
        return False
    return any(hashlib.sha256(t.lower().encode()).hexdigest() == want
               for t in re.findall(r"[A-Za-z0-9]+", body or ""))


# ---- envelope --------------------------------------------------------------
def parse_envelope(body):
    """Extract the fenced ```alexandria-message JSON block and return its validated intent
    list (only known intent types with the required fields). Returns [] if absent/malformed
    — free text is intentionally NOT auto-applied (a peer must use the explicit envelope).

    The regex extraction is email-transport-specific and stays here; the intent-list
    validation is the shared applier's ``validate_intents`` (§5.1)."""
    m = ENVELOPE_RE.search(body or "")
    if not m:
        return []
    try:
        data = json.loads(m.group(1).strip())
    except ValueError:
        return []
    return validate_intents(data)


# ---- IMAP receive ----------------------------------------------------------
def _imap():
    host = os.environ.get("IMAP_HOST", "imap.gmail.com")
    user = os.environ.get("IMAP_USER") or os.environ["SMTP_USER"]
    pw = os.environ.get("IMAP_PASS") or os.environ["SMTP_PASS"]
    c = imaplib.IMAP4_SSL(host, ssl_context=ssl.create_default_context())
    c.login(user, pw)
    return c


def process(mode="apply"):
    """Poll the ingress mailbox; for each NEW message from an allowlisted peer that passes
    the auth (+ token) gates and carries a valid envelope, file its intents. mode='dryrun'
    reports without writing/marking. Returns a list of status lines."""
    d = _digest()
    st = _state()
    cfg = _cfg(d)
    vault = d.VAULT_DIR
    today = (d.TODAY if hasattr(d, "TODAY") else dt.date.today()).isoformat()
    results = []
    try:
        c = _imap()
    except Exception as e:
        return [f"federation: IMAP unavailable ({type(e).__name__}: {e})"]
    try:
        c.select("INBOX")
        since = (dt.date.today() - dt.timedelta(days=LOOKBACK_DAYS)).strftime("%d-%b-%Y")
        _, data = c.search(None, f'(SINCE {since})')
        for num in (data[0].split() if data and data[0] else []):
            _, md = c.fetch(num, "(RFC822)")
            if not md or not md[0]:
                continue
            msg = message_from_bytes(md[0][1])
            mid = msg.get("Message-ID", "")
            frm = parseaddr(msg.get("From", ""))[1]
            peer = _peer_for(frm, cfg)
            if not peer:
                continue                                   # not an allowlisted peer
            if mode != "dryrun" and st.is_processed(mid):
                continue
            if REQUIRE_AUTH and not _auth_ok(msg):
                results.append(f"rejected {frm}: failed DKIM/SPF")
                if mode != "dryrun":
                    st.mark_processed(mid)
                continue
            body = _body_text(msg)
            if not _token_ok(body, peer):
                results.append(f"rejected {frm}: token gate")
                if mode != "dryrun":
                    st.mark_processed(mid)
                continue
            intents = parse_envelope(body)
            if not intents:
                results.append(f"skipped {frm}: no valid alexandria-message envelope")
                if mode != "dryrun":
                    st.mark_processed(mid)
                continue
            if mode == "dryrun":
                results.append(f"would apply {len(intents)} intent(s) from {peer.get('name')}")
                continue
            applied, parked = apply_intents(vault, peer, intents, cfg, today)
            st.mark_processed(mid)
            st.append_audit({"ts": today, "from": frm, "peer": peer.get("name"),
                             "applied": applied, "parked": parked, "message_id": mid})
            results.append(f"applied {applied} parked {parked} from {peer.get('name')}")
    finally:
        try:
            c.logout()
        except Exception:
            pass
    return results


def _body_text(msg):
    # Shared core extractor: best part (text/plain → HTML→text) + charset decode. The federation
    # envelope is machine-structured, so extraction only — no reply-quote stripping.
    return _email_body().body_text(msg)


# ---- egress: push to a peer's vault ----------------------------------------
def send_to_peer(peer_id, intents):
    """Compose an alexandria-message envelope and send it to a peer's ingress address.
    `intents` is a list of intent dicts (todo/note/knowledge/event). Returns the Message-ID."""
    d = _digest()
    st = _state()
    cfg = _cfg(d)
    peer = next((p for p in _peers(cfg)
                 if (p.get("id") == peer_id or p.get("name") == peer_id)), None)
    if not peer:
        raise SystemExit(f"unknown peer: {peer_id}")
    to = peer.get("agent_email")
    envelope = "```alexandria-message\n" + json.dumps({"intents": intents}, indent=2) + "\n```"
    html = ("This is an automated Alexandria vault-to-vault message.<br><br>"
            "<pre>" + envelope.replace("<", "&lt;") + "</pre>")
    mid = d.send_email(html, subject="Alexandria message", to=to)
    try:
        st.record_sent_message(to, mid)
    except Exception:
        pass
    return mid


def main():
    import argparse
    ap = argparse.ArgumentParser(description="Inter-vault federation receiver / sender.")
    ap.add_argument("--dry-run", action="store_true", help="report inbound, no writes/marks")
    ap.add_argument("--once", action="store_true", help="process inbound once (live)")
    ap.add_argument("--send-to", help="peer id/name to push the --intents JSON to")
    ap.add_argument("--intents", help="JSON intents list for --send-to")
    args = ap.parse_args()
    if args.send_to:
        intents = json.loads(args.intents or "[]")
        print(send_to_peer(args.send_to, intents))
        return
    for line in process(mode="dryrun" if args.dry_run else "apply"):
        print(line)


if __name__ == "__main__":
    main()
