"""State-home JSON files for federation transport (docs/V4-FEDERATION.md §3.2).

Two small stores under ``$ALEXANDRIA_STATE_DIR/federation/`` (never in any repo):

- ``mailbox.json``       — this brain's own relay mailbox: ``{relay_url, bearer,
                            registered}``. The bearer is the capability to poll/ack
                            our own mailbox; it lives here and nowhere else.
- ``peer-secrets.json``  — per peer (keyed by fingerprint): ``deposit_token`` (the
                            token THEY minted and handed us at pairing — we present
                            it when depositing into their mailbox) and
                            ``minted_token_sha256`` (the sha of the token WE minted
                            for them and registered with our own mailbox, so we can
                            revoke it). Mode 0600 — it holds bearer-equivalent tokens.

Reads degrade to empty on a missing/corrupt file (never raise into a caller); writes
are 0600 from creation. Paths are resolved lazily via :func:`identity.federation_home`
so tests repoint ``ALEXANDRIA_STATE_DIR`` per brain.
"""
import json
import os

from alexandria_vault.federation import identity

MAILBOX_FILE = "mailbox.json"
PEER_SECRETS_FILE = "peer-secrets.json"
PENDING_FILE = "pending-invites.json"
# ``birth.json`` — per relay_url, whether (and when) this brain announced its birth
# (docs/RELAY-CENSUS.md §4.1): ``{fingerprint, engine, channel, status, attempted_at,
# announced_at, error}`` — ``engine`` is the version the relay last ACCEPTED (never a
# failed attempt's), ``channel`` the last one offered. Not a secret, but it lives with
# the rest so a repointed state dir carries it; it is what stops a tick re-announcing
# every five minutes, and what `alexandria doctor` reads.
BIRTH_FILE = "birth.json"
# ``poll-cursor.json`` — per relay_url, where the receive walk stopped when it ran out
# of pages behind deferred blobs (RELAY-REVIEW §10 #3): ``{cursor, used}``. A tick that
# finds an unused cursor resumes from it and marks it used; the next tick starts from the
# head again, so deferred blobs that became processable are still revisited.
POLL_CURSOR_FILE = "poll-cursor.json"


def _path(name, create=False):
    return identity.federation_home(create=create) / name


def _load(name):
    try:
        return json.loads(_path(name).read_text(encoding="utf-8"))
    except Exception:
        return {}


def _save_0600(name, data):
    path = _path(name, create=True)
    # Write via an exclusive temp + replace so the file is 0600 from creation and a
    # crash can't leave a torn secrets file. The temp is unlinked on any error so a
    # mid-write failure leaves no litter (matching identity._write_secret_atomically).
    import tempfile
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix="." + name + ".")
    try:
        os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
        os.fsync(fd)
        os.close(fd)
        fd = -1
        os.chmod(tmp, 0o600)
        os.replace(tmp, str(path))
    except BaseException:
        if fd != -1:
            os.close(fd)
        try:
            os.unlink(tmp)
        except FileNotFoundError:
            pass
        raise


# ---- our mailbox(es) --------------------------------------------------------
# Keyed by relay_url so switching relays keeps each relay's bearer (a bearer is
# minted once and can't be recovered — overwriting a single slot on a relay_url
# change would permanently orphan the old mailbox).
def mailbox_all():
    d = _load(MAILBOX_FILE)
    return d if isinstance(d, dict) else {}


def mailbox_for(relay_url):
    e = mailbox_all().get(relay_url)
    return e if isinstance(e, dict) else {}


def mailbox_save(relay_url, bearer, registered, fingerprint=None):
    d = mailbox_all()
    d[relay_url] = {"bearer": bearer, "registered": registered, "fingerprint": fingerprint}
    _save_0600(MAILBOX_FILE, d)


def poll_cursor_get(relay_url):
    d = _load(POLL_CURSOR_FILE)
    e = d.get(relay_url) if isinstance(d, dict) else None
    return e if isinstance(e, dict) else {}


def poll_cursor_set(relay_url, cursor, *, used=False):
    """Store (or, with ``cursor=None``, clear) the resume point for one relay."""
    d = _load(POLL_CURSOR_FILE)
    d = d if isinstance(d, dict) else {}
    if cursor is None:
        if relay_url not in d:
            return
        del d[relay_url]
    else:
        d[relay_url] = {"cursor": cursor, "used": bool(used)}
    _save_0600(POLL_CURSOR_FILE, d)


