#!/usr/bin/env python3
"""felhom-ep0-copy-gc — remove a DELETED customer's namespace from DooPlex's ep0-copy (R-901, `09` §3 decision 181).

DooPlex pulls ep0's `felhom-offsite` into `ep0-copy` with `remove-vanished false`, so a namespace the customer delete
cascade destroyed on ep0 stays on DooPlex for ever. The ruling: it is removed within 30 days.

The rule, in one place (`decide`):
  * a top-level namespace present in the copy and ABSENT from ep0's own list is recorded with the day it was first seen
    absent (state file);
  * one that is absent for GRACE_DAYS (7) is deleted from the copy, groups and all;
  * one that reappears on ep0 is forgotten (a re-created customer, or a listing hiccup);
  * KEEP (`operator`, the hub database's copies) is never deleted.
With the daily timer: deletion on ep0 → seen absent within a day → deleted 7 days later, inside the 30-day line.

Fail-safe: if ep0's list cannot be read, or reads EMPTY, or more than MAX_ABSENT (2) copy namespaces are absent at
once (a partial list — a rebuilt ep0 — not deletions), nothing is recorded and nothing is deleted; at most one deletion
per run; a HOLD file (/etc/felhom/ep0-copy-gc/HOLD) stops it during any ep0 recovery. Default mode is a DRY RUN that only prints; `--apply` deletes.

Secrets: the two PBS token secrets are read from root-only files into the child's environment (PBS_PASSWORD); never
printed. Runbook: documentation/runbooks/ep0-datastore-copy.md, „Removing a deleted customer's copy".
Tests: test_ep0_copy_gc.py (fake proxmox-backup-client on PATH).
"""
import argparse
import datetime as dt
import json
import os
import subprocess
import sys

COPY_NS_DIR = os.environ.get("EP0_COPY_NS_DIR", "/mnt/5_hdd/backup/ep0-copy/ns")
STATE = os.environ.get("EP0_COPY_GC_STATE", "/var/lib/felhom-ep0-copy-gc/absent.json")
EP0_REPO = os.environ.get("EP0_REPO", "root@pam!dooplex-sync@127.0.0.1:18007:felhom-offsite")
EP0_TOKEN_FILE = os.environ.get("EP0_TOKEN_FILE", "/etc/felhom/ep0-copy-gc/ep0-reader.secret")
EP0_FINGERPRINT_FILE = os.environ.get("EP0_FINGERPRINT_FILE", "/etc/felhom/ep0-copy-gc/ep0.fingerprint")
LOCAL_REPO = os.environ.get("LOCAL_REPO", "root@pam!ep0-copy-gc@localhost:ep0-copy")
LOCAL_TOKEN_FILE = os.environ.get("LOCAL_TOKEN_FILE", "/etc/felhom/ep0-copy-gc/local-gc.secret")
GRACE_DAYS = int(os.environ.get("EP0_COPY_GC_GRACE_DAYS", "7"))
KEEP = {"operator"}
# Fail-safe guards (security review 2026-10-08): a PARTIAL ep0 list (a rebuilt or half-restored ep0, a token that sees
# less) would read as „these customers were deleted". So: more than MAX_ABSENT absent at once → ABORT, nothing recorded;
# at most MAX_DELETE deletions per run; and a HOLD file stops everything (create it during any ep0 recovery).
MAX_ABSENT = int(os.environ.get("EP0_COPY_GC_MAX_ABSENT", "2"))
MAX_DELETE = 1
HOLD_FILE = os.environ.get("EP0_COPY_GC_HOLD", "/etc/felhom/ep0-copy-gc/HOLD")


def log(msg):
    print("ep0-copy-gc: " + msg, flush=True)


def decide(copy_ns, ep0_ns, state, today, grace_days=GRACE_DAYS, keep=KEEP):
    """Pure rule. Returns (to_delete, new_state). state: {ns: 'YYYY-MM-DD' first seen absent}."""
    new_state = {}
    to_delete = []
    for ns in sorted(copy_ns):
        if ns in keep or ns in ep0_ns:
            continue
        first = state.get(ns, today.isoformat())
        new_state[ns] = first
        if (today - dt.date.fromisoformat(first)).days >= grace_days:
            to_delete.append(ns)
    return to_delete, new_state


