#!/usr/bin/env python3
"""Read-only self-test harness for the b2 skill.

Runs each CLI command as an isolated subprocess and checks exit code + output
markers. Makes ZERO write calls: create/delete are only exercised in their
--confirm-absent refusal path (rc 3), and `raw` write-method gating is checked
without confirm. Sizes the SMALLEST bucket only to stay cheap.

Asserts the known accountId is present and both known application keys
("cloudberrykey", "ClaudeTools") are listed. Prints a PASS/FAIL report.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
B2 = os.path.join(HERE, "b2.py")

EXPECTED_ACCOUNT_ID = "46f69bc61163"
EXPECTED_KEY_NAMES = {"cloudberrykey", "ClaudeTools"}

results = []


def run(args):
    env = dict(os.environ)
    env["PYTHONIOENCODING"] = "utf-8"
    p = subprocess.run([sys.executable, B2] + args, capture_output=True,
                       text=True, env=env, timeout=300)
    return p.returncode, p.stdout, p.stderr


def record(name, ok, detail, sample=""):
    status = "PASS" if ok else "FAIL"
    results.append((status, name, detail, sample.replace("\n", " ")[:120]))


def check(name, args, *, want_rc=None, out_has=None, err_has=None,
          out_json_ok=False):
    rc, out, err = run(args)
    problems = []
    if want_rc is not None and rc != want_rc:
        problems.append(f"rc={rc} want {want_rc}")
    if out_has and out_has not in out:
        problems.append(f"stdout missing {out_has!r}")
    if err_has and err_has not in err:
        problems.append(f"stderr missing {err_has!r}")
    if out_json_ok:
        try:
            json.loads(out)
        except Exception as e:
            problems.append(f"stdout not valid JSON: {e}")
    record(name, not problems, "; ".join(problems), out[:120])
    return rc, out, err


# --- auth / status ---
rc, out, err = check("status table", ["status"], want_rc=0, out_has="accountId")
if rc == 0 and EXPECTED_ACCOUNT_ID not in out:
    record("status accountId match", False,
           f"expected accountId {EXPECTED_ACCOUNT_ID} not in status output")
else:
    record("status accountId match", True, "")
check("status json", ["status", "--json"], want_rc=0, out_json_ok=True)

# --- buckets ---
check("buckets table", ["buckets"], want_rc=0, out_has="Buckets:")
rc, out, err = check("buckets json", ["buckets", "--json"], want_rc=0,
                     out_json_ok=True)
buckets = []
if rc == 0:
    try:
        buckets = json.loads(out)
    except Exception:
        buckets = []
record("buckets non-empty", bool(buckets),
       "" if buckets else "no buckets returned")

# --- keys: assert both known keys present ---
rc, out, err = check("keys json", ["keys", "--json"], want_rc=0, out_json_ok=True)
if rc == 0:
    try:
        keys = json.loads(out)
        names = {k.get("keyName") for k in keys}
        missing = EXPECTED_KEY_NAMES - names
        record("keys include known names", not missing,
               f"missing {missing}" if missing else "")
    except Exception as e:
        record("keys include known names", False, f"parse error: {e}")
else:
    record("keys include known names", False, "keys json call failed")

# --- bucket-size on a known-small bucket only (cheap) ---
# Probing all 12 buckets with `files --limit 1000` to discover the smallest
# burns one (paginating) list pass per bucket. The size/cost path is what we
# actually want to smoke-test, so target the known-small ACG-IX bucket directly
# and size only that one. This cuts the read-transaction cost from ~12 list
# passes to ~1 while keeping the bucket-size / usage / cost assertions just as
# meaningful. Fall back to the first listed bucket if ACG-IX is ever removed.
KNOWN_SMALL_BUCKET = "ACG-IX"
smallest = None
if buckets:
    names = {b.get("bucketName") for b in buckets}
    if KNOWN_SMALL_BUCKET in names:
        smallest = KNOWN_SMALL_BUCKET
    else:
        smallest = buckets[0].get("bucketName")
    record("found small bucket to size", smallest is not None,
           "" if smallest else "no buckets to size")

if smallest:
    rc, out, err = check(f"bucket-size {smallest}", ["bucket-size", smallest],
                         want_rc=0, out_has="stored bytes:")
    check(f"bucket-size json {smallest}", ["bucket-size", smallest, "--json"],
          want_rc=0, out_json_ok=True)

# --- usage scoped to the smallest bucket (cheap headline-feature smoke test) ---
if smallest:
    check("usage scoped json", ["usage", "--bucket", smallest, "--json"],
          want_rc=0, out_json_ok=True)
    check("usage scoped table", ["usage", "--bucket", smallest],
          want_rc=0, out_has="TOTAL")
    check("cost alias scoped", ["cost", "--bucket", smallest, "--json"],
          want_rc=0, out_json_ok=True)

# --- error handling ---
check("files bogus bucket -> rc1", ["files", "no-such-bucket-xyz"],
      want_rc=1, err_has="[ERROR]")
check("usage bogus bucket -> rc1", ["usage", "--bucket", "no-such-bucket-xyz"],
      want_rc=1, err_has="[ERROR]")

# --- argparse: missing required arg -> rc2 ---
check("files missing positional -> rc2", ["files"], want_rc=2)
check("create-key missing --name -> rc2",
      ["create-key", "--capabilities", "listFiles"], want_rc=2)

# --- gating: destructive without --confirm -> rc3, NO write call ---
check("create-bucket no confirm -> rc3", ["create-bucket", "X"], want_rc=3,
      out_has="Would")
check("create-key no confirm -> rc3",
      ["create-key", "--name", "X", "--capabilities", "listFiles"],
      want_rc=3, out_has="Would")
check("delete-key no confirm -> rc3", ["delete-key", "000bogus"], want_rc=3)

# --- lifecycle: read-only listing on the small bucket ---
if smallest:
    check(f"lifecycle {smallest} table", ["lifecycle", smallest],
          want_rc=0, out_has="Lifecycle rules:")
    check(f"lifecycle {smallest} json", ["lifecycle", smallest, "--json"],
          want_rc=0, out_json_ok=True)

# --- delete-prefix: REFUSAL paths only (no --confirm, never writes) ---
if smallest:
    # Valid-looking machine prefix, no --confirm -> rc3, shows the WOULD-add line
    # and the irreversible-deletion warning. This does NOT add a rule.
    check("delete-prefix no confirm -> rc3 (would add + warning)",
          ["delete-prefix", smallest, "MBS-00000000/CBB_SELFTEST/"],
          want_rc=3, out_has="Would add purge rule")
    # Too-broad prefixes are HARD-FAIL (rc2) even though --confirm is absent here;
    # the validation runs first. None of these write anything.
    check("delete-prefix empty -> rc2 (too broad)",
          ["delete-prefix", smallest, ""], want_rc=2, err_has="too broad")
    check("delete-prefix no-slash -> rc2 (too broad)",
          ["delete-prefix", smallest, "MBS-noslash"],
          want_rc=2, err_has="no '/'")
    check("delete-prefix account-root -> rc2 (needs --allow-account-root)",
          ["delete-prefix", smallest, "MBS-00000000/"],
          want_rc=2, err_has="account root")

# --- lifecycle-remove: REFUSAL path only (no --confirm, never writes) ---
if smallest:
    check("lifecycle-remove no confirm -> rc3",
          ["lifecycle-remove", smallest, "MBS-00000000/CBB_SELFTEST/"],
          want_rc=3, out_has="Refusing")

# --- raw gating ---
check("raw write method no confirm -> rc3",
      ["raw", "--method", "b2_delete_bucket", "--body", "{}"], want_rc=3)
check("raw update_bucket gated -> rc3",
      ["raw", "--method", "b2_update_bucket", "--body", "{}"], want_rc=3)
check("raw bad json body -> rc2",
      ["raw", "--method", "b2_list_buckets", "--body", "{bad"], want_rc=2)
check("raw read ok",
      ["raw", "--method", "b2_list_buckets",
       "--body", json.dumps({"accountId": EXPECTED_ACCOUNT_ID})],
      want_rc=0, out_json_ok=True)

# --- report ---
print("\n==== b2 skill self-test ====")
npass = sum(1 for r in results if r[0] == "PASS")
for status, name, prob, sample in results:
    line = f"[{status}] {name}"
    if prob:
        line += f"  -> {prob}"
    print(line)
print(f"\n{npass}/{len(results)} passed, {len(results)-npass} failed")
sys.exit(0 if npass == len(results) else 1)