def mailbox_forget(relay_url):
    """Drop the cached bearer for one relay — ONLY when the relay has told us it no
    longer knows the mailbox (a 401 after its idle GC), so the next
    ``ensure_mailbox`` registers afresh. A bearer is minted once, so this is never
    called on a hunch."""
    d = mailbox_all()
    if relay_url in d:
        del d[relay_url]
        _save_0600(MAILBOX_FILE, d)
        return True
    return False


# ---- birth announcements (keyed by relay_url, like mailboxes) ---------------
def birth_all():
    d = _load(BIRTH_FILE)
    return d if isinstance(d, dict) else {}


def birth_for(relay_url):
    e = birth_all().get(relay_url)
    return e if isinstance(e, dict) else {}


def birth_save(relay_url, entry):
    d = birth_all()
    d[relay_url] = dict(entry)
    _save_0600(BIRTH_FILE, d)


# ---- per-peer secrets -------------------------------------------------------
def peer_secrets_load():
    d = _load(PEER_SECRETS_FILE)
    return d if isinstance(d, dict) else {}


def peer_secret_get(fingerprint):
    return peer_secrets_load().get(fingerprint)


def peer_secret_set(fingerprint, *, deposit_token, minted_token_sha256):
    d = peer_secrets_load()
    d[fingerprint] = {"deposit_token": deposit_token,
                      "minted_token_sha256": minted_token_sha256}
    _save_0600(PEER_SECRETS_FILE, d)


def peer_secret_remove(fingerprint):
    d = peer_secrets_load()
    if fingerprint in d:
        del d[fingerprint]
        _save_0600(PEER_SECRETS_FILE, d)
        return True
    return False


# ---- pending invites (an offer deposited, awaiting an answer) ----------------
# Holds the freshly-minted deposit token (a secret) between `peer invite` and the answer
# arriving, so an invite can be resumed later (poll_invite picks up the answer). 0600.
def pending_save(code, data):
    d = _load(PENDING_FILE)
    if not isinstance(d, dict):
        d = {}
    d[code] = data
    _save_0600(PENDING_FILE, d)


def pending_all():
    d = _load(PENDING_FILE)
    return d if isinstance(d, dict) else {}


def pending_clear(code):
    d = pending_all()
    if code in d:
        del d[code]
        _save_0600(PENDING_FILE, d)


# An offer's rendezvous dies in 10 min; keep the local pending an hour, then reclaim
# it (and revoke the token it minted — see pairing.reap_pending) so an abandoned
# invite doesn't leak a secret token forever or pin the relay mailbox.
PENDING_TTL = 3600


def pending_gc(now):
    """Drop pending invites older than PENDING_TTL; return the dropped entries (so the
    caller can revoke their deposit tokens)."""
    d = pending_all()
    keep, expired = {}, []
    for code, v in d.items():
        if isinstance(v, dict) and (now - float(v.get("created") or now)) > PENDING_TTL:
            expired.append(v)
        else:
            keep[code] = v
    if expired:
        _save_0600(PENDING_FILE, keep)
    return expired


# ---- pending handshakes (an answer arrived / a code was redeemed; awaiting the
# owner's verification-word confirm) ------------------------------------------
# The resumable, non-blocking bridge between "show the words" and confirm/abort:
# a handshake carries the deposit tokens + bearer (secrets), so 0600, and is keyed
# by the peer's local handle so a later `confirm <handle>` / `abort <handle>` — from
# the CLI or an MCP tool, in a separate process — can finalize it. Same TTL as a
# pending invite; reaped tokens are revoked by pairing.reap_pending.
READY_FILE = "pairing-ready.json"


def ready_save(handle, data):
    d = _load(READY_FILE)
    if not isinstance(d, dict):
        d = {}
    d[handle] = data
    _save_0600(READY_FILE, d)


def ready_all():
    d = _load(READY_FILE)
    return d if isinstance(d, dict) else {}


def ready_get(handle):
    v = ready_all().get(handle)
    return v if isinstance(v, dict) else None


def ready_clear(handle):
    d = ready_all()
    if handle in d:
        del d[handle]
        _save_0600(READY_FILE, d)


def ready_gc(now):
    """Drop pending handshakes older than PENDING_TTL; return the dropped entries (so
    the caller can revoke the deposit tokens they exposed)."""
    d = ready_all()
    keep, expired = {}, []
    for handle, v in d.items():
        if isinstance(v, dict) and (now - float(v.get("created") or now)) > PENDING_TTL:
            expired.append(v)
        else:
            keep[handle] = v
    if expired:
        _save_0600(READY_FILE, keep)
    return expired
