#!/usr/bin/env python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.

"""
SecuBox-Deb :: sbx-centersctl — box operator CLI for delegated center grants

Thin CLI over annuaire.verbs.grant_issue/grant_revoke, annuaire.grants, and
annuaire.config_router.route_config — operates on the BOX journal + BOX key
(the box's own sovereign identity), never a center's. A center is delegated
config authority over a (scope, layer) pair by a grant the box issues here;
grant/revoke are signed journal appends (audit trail), matching the idiom
already used by sbin/annuairectl for every other verb.

Commands:
  grant <center_did> <scope> <layer>   Issue a delegated grant. Rejected
                                        (rc!=0, JSON {"error": reason} on
                                        stderr) by grants.validate_issue —
                                        see annuaire/grants.py for the
                                        rejection reasons (scope-not-
                                        delegatable, layer-local-not-
                                        delegatable, already-owned).
  revoke <grant_id>                    Withdraw a previously issued grant.
  list                                 Print the active (scope, layer) ->
                                        grant matrix as JSON.
  route                                Verify + apply every routable scope's
                                        published config via the grant
                                        matrix (annuaire.config_router.
                                        route_config); prints
                                        {"applied": [...], "proposals": [...]}.

Paths (override via env — the box key is NEVER generated by this CLI; it is
the box's sovereign identity, provisioned once elsewhere):
  ANNUAIRE_JOURNAL   default /var/lib/secubox/annuaire/journal.db
  ANNUAIRE_KEY_PATH  default /etc/secubox/secrets/annuaire/node.key
                     (32-byte raw Ed25519 private key, 64 hex chars) — the
                     SAME file `annuairectl init` writes/reads (env var name
                     and default path deliberately match annuairectl and
                     api/main.py's `_publish_self_node`/`_self_did_best_effort`).
                     One box, one sovereign identity — there is no separate
                     "centers" key. Provisioning: `annuairectl init` (image
                     firstboot / operator bootstrap), never this CLI.
  CONFIG_TARGET_DIR  default /etc/secubox               (route)
  CONFIG_LOCAL_DIR   default /etc/secubox/config-local   (route)
  ANNUAIRE_LIB       default /usr/lib/secubox/annuaire  (import root, prod)

  DRYRUN=1           on grant/revoke/route: do NOT append to the journal and
                      do NOT apply any config -- print the action that WOULD
                      have been taken as {"dryrun": true, "would": ..., ...}
                      and exit 0. Nothing is written in this mode (grant/
                      revoke never touch the journal; route never touches
                      CONFIG_TARGET_DIR); `list` afterwards is unchanged.
"""
from __future__ import annotations

import argparse
import json
import os
import sys

LIB = os.environ.get("ANNUAIRE_LIB", "/usr/lib/secubox/annuaire")
if LIB not in sys.path:
    sys.path.insert(0, LIB)

JOURNAL_PATH = os.environ.get("ANNUAIRE_JOURNAL", "/var/lib/secubox/annuaire/journal.db")
# Same env var + default as annuairectl's KEY_PATH and api/main.py's node-key
# lookups: one box, one sovereign identity (see module docstring above).
KEY_PATH = os.environ.get("ANNUAIRE_KEY_PATH", "/etc/secubox/secrets/annuaire/node.key")
CONFIG_TARGET_DIR = os.environ.get("CONFIG_TARGET_DIR", "/etc/secubox")
CONFIG_LOCAL_DIR = os.environ.get("CONFIG_LOCAL_DIR", "/etc/secubox/config-local")


def _die(msg: str, code: int = 1) -> "None":
    print(json.dumps({"error": msg}), file=sys.stderr)
    raise SystemExit(code)


def _is_dryrun() -> bool:
    """True iff DRYRUN=1 — grant/revoke/route must not write anything."""
    return os.environ.get("DRYRUN") == "1"


def _dryrun_report(would: str, **fields) -> int:
    """Print the {"dryrun": true, "would": ...} preview and return rc0.

    Used by grant/revoke/route in DRYRUN mode INSTEAD OF the real write path
    — no journal append, no apply, nothing touched on disk beyond what a
    read-only preview needs (e.g. listing the current grant matrix).
    """
    print(json.dumps({"dryrun": True, "would": would, **fields}, indent=2))
    return 0


def _load_box_priv() -> bytes:
    """Load the box's own 32-byte raw Ed25519 private key (hex-encoded on disk).

    This is the SAME node identity key `annuairectl init` generates/reads at
    ANNUAIRE_KEY_PATH — the box's one sovereign identity, provisioned once
    (image firstboot / `annuairectl init`), never minted here. A missing key
    is a hard configuration error, not something this CLI silently papers
    over by generating a fresh one.
    """
    if not os.path.exists(KEY_PATH):
        _die(
            f"no box key at {KEY_PATH} — run `annuairectl init` first (this "
            "CLI never generates one; it reuses the box's sovereign node "
            "identity)"
        )
    with open(KEY_PATH, "r", encoding="ascii") as fh:
        hexstr = fh.read().strip()
    try:
        priv = bytes.fromhex(hexstr)
    except ValueError:
        _die(f"box key at {KEY_PATH} is not valid hex")
        raise  # unreachable — _die raises SystemExit; satisfies type-checkers
    if len(priv) != 32:
        _die(f"box key must be 32 raw bytes (64 hex chars), got {len(priv)}")
        raise  # unreachable
    return priv


