"""Owner-facing federation surfaces (§6) — the pure logic behind the MCP tools
(`peers`/`peer_status`/`peer_stage`/`peer_fire`), the `push` CLI, and the digest
lines. Kept framework-free (no MCP/CLI imports) so it is unit-tested directly and
reused by every surface.

The **fire rule** lives in the tool descriptions and here structurally: `stage`
only stores a push (`staged`) and returns a manifest the owner can read; `fire`
is the separate, explicit act that arms it for delivery. Nothing here sends —
the worker deposits fired pushes on its tick.
"""
from alexandria_vault.federation import outbox, envelope, apply as fed_apply

# The staged plaintext-intents cap, derived from the envelope's wire cap (§2.3):
# the seal's base64 expands the plaintext by 4/3, and 64 KiB covers the header,
# nonce + MAC, and the transport JSON — so a push at this cap still seals under
# the 8 MiB wire cap (~5.9 MiB of intents JSON, a full 1M-token context window).
MAX_STAGED_BYTES = envelope.MAX_ENVELOPE_BYTES * 3 // 4 - 64 * 1024


def _peers(cfg):
    return [p for p in (cfg.get("peers") or []) if p.get("fingerprint")]


def _peer(cfg, handle):
    return next((p for p in _peers(cfg) if p.get("handle") == handle), None)


def _last_receipt(fp):
    for r in outbox.list_for(fp, limit=25):
        if r.get("receipt"):
            return {"id": r["id"][:8], "state": r["state"]}
    return None


def roster(cfg):
    """The peer roster: handle, name, fingerprint, linked People note, accepted
    intents/areas, last receipt, and staged/pending counts. Verification words are NOT a
    standing field (F18) — they're the pairing-ceremony check, shown on demand via
    ``verification_words`` so owners don't come to treat them as a secret to guard."""
    return [{"handle": p.get("handle"), "name": p.get("name"), "fingerprint": p["fingerprint"],
             "people_note": p.get("people_note"),
             "permissions": p.get("permissions") or {},
             "can_send": p.get("can_send", True),
             "outbox": outbox.status(p["fingerprint"]),
             "last_receipt": _last_receipt(p["fingerprint"])}
            for p in _peers(cfg)]


def verification_words(cfg, handle):
    """The three SAS words for one peer, computed ON DEMAND (F18) — a PUBLIC fingerprint
    for re-checking a peer's identity out of band, never a secret. None if the peer or our
    own key is missing."""
    p = _peer(cfg, handle)
    if p is None or not p.get("pubkey"):
        return None
    try:
        import base64
        from alexandria_vault.federation import identity
        _, our_pub = identity.try_load()          # load-only: never mints a key
        if our_pub is None:
            return None
        return identity.verification_words(our_pub, base64.b64decode(p["pubkey"]))
    except Exception:  # noqa: BLE001
        return None


def peer_detail(cfg, handle):
    """One peer's outbox items + receipt detail, or None if unknown."""
    p = _peer(cfg, handle)
    if p is None:
        return None
    fp = p["fingerprint"]
    items = [{"id": r["id"][:8], "kind": r["kind"], "state": r["state"],
              "attempts": r["attempts"], "last_error": r["last_error"],
              "receipt": r.get("receipt")}
             for r in outbox.list_for(fp, limit=50)]
    return {"handle": handle, "name": p.get("name"), "fingerprint": fp,
            "permissions": p.get("permissions") or {},
            "status": outbox.status(fp), "items": items}


