#!/usr/bin/env python3
# substrate-bypass: mesh-bridge primitive (audience-class claim) — operator-authorised 2026-05-28
"""arq-audience-claim — emit an `audience_class_claim` substrate act per
`arq://doc/principle/audience-class-is-a-claim-v1`.

This is the **convention layer** on top of `claim-on-address-v1`. The
mesh-daemon's claim store already accepts polymorphic `target_class`
values (any string) — this CLI:

  1. Registers a claim on `arq://subdomain/<name>` with
     `target_class=audience-mode` via the existing `twin claim register`
     verb (talks to mesh-daemon's IPC socket).
  2. Emits the canonical substrate act
     `arq://act/audience_class_claim/<id>` carrying
     `{target_address, target_class, holder, audience_class, policy, ttl_s}`.
  3. On `release`, drops the mesh-daemon claim **and** emits
     `arq://act/audience_class_release/<id>` so the lineage is closed
     on substrate.

v0 verbs:
  arq-audience-claim enter   --subdomain <name> --audience-class <class> \\
                             [--policy exclusive|shared] [--ttl-s N|session-life]
  arq-audience-claim release --subdomain <name>
  arq-audience-claim show    --subdomain <name>
  arq-audience-claim list    [--audience-class <class>]

Composes with `claim-on-address-v1` — no Rust changes needed. The
mesh-daemon's existing polymorphic claim store handles `audience-mode`
target_class with no schema change (verified 2026-05-28 against
ara-protocol/bins/twin/src/claim_ipc.rs).
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
import time
from datetime import UTC, datetime
from pathlib import Path

BRIDGE_DIR = Path(__file__).parent
sys.path.insert(0, str(BRIDGE_DIR))

# Reuse the substrate-emission + audit envelope from the shared base.
from _arq_provider_base import (  # noqa: E402
    call_with_audit,
    emit_act,
    handle_meta_flags,
    print_json,
)

PROVIDER = "audience-claim"
PRINCIPLE = "arq://doc/principle/audience-class-is-a-claim-v1"
COMPOSES_WITH = "arq://doc/principle/claim-on-address-v1"

# Open set — the principle's `audience_class` field is open to extension
# (new modes via amendment) but not contraction. We don't enforce here;
# the substrate-side adjudicator + governance verdict do that.
DEFAULT_AUDIENCE_CLASSES = (
    "personal",
    "work",
    "gig",
    "enterprise",
    "regulator",
    "developer",
)

REQUIRED_SCOPES: dict[str, list[str]] = {
    # mesh-daemon IPC socket access (file-class auth on ~/.arqera/mesh-daemon.sock)
    "enter":   ["mesh-daemon.ipc.claim"],
    "release": ["mesh-daemon.ipc.release"],
    "show":    ["mesh-daemon.ipc.query"],
    "list":    ["mesh-daemon.ipc.list_claims"],
}


def _twin_claim(args: list[str]) -> tuple[int, str, str]:
    """Invoke `twin claim …` with the supplied subcommand arguments."""
    cmd = ["twin", "--use-keychain", "claim"] + args
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False)
        return r.returncode, r.stdout, r.stderr
    except FileNotFoundError:
        return 127, "", "twin CLI not on PATH"
    except subprocess.TimeoutExpired:
        return 124, "", "twin CLI timeout"


def _peer_identity() -> str:
    """Return the calling actor's identity address. Best-effort:
    falls back to `arq://body/identity/<peer-fp>` when no separate
    identity address is configured (the twin peer IS the identity
    for solo-actor cases per identity-is-singular-across-subdomains-v1).
    """
    explicit = os.environ.get("ARQERA_ACTOR_IDENTITY")
    if explicit:
        return explicit
    try:
        r = subprocess.run(
            ["twin", "--use-keychain", "status"],
            capture_output=True, text=True, check=False, timeout=5,
        )
        if r.returncode == 0:
            for line in r.stdout.splitlines():
                line = line.strip()
                if line.startswith("address") and ":" in line:
                    addr = line.split(":", 1)[1].strip()
                    if addr.startswith("arq://body/peer/"):
                        fp = addr.rsplit("/", 1)[-1]
                        return f"arq://body/identity/{fp}"
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        pass
    return "arq://body/identity/unknown"


def _subdomain_address(name: str) -> str:
    name = name.strip().lstrip("/")
    if name.startswith("arq://"):
        return name
    return f"arq://subdomain/{name}"


def _emit_claim_act(
    subdomain_addr: str,
    holder: str,
    audience_class: str,
    policy: str,
    ttl_s: int | str,
    decision: str,
) -> str:
    """Emit `arq://act/audience_class_claim/<id>` with the canonical shape
    per the principle. Returns the act reference.
    """
    ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
    # Stable-ish ref: <decision>-<audience_class>-<subdomain-suffix>-<ts>
    suffix = subdomain_addr.rsplit("/", 1)[-1]
    ref = f"{decision}-{audience_class}-{suffix}-{ts}".replace(" ", "-")
    payload = {
        "target_address": subdomain_addr,
        "target_class":   "audience-mode",
        "holder":         holder,
        "audience_class": audience_class,
        "policy":         policy,
        "ttl_s":          ttl_s,
        "decision":       decision,
        "principle":      PRINCIPLE,
        "composes_with":  COMPOSES_WITH,
    }
    emit_act("act", "audience_class_claim", ref, payload,
             provider=PROVIDER, verbs=[decision])
    return f"arq://act/audience_class_claim/{ref}"


def _emit_release_act(subdomain_addr: str, holder: str, audience_class: str) -> str:
    ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
    suffix = subdomain_addr.rsplit("/", 1)[-1]
    ref = f"release-{audience_class}-{suffix}-{ts}".replace(" ", "-")
    payload = {
        "target_address": subdomain_addr,
        "target_class":   "audience-mode",
        "holder":         holder,
        "audience_class": audience_class,
        "decision":       "released",
        "principle":      PRINCIPLE,
    }
    emit_act("act", "audience_class_release", ref, payload,
             provider=PROVIDER, verbs=["release"])
    return f"arq://act/audience_class_release/{ref}"


def _cmd_enter(args) -> int:
    subdomain_addr = _subdomain_address(args.subdomain)
    holder = args.holder or _peer_identity()
    audience_class = args.audience_class
    policy = args.policy
    ttl_s = args.ttl_s

    # Translate "session-life" sentinel into the daemon's max TTL (300s).
    # session-life is a *semantic* TTL — refreshed by the session-keepalive
    # loop. Daemon caps at DAEMON_CLAIM_TTL_SECS (300) anyway.
    if isinstance(ttl_s, str) and ttl_s == "session-life":
        ttl_for_daemon = 300
        ttl_for_act: int | str = "session-life"
    else:
        ttl_for_daemon = int(ttl_s)
        ttl_for_act = ttl_for_daemon

    twin_args = [
        "register",
        "--target", subdomain_addr,
        "--policy", policy,
        "--tool",   f"audience-claim:{audience_class}",
        "--ttl-s",  str(ttl_for_daemon),
    ]
    code, stdout, stderr = _twin_claim(twin_args)

    decision: str
    daemon_response: dict | None = None
    if code == 0 and stdout.strip():
        try:
            daemon_response = json.loads(stdout)
        except json.JSONDecodeError:
            daemon_response = {"raw": stdout.strip()[:200]}
        decision = (daemon_response or {}).get("decision", "claimed_fresh") if daemon_response else "claimed_fresh"
    else:
        # mesh-daemon offline? Still emit the substrate act (queue-fallback
        # path inside emit_act keeps the lineage). Mark it as offline-claim.
        decision = "offline_attempt"
        daemon_response = {
            "decision": "offline_attempt",
            "stderr": (stderr or "")[:300],
            "exit_code": code,
        }

    # Conflicting exclusive claim ⇒ Denied. Reflect non-zero exit + emit
    # the denied act so the substrate has the rejection on record.
    if decision == "denied":
        act_ref = _emit_claim_act(
            subdomain_addr, holder, audience_class, policy, ttl_for_act, "denied"
        )
        print_json({
            "decision":     "denied",
            "principle":    PRINCIPLE,
            "subdomain":    subdomain_addr,
            "holder":       holder,
            "audience_class": audience_class,
            "policy":       policy,
            "act_ref":      act_ref,
            "daemon":       daemon_response,
        })
        return 1

    act_ref = _emit_claim_act(
        subdomain_addr, holder, audience_class, policy, ttl_for_act, decision
    )
    print_json({
        "decision":       decision,
        "principle":      PRINCIPLE,
        "subdomain":      subdomain_addr,
        "holder":         holder,
        "audience_class": audience_class,
        "policy":         policy,
        "ttl_s":          ttl_for_act,
        "act_ref":        act_ref,
        "daemon":         daemon_response,
    })
    return 0


def _cmd_release(args) -> int:
    subdomain_addr = _subdomain_address(args.subdomain)
    holder = args.holder or _peer_identity()
    audience_class = args.audience_class or "unknown"

    code, stdout, stderr = _twin_claim(["release", "--target", subdomain_addr])
    daemon_response: dict | None
    if code == 0 and stdout.strip():
        try:
            daemon_response = json.loads(stdout)
        except json.JSONDecodeError:
            daemon_response = {"raw": stdout.strip()[:200]}
    else:
        daemon_response = {"exit_code": code, "stderr": (stderr or "")[:300]}

    act_ref = _emit_release_act(subdomain_addr, holder, audience_class)
    print_json({
        "decision":     "released",
        "principle":    PRINCIPLE,
        "subdomain":    subdomain_addr,
        "holder":       holder,
        "audience_class": audience_class,
        "act_ref":      act_ref,
        "daemon":       daemon_response,
    })
    return 0 if code == 0 else 1


def _cmd_show(args) -> int:
    subdomain_addr = _subdomain_address(args.subdomain)
    code, stdout, stderr = _twin_claim(["show", "--target", subdomain_addr])
    if code != 0:
        sys.stderr.write(f"twin claim show failed (exit {code}): {stderr[:300]}\n")
        return 1
    try:
        return print_json(json.loads(stdout))
    except json.JSONDecodeError:
        sys.stderr.write(f"non-JSON from twin claim show: {stdout[:200]}\n")
        return 1


def _cmd_list(args) -> int:
    twin_args = ["list", "--target-class", "audience-mode"]
    code, stdout, stderr = _twin_claim(twin_args)
    if code != 0:
        sys.stderr.write(f"twin claim list failed (exit {code}): {stderr[:300]}\n")
        return 1
    try:
        data = json.loads(stdout)
    except json.JSONDecodeError:
        sys.stderr.write(f"non-JSON from twin claim list: {stdout[:200]}\n")
        return 1
    if args.audience_class:
        # Daemon returns ClaimList with claims[].holders[] cli-ids. The
        # audience_class itself is carried in the *substrate act* (which
        # is the canonical record), so a precise filter requires consulting
        # the substrate `arq://act/audience_class_claim/...` ledger. The
        # daemon view is a fast-path; we surface both.
        data["_note"] = (
            "audience_class filter at daemon-view is coarse; the canonical "
            "filter uses substrate index: "
            "`twin index --type audience_class_claim --audience-class <c>`."
        )
    return print_json(data)


def main() -> int:
    handle_meta_flags(PROVIDER, REQUIRED_SCOPES)
    p = argparse.ArgumentParser(prog="arq-audience-claim",
                                description=__doc__.splitlines()[0])
    sub = p.add_subparsers(dest="cmd", required=True)

    s_e = sub.add_parser("enter", help="Claim an audience-mode on a subdomain.")
    s_e.add_argument("--subdomain", required=True,
                     help="Subdomain name or arq://subdomain/<name> address.")
    s_e.add_argument("--audience-class", required=True,
                     help="One of: " + ", ".join(DEFAULT_AUDIENCE_CLASSES) + " (open set).")
    s_e.add_argument("--policy", default="exclusive",
                     choices=("exclusive", "shared"))
    s_e.add_argument("--ttl-s", default="session-life",
                     help="TTL in seconds or 'session-life' (default).")
    s_e.add_argument("--holder", default=None,
                     help="Override holder identity (defaults to twin peer identity).")
    s_e.set_defaults(func=_cmd_enter, verb="enter")

    s_r = sub.add_parser("release", help="Release an audience-mode claim.")
    s_r.add_argument("--subdomain", required=True)
    s_r.add_argument("--audience-class", default=None)
    s_r.add_argument("--holder", default=None)
    s_r.set_defaults(func=_cmd_release, verb="release")

    s_s = sub.add_parser("show", help="Show current claim state for a subdomain.")
    s_s.add_argument("--subdomain", required=True)
    s_s.set_defaults(func=_cmd_show, verb="show")

    s_l = sub.add_parser("list", help="List all audience-mode claims.")
    s_l.add_argument("--audience-class", default=None,
                     help="Filter by audience_class (daemon view is coarse; see note).")
    s_l.set_defaults(func=_cmd_list, verb="list")

    args = p.parse_args()
    return call_with_audit(PROVIDER, args.verb, args.func, args)


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