#!/usr/bin/env python3
"""TonePerfect evaluation capsule runner (standard library only).

Sends each fixture in manifest.json to POST /v1/assess with the compact schema
and checks the outcome against the expectation. Costs about 3 credits (only
assessable recordings are charged). With a tp_test_ key it checks the request
path and schema only and costs nothing. Usage:
    export TONEPERFECT_API_KEY=tp_live_...   # or tp_test_... for a free dry check
    python3 run_capsule.py [--dry-run] [--base https://api.toneperfect.app]
"""
import json, os, sys, time, uuid, urllib.request, urllib.error

BASE = sys.argv[sys.argv.index("--base") + 1] if "--base" in sys.argv else "https://api.toneperfect.app"
DRY = "--dry-run" in sys.argv
here = os.path.dirname(os.path.abspath(__file__))
manifest = json.load(open(os.path.join(here, "manifest.json")))
schema_required = json.load(open(os.path.join(here, "compact.schema.json")))["required"]
key = os.environ.get("TONEPERFECT_API_KEY", "")
# A tp_test_ key returns fixed example results: the run checks the request
# path and the schema only (no audio is scored, nothing is charged).
TEST = key.startswith("tp_test_")
if not key and not DRY:
    sys.exit("Set TONEPERFECT_API_KEY (your 50 free credits cover this run) or use --dry-run.")

def post(fixture, idem):
    boundary = uuid.uuid4().hex
    parts = []
    for name, value in (("text", fixture["text"]), ("language", fixture["language"])):
        parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
    audio = open(os.path.join(here, fixture["file"]), "rb").read()
    parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="audio"; filename="{fixture["file"]}"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode() + audio + b"\r\n")
    parts.append(f"--{boundary}--\r\n".encode())
    request = urllib.request.Request(BASE + "/v1/assess", data=b"".join(parts), method="POST", headers={
        "Authorization": "Bearer " + key, "Idempotency-Key": idem, "TonePerfect-Response": "compact",
        "TonePerfect-Client": "capsule/1", "Content-Type": "multipart/form-data; boundary=" + boundary})
    started = time.monotonic()
    try:
        with urllib.request.urlopen(request, timeout=90) as response:
            status, headers, body = response.status, dict(response.headers), response.read()
    except urllib.error.HTTPError as error:
        status, headers, body = error.code, dict(error.headers), error.read()
    try:
        data = json.loads(body)
    except ValueError:
        data = {"error": {"code": "non_json", "message": body[:120].decode("utf-8", "replace")}}
    return status, {k.lower(): v for k, v in headers.items()}, data, int((time.monotonic() - started) * 1000)

keys, passed = {}, 0
for fixture in manifest["fixtures"]:
    idem = keys.get(fixture.get("replay_of")) or f"capsule-{fixture['id']}-{uuid.uuid4().hex[:12]}"
    keys[fixture["id"]] = idem
    if DRY:
        print(f"{fixture['id']:12} {fixture['file']:22} {fixture['language']:7} expect: {fixture['expect']}")
        continue
    status, headers, data, ms = post(fixture, idem)
    want, problems = fixture["expected"], []
    if TEST:
        if status not in (200, 422):
            problems.append(f"status {status}")
        if status == 200 and (data.get("schema") != "toneperfect.compact.v1" or data.get("test") is not True or (data.get("billing") or {}).get("credits") != 0):
            problems.append("test-key response is not a free compact example")
        outcome = (data.get("error") or {}).get("code") or "fixed example"
        passed += not problems
        print(f"{'ok  ' if not problems else 'DIFF'} {fixture['id']:12} {status} {outcome:28} {ms} ms {'; '.join(problems)}")
        continue
    if status != want["status"]:
        problems.append(f"status {status} != {want['status']}")
    if "code" in want and (data.get("error") or {}).get("code") != want["code"]:
        problems.append(f"code {(data.get('error') or {}).get('code')} != {want['code']}")
    if want.get("schema") and status == 200:
        missing = [k for k in schema_required if k not in data]
        if data.get("schema") != want["schema"] or missing:
            problems.append(f"schema mismatch (missing {missing})")
    if "assessable" in want and data.get("assessable") is not want["assessable"]:
        problems.append(f"assessable {data.get('assessable')} != {want['assessable']}")
    credits = (data.get("billing") or {}).get("credits")
    if "credits" in want and status == 200 and data.get("assessable") is not False and credits != want["credits"]:
        problems.append(f"credits {credits} != {want['credits']}")
    if want.get("replayed") and headers.get("idempotent-replayed") != "true":
        problems.append("not replayed")
    if want.get("units") and status == 200 and data.get("words"):
        dims = [u["dimension"] for u in data["words"][0]["units"]]
        if dims != want["units"]:
            problems.append(f"units {dims}")
    outcome = (data.get("error") or {}).get("code") or (f"overall {data['scores']['overall']}" if data.get("scores") else f"assessable={data.get('assessable')}")
    passed += not problems
    print(f"{'ok  ' if not problems else 'DIFF'} {fixture['id']:12} {status} {outcome:28} credits={credits} {ms} ms {'; '.join(problems)}")
if not DRY:
    print(f"{passed}/{len(manifest['fixtures'])} fixtures matched. Median customer latency: {manifest['timings']['median_ms']} ms.")
    sys.exit(0 if passed == len(manifest["fixtures"]) else 1)