def _outcome(peer, intent):
    """What will happen to one intent on the peer's side — for the manifest.

    Mirrors `apply.apply_intents` step for step: sanitize the peer-bound
    `area`/`subject` with the SAME `_safe_component`, park first on an unsafe
    value, and check permissions against the SANITIZED area — so the owner's
    preview never claims "files to X" for an intent the receiver will park."""
    t = (intent.get("type") or "").strip().lower()
    area_raw = (intent.get("area") or "").strip() or None
    area = fed_apply._safe_component(area_raw) if area_raw else None
    if area_raw and area is None:        # the receiver parks a traversal/unsafe area first
        return "will park (unsafe area)"
    if not fed_apply._permits(peer, t, area):
        return "will park for their review"
    if t == "knowledge" and intent.get("subject"):
        subj = fed_apply._safe_component(intent["subject"])
        return (f"files to Knowledge/{subj}" if subj is not None
                else "will park (unsafe subject)")
    if t == "note" and area and (intent.get("title") or "").strip():
        title = fed_apply._safe_component(intent["title"])
        return (f"files to {area}/{title} (its own note)" if title is not None
                else "will park (unsafe title)")
    return {"todo": f"files to {area} TODO" if area else "will park (no area)",
            "note": f"files to {area}" if area else "will park (no area)",
            "knowledge": "will park (no subject)",
            "event": "files to Events"}.get(t, "will park for their review")


def stage(cfg, handle, intents):
    """Validate + store a `staged` push (never fires). Returns the manifest: id,
    the verbatim intents, and each intent's projected outcome on the peer."""
    peer = _peer(cfg, handle)
    if peer is None:
        raise ValueError(f"no peer @{handle} (see the peers roster)")
    if peer.get("can_send") is False:
        raise ValueError(f"peer @{handle} is marked can_send:false")
    if not isinstance(intents, list):
        raise ValueError("intents must be a list of intent objects")
    valid = fed_apply.validate_intents({"intents": intents})
    if not valid:
        raise ValueError("no valid intents — each needs type in "
                         "todo|note|knowledge|event")
    import json
    # Measured exactly as envelope.seal serializes the plaintext (compact, UTF-8
    # unescaped) so the cap tracks the real wire size, not an inflated one.
    staged_len = len(json.dumps(valid, ensure_ascii=False,
                                separators=(",", ":")).encode("utf-8"))
    if staged_len > MAX_STAGED_BYTES:
        raise ValueError(f"intents are {staged_len} bytes — over the "
                         f"{MAX_STAGED_BYTES / (1024 * 1024):.1f} MiB stage cap; "
                         "split into smaller pushes")
    manifest = [{"intent": it, "outcome": _outcome(peer, it)} for it in valid]
    pid = outbox.stage(peer["fingerprint"], "intents", {"intents": valid})
    return {"id": pid, "peer": handle, "count": len(valid),
            "state": "staged", "manifest": manifest}


def fire(pid):
    """Arm a staged push for delivery (`staged`→`fired`). Returns the new state."""
    fired = outbox.fire(pid)
    row = outbox.get(pid)
    return {"id": pid, "fired": fired, "state": row.get("state") if row else None}


# The window before a fired push's 14-day expiry at which the digest warns.
_EXPIRING_SOON = 2 * 24 * 3600
_EXPIRY = 14 * 24 * 3600


def digest_lines(cfg, *, now=None):
    """Federation status lines for the daily digest (§6): pushes staged awaiting the
    owner's fire, delivered-and-applied, expiring within 2 days, expired, and
    rejected. Returns [] when there's nothing worth surfacing."""
    import time
    now = time.time() if now is None else now
    counts = outbox.status()
    lines = []
    if counts.get("staged"):
        lines.append(f"federation: {counts['staged']} push(es) staged — awaiting your fire")
    if counts.get("applied"):
        lines.append(f"federation: {counts['applied']} push(es) delivered and applied")
    if counts.get("deposited"):
        lines.append(f"federation: {counts['deposited']} push(es) delivered, awaiting a "
                     "receipt")
    # Only UNDELIVERED (`fired`) pushes expire (a `deposited` push is delivered and never
    # expires, §10.7) — so only they can be 'expiring soon'.
    soon = sum(1 for r in outbox.list_for(limit=1000)
               if r["state"] == "fired" and r.get("fired")
               and (now - r["fired"]) >= (_EXPIRY - _EXPIRING_SOON))
    if soon:
        lines.append(f"federation: {soon} undelivered push(es) expire within 2 days")
    if counts.get("expired"):
        lines.append(f"federation: {counts['expired']} push(es) expired undelivered")
    if counts.get("declined"):
        lines.append(f"federation: {counts['declined']} push(es) reviewed and declined by "
                     "the peer's owner")
    if counts.get("rejected"):
        lines.append(f"federation: {counts['rejected']} push(es) rejected (a peer "
                     "revoked us, or a delivery error)")
    return lines


