#!/usr/bin/env python3
"""freehold_reminders.py — expiry reminders computed from an issuer's own PUBLIC signed record.

Runs anywhere Python 3.8+ runs. Standard library ONLY — no pip installs, no accounts, no API keys,
and no dependency on FreeholdIP: every fact this tool reports is read from the issuer's signed,
content-addressed public record (IPFS/IPNS) and re-verified locally, signature by signature.
Delete FreeholdIP and this tool still works against any IPFS gateway.

What it does
    1. Resolves the issuer's signed IDENTITY document (their public trust root).
    2. Follows the identity's `issued_list` pointer to the issuer's SIGNED issued-index —
       {credential_id, issued_at, expires_at, status} per credential; nothing personal.
    3. Verifies both signatures locally (pure-Python secp256k1 — verification only, no secrets),
       and confirms the index is signed by the SAME key the identity declares.
    4. Optionally confirms the issuer's DNS domain vouch (TXT record, fetched over DNS-over-HTTPS).
    5. Computes what needs attention — expired, or expiring within --lead days — and reports it.

Usage
    python3 freehold_reminders.py --issuer WOOKWATER                 # bootstrap via freeholdip.com
    python3 freehold_reminders.py --identity /ipns/k51...            # fully independent bootstrap
    ... --pin 0373a7c4...       pin the issuer's known signing key (refuse anything else)
    ... --lead 60               days before expiry to start warning (default 60)
    ... --ics renewals.ics      also write an iCalendar file — import/subscribe in any calendar app
    ... --smtp smtp.json        also email the digest via YOUR OWN smtp ({host,port,user,pass,from,to})
    ... --gateway URL           add/override IPFS gateways (repeatable; tried in order)
    ... --json                  machine-readable output

The bootstrap hint (--issuer via freeholdip.com) is a CONVENIENCE, not a trust source: whatever
returns it, the identity must self-verify, the index must verify against the identity's key, and
--pin (recommended) rejects a swapped identity outright. Availability aside, this tool trusts only
signatures.

Note for fiduciary/law-firm issuers: your issuance list is confidential and is deliberately NOT
published as a public index. Use your console's Manage view (or its authenticated API) instead.
"""
import argparse
import json
import smtplib
import sys
import time
import urllib.parse
import urllib.request
from email.mime.text import MIMEText

DEFAULT_GATEWAYS = ["https://ipfs.io", "https://dweb.link"]
HINT_BASE = "https://freeholdip.com"          # bootstrap convenience only — see docstring
DOH = "https://cloudflare-dns.com/dns-query"  # DNS-over-HTTPS for the domain-vouch TXT check

# ---- secp256k1 signature VERIFICATION, pure stdlib -----------------------------------------------
# Verification only (public keys, public data — no secrets, so no side-channel surface). The scheme
# matches the issuing platform: sha256 over canonical JSON (sorted keys, compact separators, raw
# UTF-8) minus the signature field; 64-byte r||s signature; compressed 33-byte public key.
_P = 2**256 - 2**32 - 977
_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
_GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
_GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8


def _pt_add(p, q):
    if p is None:
        return q
    if q is None:
        return p
    (x1, y1), (x2, y2) = p, q
    if x1 == x2 and (y1 + y2) % _P == 0:
        return None
    if p == q:
        lam = (3 * x1 * x1) * pow(2 * y1, _P - 2, _P) % _P
    else:
        lam = (y2 - y1) * pow(x2 - x1, _P - 2, _P) % _P
    x3 = (lam * lam - x1 - x2) % _P
    return (x3, (lam * (x1 - x3) - y1) % _P)


def _pt_mul(k, p):
    r = None
    while k:
        if k & 1:
            r = _pt_add(r, p)
        p = _pt_add(p, p)
        k >>= 1
    return r