def _journal():
    from annuaire.log import Journal  # noqa: PLC0415
    os.makedirs(os.path.dirname(JOURNAL_PATH), exist_ok=True)
    return Journal(JOURNAL_PATH)


def _box_identity():
    from annuaire.crypto import did_from_pubkey, public_from_private  # noqa: PLC0415
    box_priv = _load_box_priv()
    box_did = did_from_pubkey(public_from_private(box_priv))
    return box_priv, box_did


# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #

def _self_did_or_none():
    """Best-effort box_did — None if the box key is absent (never dies here;
    read/preview paths must survive a missing key, see route_config's
    self_did docstring)."""
    if not os.path.exists(KEY_PATH):
        return None
    _, box_did = _box_identity()
    return box_did


def cmd_grant(args) -> int:
    from annuaire.verbs import grant_issue  # noqa: PLC0415

    if _is_dryrun():
        from annuaire import grants  # noqa: PLC0415
        j = _journal()
        reason = grants.validate_issue(
            list(j.iter_entries()), args.scope, args.layer, self_did=_self_did_or_none()
        )
        return _dryrun_report(
            "grant",
            center_did=args.center_did, scope=args.scope, layer=args.layer,
            valid=reason is None, reason=reason,
        )

    box_priv, box_did = _box_identity()
    j = _journal()
    try:
        result = grant_issue(j, box_priv, box_did, args.center_did, args.scope, args.layer)
    except ValueError as e:
        _die(str(e))
        return 1  # unreachable — _die raises SystemExit
    print(json.dumps(result, indent=2))
    return 0


def cmd_revoke(args) -> int:
    from annuaire.verbs import grant_revoke  # noqa: PLC0415

    if _is_dryrun():
        return _dryrun_report("revoke", grant_id=args.grant_id)

    box_priv, box_did = _box_identity()
    j = _journal()
    try:
        result = grant_revoke(j, box_priv, box_did, args.grant_id)
    except ValueError as e:
        _die(str(e))
        return 1  # unreachable
    print(json.dumps(result, indent=2))
    return 0


def cmd_list(args) -> int:
    from annuaire import grants  # noqa: PLC0415

    j = _journal()
    active = grants.active_grants(list(j.iter_entries()), _self_did_or_none())
    matrix = [
        {"scope": scope, "layer": layer, **payload}
        for (scope, layer), payload in sorted(active.items())
    ]
    print(json.dumps({"grants": matrix}, indent=2))
    return 0


def cmd_route(args) -> int:
    from annuaire.config_router import route_config  # noqa: PLC0415

    if _is_dryrun():
        from pathlib import Path  # noqa: PLC0415

        from annuaire import grants  # noqa: PLC0415

        j = _journal()
        grant_map = grants.active_grants(list(j.iter_entries()), _self_did_or_none())
        scopes = {scope for (scope, _layer) in grant_map.keys()}
        local_root = Path(CONFIG_LOCAL_DIR)
        if local_root.is_dir():
            scopes.update(f.stem for f in local_root.glob("*.toml"))
        return _dryrun_report("route", scopes=sorted(scopes), target_dir=CONFIG_TARGET_DIR)

    # self_did is the sovereignty filter route_config/grants.active_grants
    # apply — best-effort (interface symmetry with apply_pending's
    # self-publish loop guard): the box key may legitimately be absent on a
    # node that only ever routes centers' delegated config (see
    # route_config's docstring in annuaire/config_router.py), in which case
    # ONLY grants issued by this exact box_did are honored as delegated
    # authority; a mesh peer's self-issued grant for its own center is
    # never treated as this box's own delegation.
    self_did = _self_did_or_none()

    j = _journal()
    result = route_config(
        list(j.iter_entries()), CONFIG_TARGET_DIR, self_did, CONFIG_LOCAL_DIR
    )
    print(json.dumps(result, indent=2))
    return 0


def main(argv=None) -> int:
    p = argparse.ArgumentParser(
        prog="sbx-centersctl",
        description="Box operator CLI for delegated center config grants (grant/revoke/list/route)",
    )
    sub = p.add_subparsers(dest="cmd", required=True)

    pg = sub.add_parser("grant", help="delegate config authority over (scope, layer) to a center")
    pg.add_argument("center_did")
    pg.add_argument("scope")
    pg.add_argument("layer")
    pg.set_defaults(fn=cmd_grant)

    pr = sub.add_parser("revoke", help="withdraw a previously issued grant")
    pr.add_argument("grant_id")
    pr.set_defaults(fn=cmd_revoke)

    sub.add_parser("list", help="list the active (scope, layer) -> grant matrix").set_defaults(fn=cmd_list)
    sub.add_parser(
        "route", help="verify + apply routable config layers via the grant matrix"
    ).set_defaults(fn=cmd_route)

    args = p.parse_args(argv)
    return args.fn(args)


if __name__ == "__main__":
    raise SystemExit(main())