# ---- inbox: a non-destructive peek at the relay mailbox (§6) -------------------
# The relay is a dumb drop box — it stores SEALED ciphertext and can render nothing.
# So the readable inbox is built here, brain-side: fetch the waiting blobs, open the
# ones sealed to us, and return one sanitized row per message. This is READ-ONLY —
# it never acks, deletes, marks-processed, or files, so the next real tick still
# processes everything untouched. It exists to answer "what's actually in my relay
# inbox?" — and to make plain that a receipt or a ping is protocol, not new content.

_PREVIEW_MAX = 160          # per-message content preview
_INTENT_MAX = 60            # per-intent line inside a preview


def _sanitize(text, limit):
    """A peer-supplied string reduced to ONE safe, short display line: control chars
    dropped, whitespace/newlines collapsed, wiki/backtick markup defused (a preview
    is data, never rendered as links or code), then truncated with an ellipsis."""
    s = "".join(ch if ch.isprintable() else " " for ch in str(text or ""))
    s = " ".join(s.split())
    s = (s.replace("[[", "[ ").replace("]]", " ]")       # wiki links / embeds
          .replace("](", "] (")                           # inline link/image target
          .replace("`", "'"))                             # code span / fence
    return (s[: limit - 1] + "…") if len(s) > limit else s


def _short_fp(fp):
    return (fp[:12] + "…") if fp and len(fp) > 13 else (fp or "unknown")


def _intent_line(it):
    it = it if isinstance(it, dict) else {}
    t = _sanitize((it.get("type") or "?"), 24).strip().lower() or "?"
    dest = it.get("area") or it.get("subject")
    head = f"{t}→{dest}" if dest else t
    text = it.get("text") or it.get("body") or ""
    return _sanitize(f"{head}: {text}" if text else head, _INTENT_MAX)


def _summarize(kind, body, sender):
    """(preview, summary) for one opened envelope, by kind. `preview` is the truncated
    sanitized content; `summary` is a plain sentence — including whether it's content the
    owner will review or protocol plumbing they can ignore. The opened body is
    AUTHENTICATED but its inner shape is fully sender-controlled JSON (open_envelope only
    checks the outer frame), so every field is treated as hostile: non-dict bodies,
    non-list intents, and non-dict items can't crash the peek."""
    body = body if isinstance(body, dict) else {}
    if kind == "ping":
        return "connection check", f"{sender} pinged to check the link is live — no content."
    if kind == "receipt":
        rc = body.get("receipt")
        rc = rc if isinstance(rc, dict) else {}
        st = _sanitize(str(rc.get("status") or "?"), 24)
        applied = rc.get("applied") if isinstance(rc.get("applied"), list) else []
        parked = rc.get("parked") if isinstance(rc.get("parked"), list) else []
        n = len(applied) + len(parked)
        tail = f" ({n} item[s])" if n else ""
        return (f"receipt: {st}{tail}",
                f"{sender} acknowledged a push you sent — status “{st}”. Protocol, not "
                "new content.")
    intents = body.get("intents")
    intents = [it for it in intents if isinstance(it, dict)] if isinstance(intents, list) else []
    if not intents:
        return "(empty)", f"{sender} sent an empty envelope — nothing to file."
    shown = "; ".join(_intent_line(it) for it in intents[:4])
    if len(intents) > 4:
        shown += f" (+{len(intents) - 4} more)"
    kinds = ", ".join(sorted({_sanitize((it.get("type") or "?"), 24).strip().lower()
                              for it in intents}))
    return (_sanitize(shown, _PREVIEW_MAX),
            f"{sender} wants to add {len(intents)} item(s) [{kinds}] — on your next tick each "
            "is captured to Agent/inbox/ for your review (nothing is filed until you act).")