def pbc(args, token_file, fingerprint_file=None):
    env = dict(os.environ)
    with open(token_file) as f:
        env["PBS_PASSWORD"] = f.read().strip()
    if fingerprint_file:
        with open(fingerprint_file) as f:
            env["PBS_FINGERPRINT"] = f.read().strip()
    return subprocess.run(["proxmox-backup-client"] + args, env=env, capture_output=True, text=True, timeout=300)


def ep0_namespaces():
    r = pbc(["namespace", "list", "--repository", EP0_REPO, "--output-format", "json"], EP0_TOKEN_FILE, EP0_FINGERPRINT_FILE)
    if r.returncode != 0:
        raise RuntimeError("ep0 namespace list failed (rc %d): %s" % (r.returncode, r.stderr.strip()[-200:]))
    out = set()
    for item in json.loads(r.stdout or "[]"):
        name = item.get("ns", "") if isinstance(item, dict) else str(item)
        top = name.split("/")[0]
        if top:
            out.add(top)
    return out


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("--apply", action="store_true", help="delete; without it, only print what would be deleted")
    a = ap.parse_args(argv)
    today = dt.date.today()
    if os.path.exists(HOLD_FILE):
        log("HOLD — %s exists (an ep0 recovery in progress?); nothing recorded, nothing deleted" % HOLD_FILE)
        return 0
    copy_ns = {d for d in os.listdir(COPY_NS_DIR) if os.path.isdir(os.path.join(COPY_NS_DIR, d))}
    try:
        ep0 = ep0_namespaces()
    except Exception as e:  # noqa: BLE001 — any failure means „could not tell"
        log("ABORT — %s; nothing recorded, nothing deleted" % e)
        return 2
    if not ep0:
        log("ABORT — ep0 lists NO namespace (read as „could not tell\", never as „all deleted\"); nothing changed")
        return 2
    try:
        with open(STATE) as f:
            state = json.load(f)
    except FileNotFoundError:
        state = {}
    to_delete, new_state = decide(copy_ns, ep0, state, today)
    if len(new_state) > MAX_ABSENT:
        log("ABORT — %d namespaces absent on ep0 at once (limit %d): ep0's list looks partial (a rebuild?), not like "
            "customer deletions; nothing recorded, nothing deleted. Absent: %s" % (len(new_state), MAX_ABSENT, ", ".join(sorted(new_state))))
        return 2
    if len(to_delete) > MAX_DELETE:
        log("limit: %d due, deleting %d this run (the rest stay due)" % (len(to_delete), MAX_DELETE))
        to_delete = to_delete[:MAX_DELETE]
    for ns, first in sorted(new_state.items()):
        log("absent on ep0 since %s: %s" % (first, ns))
    failed = 0
    for ns in to_delete:
        if not a.apply:
            log("DRY RUN — would delete namespace %s from ep0-copy (absent on ep0 since %s)" % (ns, new_state[ns]))
            continue
        r = pbc(["namespace", "delete", ns, "--delete-groups", "true", "--repository", LOCAL_REPO], LOCAL_TOKEN_FILE)
        if r.returncode != 0:
            failed += 1
            log("FAILED to delete namespace %s (rc %d): %s" % (ns, r.returncode, r.stderr.strip()[-200:]))
            continue
        log("DELETED namespace %s from ep0-copy (absent on ep0 since %s)" % (ns, new_state[ns]))
        new_state.pop(ns, None)
    os.makedirs(os.path.dirname(STATE), exist_ok=True)
    tmp = STATE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(new_state, f, indent=1, sort_keys=True)
    os.replace(tmp, STATE)
    log("done: %d copy namespace(s), %d on ep0, %d absent, %d to delete%s, %d failed"
        % (len(copy_ns), len(ep0), len(new_state) + (len(to_delete) if a.apply else 0), len(to_delete),
           "" if a.apply else " (dry run)", failed))
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
