#!/usr/bin/env python3
"""wdy_certify.py — issue a WDY provenance manifest (wdy-manifest v0.1).

STDLIB ONLY (no third-party deps). The signing key never leaves your machine;
only the manifest JSON (with public key id) is published.

Usage:
  python wdy_certify.py keygen --out PATH.key                # new key; prints public key (b64)
  python wdy_certify.py pubkey --key PATH.key                # print public key (b64) for a key
  python wdy_certify.py issue FILE --title T --type video|script|storyboard|poster
        --mode ai|live|hybrid --ai-ratio 0.8 --record-id WDY-2026-0002
        --key PATH.key --out manifest.json
        [--tool "Kling v2:video-gen"] [--claim "role:party:basis:scope"]
        [--no-visible-label] [--eu-art50] [--tsa-provider P --tsa-serial S --tsa-ts T]

Rules enforced:
  * ai/hybrid works REQUIRE a visible on-screen AI label (refuse to sign otherwise)
  * ai_generated_ratio + live_action_ratio must sum to 1.0 (single source: --ai-ratio)
  * mode=ai  -> ai-ratio locked to 1.0; mode=live -> locked to 0.0
"""
import argparse
import base64
import hashlib
import json
import os
import sys
from datetime import datetime, timezone

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from wdy_ed25519 import canonical_bytes, ed25519_pub, ed25519_sign  # noqa: E402

SCHEMA_VERSION = "0.1"


def _now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _load_key(path):
    seed = bytes.fromhex(open(path, encoding="utf-8").read().strip())
    if len(seed) != 32:
        raise SystemExit("key file must contain 64 hex chars (32 bytes of entropy)")
    return seed


def cmd_keygen(args):
    os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
    if os.path.exists(args.out):
        raise SystemExit(f"refusing to overwrite existing key: {args.out}")
    seed = os.urandom(32)
    with open(args.out, "w", encoding="utf-8") as f:
        f.write(seed.hex())
    pub = base64.b64encode(ed25519_pub(seed)).decode()
    print(f"key written : {args.out}")
    print(f"public key  : {pub}")
    print("store the PRIVATE key offline; publish ONLY the public key "
          "(e.g. wdy.org/certified/rules/wdy-key.pub, one 'key_id=pub' per line)")


def cmd_pubkey(args):
    seed = _load_key(args.key)
    print(base64.b64encode(ed25519_pub(seed)).decode())


def _parse_claims(items):
    out = []
    for it in items or []:
        parts = it.split(":", 3)
        if len(parts) < 2:
            raise SystemExit(f"--claim expects 'role:party[:basis[:scope]]', got: {it}")
        rec = {"role": parts[0].strip(), "party": parts[1].strip()}
        if len(parts) > 2 and parts[2].strip():
            rec["basis"] = parts[2].strip()
        if len(parts) > 3 and parts[3].strip():
            rec["scope"] = parts[3].strip()
        # Guard (added 2026-09-11 after the 0005/0007-0011 claims defect):
        # a comma inside any field means the caller passed a comma-joined list
        # (e.g. "role,producer,party") instead of a colon-separated claim.
        for k, v in rec.items():
            if "," in v:
                raise SystemExit(
                    "claim field '%s' contains a comma: %r\n"
                    "  --claim takes ONE claim as 'role:party[:basis[:scope]]' "
                    "(colon-separated). Pass a separate --claim for each claim; "
                    "commas are not field separators." % (k, v))
        if not rec["role"] or not rec["party"]:
            raise SystemExit(f"claim needs non-empty role and party, got: {it}")
        out.append(rec)
    return out