def _decompress(cpub):
    x = int.from_bytes(cpub[1:], "big")
    y = pow((pow(x, 3, _P) + 7) % _P, (_P + 1) // 4, _P)
    if (y & 1) != (cpub[0] & 1):
        y = _P - y
    return (x, y)


def _canonical(obj, exclude):
    c = {k: v for k, v in obj.items() if k != exclude}
    return json.dumps(c, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def verify_signed(obj, sig_field):
    """True iff obj[sig_field] is a valid signature over the rest of obj by obj['signing_pubkey']."""
    import hashlib
    try:
        pub = _decompress(bytes.fromhex(obj["signing_pubkey"]))
        sig = bytes.fromhex(obj[sig_field])
        r, s = int.from_bytes(sig[:32], "big"), int.from_bytes(sig[32:], "big")
        if not (1 <= r < _N and 1 <= s < _N):
            return False
        z = int.from_bytes(hashlib.sha256(_canonical(obj, sig_field)).digest(), "big")
        w = pow(s, _N - 2, _N)
        pt = _pt_add(_pt_mul(z * w % _N, (_GX, _GY)), _pt_mul(r * w % _N, pub))
        return pt is not None and pt[0] % _N == r
    except Exception:
        return False


# ---- fetching (gateways tried in order; content is signed, so the gateway is untrusted) ----------
def _http(url, timeout=30, headers=None):
    req = urllib.request.Request(url, headers=headers or {"User-Agent": "freehold-reminders"})
    with urllib.request.urlopen(req, timeout=timeout) as f:
        return f.read()


def fetch_ref(ref, gateways):
    """Fetch an /ipns/... or /ipfs/... reference from the first gateway that answers."""
    ref = "/" + ref.strip("/")
    last = None
    for gw in gateways:
        try:
            return json.loads(_http(gw.rstrip("/") + ref, timeout=45))
        except Exception as e:      # noqa: BLE001 — try the next gateway, keep the last error
            last = e
    raise SystemExit(f"could not fetch {ref} from any gateway ({last}) — try --gateway")


def dns_vouch(domain, pubkey):
    """Does the issuer's own domain publish a TXT vouching for this signing key? (DoH; no local
    resolver dependency.) Returns True/False, or None when the lookup itself failed."""
    try:
        raw = _http(f"{DOH}?name={urllib.parse.quote(domain)}&type=TXT",
                    headers={"accept": "application/dns-json", "User-Agent": "freehold-reminders"})
        answers = json.loads(raw).get("Answer") or []
        want = f"freeholdip-issuer-key={pubkey}"
        return any(want in (a.get("data") or "").replace('"', "") for a in answers)
    except Exception:
        return None


# ---- lifecycle (same rule the platform applies: expired, or expiring within the lead window) -----
def days_until(ymd, today):
    try:
        import calendar
        t = calendar.timegm(time.strptime(str(ymd)[:10], "%Y-%m-%d"))
        return int((t - calendar.timegm(time.strptime(today, "%Y-%m-%d"))) // 86400)
    except Exception:
        return None


def attention(entries, lead, today):
    out = []
    for e in entries:
        if e.get("status", "valid") != "valid":
            continue                      # revoked handles itself; nothing to renew
        d = days_until(e.get("expires_at"), today)
        if d is None:
            continue                      # perpetual
        if d < 0:
            out.append({**e, "state": "expired", "days": d})
        elif d <= lead:
            out.append({**e, "state": "expiring", "days": d})
    out.sort(key=lambda e: e["days"])
    return out


# ---- outputs -------------------------------------------------------------------------------------
def to_ics(items, issuer_name, lead):
    """One all-day event per credential on its expiry date, with a reminder --lead days ahead.
    Import once or re-generate on a schedule; UIDs are stable so re-imports update, not duplicate."""
    L = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//freehold-reminders//EN",
         "CALSCALE:GREGORIAN", "METHOD:PUBLISH"]
    stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
    for e in items:
        ymd = str(e["expires_at"])[:10].replace("-", "")
        L += ["BEGIN:VEVENT",
              f"UID:{e['credential_id']}@freehold-reminders",
              f"DTSTAMP:{stamp}",
              f"DTSTART;VALUE=DATE:{ymd}",
              f"SUMMARY:{issuer_name}: {e['credential_id']} expires",
              "BEGIN:VALARM", "ACTION:DISPLAY",
              f"DESCRIPTION:Renew {e['credential_id']}", f"TRIGGER:-P{int(lead)}D", "END:VALARM",
              "END:VEVENT"]
    L.append("END:VCALENDAR")
    return "\r\n".join(L) + "\r\n"


def digest_text(items, issuer_name, today):
    if not items:
        return f"{issuer_name} — nothing needs attention (checked {today})."
    lines = [f"{issuer_name} — {len(items)} credential{'s' if len(items) != 1 else ''} need attention "
             f"(checked {today}):", ""]
    for e in items:
        when = (f"expired {-e['days']} day{'s' if e['days'] != -1 else ''} ago" if e["state"] == "expired"
                else f"expires in {e['days']} day{'s' if e['days'] != 1 else ''}")
        lines.append(f"  - {e['credential_id']}  {when}  ({e['expires_at']})")
    lines += ["", "Computed locally from the issuer's signed public record."]
    return "\n".join(lines)


def send_smtp(cfg, subject, body):
    msg = MIMEText(body)
    msg["Subject"], msg["From"], msg["To"] = subject, cfg["from"], cfg["to"]
    s = smtplib.SMTP(cfg["host"], int(cfg.get("port", 587)), timeout=15)
    if cfg.get("starttls", True):
        s.starttls()
    if cfg.get("user"):
        s.login(cfg["user"], cfg.get("pass", ""))
    s.sendmail(cfg["from"], [cfg["to"]], msg.as_string())
    s.quit()


# ---- main ----------------------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser(description="Expiry reminders from an issuer's signed public record.")
    src = ap.add_mutually_exclusive_group(required=True)
    src.add_argument("--issuer", help="issuer id (bootstrap hint via freeholdip.com; then verified)")
    src.add_argument("--identity", help="the issuer identity's /ipns/... reference (independent bootstrap)")
    ap.add_argument("--pin", help="issuer signing pubkey (hex) you already trust; refuse any other")
    ap.add_argument("--lead", type=int, default=60, help="warn this many days before expiry (default 60)")
    ap.add_argument("--gateway", action="append", default=[], help="IPFS gateway base URL (repeatable)")
    ap.add_argument("--ics", metavar="FILE", help="also write an iCalendar file of upcoming expiries")
    ap.add_argument("--smtp", metavar="FILE", help="also email the digest via your own SMTP (JSON config)")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    a = ap.parse_args()
    gateways = a.gateway or DEFAULT_GATEWAYS

    # 1. the issuer's signed identity (trust root)
    if a.identity:
        idoc = fetch_ref(a.identity, gateways)
    else:
        hint = json.loads(_http(f"{HINT_BASE}/api/issuer-identity?id={urllib.parse.quote(a.issuer)}"))
        if not hint.get("found"):
            raise SystemExit(f"issuer {a.issuer!r} not found via {HINT_BASE}")
        idoc = json.loads(hint["payload"])
    if not verify_signed(idoc, "self_signature"):
        raise SystemExit("REFUSING: issuer identity's self-signature does not verify")
    pub = idoc["signing_pubkey"]
    if a.pin and a.pin.lower() != pub.lower():
        raise SystemExit(f"REFUSING: identity key {pub[:16]}… does not match --pin {a.pin[:16]}…")

    # 2. the domain vouch (reported, and enforced when you pinned nothing)
    vouch = dns_vouch(idoc["domain"], pub) if idoc.get("domain") else None
    if not a.pin and vouch is False:
        raise SystemExit("REFUSING: issuer's domain does not vouch for this key (and no --pin given)")

    # 3. the signed issued-index
    ptr = idoc.get("issued_list")
    if not ptr:
        raise SystemExit(f"{idoc.get('name') or idoc.get('issuer_id')} publishes no issued-index "
                         "(fiduciary/confidential issuers don't — use your console instead)")
    ilist = fetch_ref(ptr, gateways)
    if not verify_signed(ilist, "issuer_signature"):
        raise SystemExit("REFUSING: issued-index signature does not verify")
    if ilist.get("signing_pubkey") != pub:
        raise SystemExit("REFUSING: issued-index is signed by a different key than the identity declares")
    if (ilist.get("issuer_id") or "").upper() != (idoc.get("issuer_id") or "").upper():
        raise SystemExit("REFUSING: issued-index names a different issuer than the identity")

    # 4. compute + report
    today = time.strftime("%Y-%m-%d", time.gmtime())
    items = attention(ilist.get("issued") or [], a.lead, today)
    name = idoc.get("name") or idoc.get("issuer_id")
    if a.json:
        print(json.dumps({"issuer": idoc.get("issuer_id"), "name": name, "checked": today,
                          "sequence": ilist.get("sequence"), "updated_at": ilist.get("updated_at"),
                          "signature_ok": True, "domain_vouch": vouch,
                          "total": len(ilist.get("issued") or []), "attention": items}, indent=2))
    else:
        trust = ("key pinned" if a.pin else
                 "domain-vouched" if vouch else
                 "domain lookup failed — signatures still verified" if vouch is None else "")
        print(f"[{name}] index seq {ilist.get('sequence')} · {len(ilist.get('issued') or [])} issued "
              f"· signatures OK{' · ' + trust if trust else ''}\n")
        print(digest_text(items, name, today))
    if a.ics:
        upcoming = [e for e in items if e["state"] == "expiring"]
        with open(a.ics, "w") as f:
            f.write(to_ics(upcoming, name, a.lead))
        print(f"\nwrote {a.ics} ({len(upcoming)} upcoming expir{'y' if len(upcoming) == 1 else 'ies'})")
    if a.smtp:
        cfg = json.load(open(a.smtp))
        send_smtp(cfg, f"{name} — {len(items)} credential{'s' if len(items) != 1 else ''} need attention",
                  digest_text(items, name, today))
        print(f"emailed digest to {cfg['to']}")
    sys.exit(2 if any(e["state"] == "expired" for e in items) else 0)


if __name__ == "__main__":
    main()