def _inbox_row(blob, priv, peers):
    import base64
    from alexandria_vault.federation import envelope
    bid = (blob.get("id") or "")[:8]
    try:
        raw = base64.b64decode(blob.get("body_b64", ""), validate=True)
        env = envelope.parse(raw)
    except Exception:  # noqa: BLE001
        return {"id": bid, "from": None, "sender": "unknown", "at": None, "kind": None,
                "readable": False, "preview": "(unreadable envelope)",
                "summary": "A malformed message — the next tick will discard it."}
    frm = env.get("from")
    peer = peers.get(frm)
    sender = (peer or {}).get("name") or (peer or {}).get("handle") or _short_fp(frm)
    row = {"id": bid, "from": frm, "sender": sender, "at": env.get("ts"),
           "kind": env.get("kind"), "readable": False, "preview": "", "summary": ""}
    if peer is None:
        row["preview"] = "(sender not in your peer list)"
        row["summary"] = ("From a fingerprint you haven't paired with — left in place for "
                          "a later tick, never applied.")
        return row
    try:
        peer_pub = base64.b64decode(peer["pubkey"], validate=True)
        body = envelope.open_envelope(env, priv, peer_pub)
    except Exception:  # noqa: BLE001
        row["preview"] = "(can't open — not sealed to this brain)"
        row["summary"] = "Sealed to a different key; the next tick will drop it."
        return row
    row["readable"] = True
    row["preview"], row["summary"] = _summarize(env.get("kind"), body, sender)
    return row


def inbox(cfg, relay_client, *, limit=50):
    """A non-destructive peek at the relay mailbox. Returns
    ``{status, count, content, protocol, messages:[row…]}`` — ``status`` is
    ``ok``/``empty``/``no-identity``/``no-mailbox``/``unreachable``; each row carries
    ``sender``, ``at`` (ISO time), ``kind``, a sanitized ``preview``, and a plain
    ``summary``. ``content`` vs ``protocol`` split the count so a wall of receipts
    can't read as unread mail. Reads only — nothing is acked, filed, or consumed."""
    from alexandria_vault.federation import identity, receive
    from alexandria_vault.federation import state as fed_statefiles
    priv, pub = identity.try_load()                       # load-only: never mints a key
    if priv is None:
        return {"status": "no-identity", "count": 0, "content": 0, "protocol": 0,
                "messages": []}
    our_fp = identity.fingerprint(pub)
    mb = fed_statefiles.mailbox_for(receive._relay_url(cfg))
    bearer = mb.get("bearer")
    if not bearer:
        return {"status": "no-mailbox", "count": 0, "content": 0, "protocol": 0,
                "messages": []}
    try:
        status, payload = relay_client.poll(our_fp, bearer, limit=limit)
    except Exception as exc:  # noqa: BLE001 — relay down / network
        return {"status": "unreachable", "reason": str(exc)[:120], "count": 0,
                "content": 0, "protocol": 0, "messages": []}
    if status != 200 or not isinstance(payload, dict):
        return {"status": "empty", "count": 0, "content": 0, "protocol": 0, "messages": []}
    peers = receive.peers_by_fingerprint(cfg)
    blobs = payload.get("blobs")
    rows = []
    for b in (blobs if isinstance(blobs, list) else []):
        if not isinstance(b, dict):
            continue                                     # a hostile/odd relay response entry
        try:
            rows.append(_inbox_row(b, priv, peers))
        except Exception:  # noqa: BLE001 — one bad blob must never break the whole peek
            rows.append({"id": "", "from": None, "sender": "unknown", "at": None,
                         "kind": None, "readable": False, "preview": "(unreadable)",
                         "summary": "A message that couldn't be read — the next tick handles it."})
    # Count content vs protocol only for rows we actually OPENED — an unopenable blob's
    # cleartext `kind` is unauthenticated and must not inflate either tally.
    content = sum(1 for r in rows if r.get("readable") and r["kind"] == "intents")
    protocol = sum(1 for r in rows if r.get("readable") and r["kind"] in ("receipt", "ping"))
    return {"status": "ok" if rows else "empty", "count": len(rows),
            "content": content, "protocol": protocol, "messages": rows}