def cmd_issue(args):
    if not os.path.exists(args.file):
        raise SystemExit(f"file not found: {args.file}")
    data = open(args.file, "rb").read()
    sha = hashlib.sha256(data).hexdigest()

    if args.mode == "ai":
        ai_ratio = 1.0
    elif args.mode in ("live", "authored"):
        ai_ratio = 0.0
    else:
        if args.ai_ratio is None:
            raise SystemExit("--ai-ratio is required for mode=hybrid")
        ai_ratio = round(float(args.ai_ratio), 4)
        if not 0 <= ai_ratio <= 1:
            raise SystemExit("--ai-ratio must be within [0,1]")
    visible_label = not args.no_visible_label
    if args.mode in ("ai", "hybrid") and not visible_label:
        raise SystemExit("REFUSED: ai/hybrid works require a visible on-screen AI label "
                         "(NRTA Order No.16 Art.34 / China AI labelling measures). "
                         "Add the label, or fix the declared mode.")
    if args.mode == "authored":
        # Human-authored work (e.g. research reports, guidance drafts): not AI-generated
        # synthetic content, so neither visible labels nor metadata marking obligations apply.
        if args.ai_ratio is not None:
            raise SystemExit("mode=authored fixes ai_generated_ratio to 0.0; do not pass --ai-ratio")
        visible_label = False
        metadata_marking = False

    m = {
        "manifest_version": SCHEMA_VERSION,
        "record_id": args.record_id,
        "title": args.title,
        "content_type": args.type,
        "file": {"filename": os.path.basename(args.file),
                 "size_bytes": len(data), "sha256": sha},
        "provenance": {
            "production_mode": args.mode,
            "ai_generated_ratio": ai_ratio,
            "live_action_ratio": round(1.0 - ai_ratio, 4),
        },
        "ai_disclosure": {
            "visible_label": visible_label,
            "metadata_marking": (False if args.mode == "authored" else not args.no_metadata_marking),
            "basis": ("Human-authored work — not AI-generated synthetic content; no AI labelling "
                      "obligation (China AI Generated Content Labelling Measures, eff. 2025-09-01, "
                      "apply to AI-generated/synthesised content only)"
                      if args.mode == "authored" else
                      "China AI Generated Content Labelling Measures (eff. 2025-09-01); "
                      "NRTA Order No.16 Art.34, Micro-Drama Development Measures (eff. 2026-09-01)"),
        },
        "rights_claims": _parse_claims(args.claim),
        "signer": {"name": args.signer, "key_id": args.key_id},
        "created_at": _now_iso(),
    }
    tools = []
    for t in args.tool or []:
        name, _, role = t.partition(":")
        rec = {"name": name.strip()}
        if role.strip():
            rec["role"] = role.strip()
        tools.append(rec)
    if tools:
        m["provenance"]["ai_tools"] = tools

    if args.eu_art50:
        m["ai_disclosure"]["eu_ai_act_article_50"] = {
            "applicable": True,
            "machine_readable_marking": {"metadata": True, "pixel_watermark": None},
            "disclosure_log_url": args.disclosure_log_url,
        }
    if args.tier != "standard":
        m["request"] = {"evidence_tier": args.tier}
    anchors = {}
    if args.tsa_provider:
        anchors["tsa"] = {"provider": args.tsa_provider, "serial": args.tsa_serial,
                          "timestamp": args.tsa_ts}
    if anchors:
        m["anchors"] = anchors

    seed = _load_key(args.key)
    sig = ed25519_sign(seed, canonical_bytes(m))
    m["signature"] = {"alg": "Ed25519", "key_id": args.key_id,
                      "created_at": _now_iso(),
                      "value": base64.b64encode(sig).decode()}

    out = args.out or (args.record_id + ".manifest.json")
    with open(out, "w", encoding="utf-8") as f:
        json.dump(m, f, ensure_ascii=False, indent=2)
    print(f"manifest written : {out}")
    print(f"  record_id      : {args.record_id}")
    print(f"  sha256         : {sha}")
    print(f"  signed with    : {args.key_id}")
    print("verify locally : python wdy_verify.py %s FILE --pub wdy-key.pub" % out)


def main():
    ap = argparse.ArgumentParser(description="WDY provenance manifest issuer (v0.1)")
    sub = ap.add_subparsers(dest="cmd", required=True)

    k = sub.add_parser("keygen")
    k.add_argument("--out", required=True)
    k.set_defaults(fn=cmd_keygen)

    p = sub.add_parser("pubkey")
    p.add_argument("--key", required=True)
    p.set_defaults(fn=cmd_pubkey)

    i = sub.add_parser("issue")
    i.add_argument("file")
    i.add_argument("--title", required=True)
    i.add_argument("--type", required=True, choices=["video", "script", "storyboard", "poster", "report", "document", "dataset", "webpage"])
    i.add_argument("--mode", required=True, choices=["ai", "live", "hybrid", "authored"])
    i.add_argument("--ai-ratio", type=float, default=None)
    i.add_argument("--record-id", required=True)
    i.add_argument("--key", required=True)
    i.add_argument("--key-id", default="2026-rot1")
    i.add_argument("--signer", default="WDY")
    i.add_argument("--out", default=None)
    i.add_argument("--tool", action="append")
    i.add_argument("--claim", action="append")
    i.add_argument("--no-visible-label", action="store_true")
    i.add_argument("--no-metadata-marking", action="store_true")
    i.add_argument("--eu-art50", action="store_true")
    i.add_argument("--disclosure-log-url", default=None)
    i.add_argument("--tier", default="standard", choices=["standard", "tsa", "judicial"])
    i.add_argument("--tsa-provider", default=None)
    i.add_argument("--tsa-serial", default=None)
    i.add_argument("--tsa-ts", default=None)
    i.set_defaults(fn=cmd_issue)

    args = ap.parse_args()
    args.fn(args)


if __name__ == "__main__":
    main()
