#!/usr/bin/env python3
"""models-json — render a pi-agent model list from the gateway catalog (Roundhouse card 3).

catalog-diff makes the drift visible; this closes it. The hand-kept list is what Fable's
architecture pass called "a frozen selection masquerading as a catalog": on 2026-08-02 six
of thirteen providers pointed at endpoints that no longer answered, and four of five
gateway entries named models the gateway no longer serves.

WHAT IT DOES NOT DO. It does not invent provider names and it does not rename anything.
Names are referenced from outside this file — bullpen's cfg.OC_MODEL is literally
"bosch-dspark/deepseek-v4-flash-dspark", and a running agent session pins the provider it
was started with. Renaming would be tidier and would break both. So:

  * a provider pointing AT THE GATEWAY gets its model list regenerated from the catalog;
  * a provider pointing straight at a backend is left alone if it answers — bypassing the
    gateway is a deliberate choice, not drift — and dropped if it does not;
  * nothing is added that was not asked for.

SELECTION IS POLICY, NOT FACT. The catalog holds 259 models; a picker with 259 entries is
not a picker. Default filter is local + free, which keeps the "no paid offloading" posture
visible in the tool a human actually looks at. Enforcement stays where it already is — the
gateway's cost regulator answers 403 — because a filter is a convenience and a regulator
is a guarantee.

  bin/models-json <host>            # print the diff, change nothing
  bin/models-json <host> --apply    # back up and install
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.parse
import urllib.request

sys.path[:0] = [p for p in (os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "lib"),
                            "/usr/local/lib/bullpen") if os.path.isdir(p)]
import bullpen_config as cfg

TAGS = ("[$] ", "[free] ", "[local] ")
MODELS_JSON = ".pi/agent/models.json"
KEEP_CLASSES = ("local", "free")
# The one gateway provider that acts as a CATALOG - see cfg.CATALOG_PROVIDER for why
# the other providers on the same endpoint are aliases rather than catalogues. The
# default lives in bullpen_config because tests/test_no_hardcoded_hosts.py allows a
# fleet name in exactly that one file.
CATALOG_PROVIDER = cfg.CATALOG_PROVIDER


def _untag(mid):
    for t in TAGS:
        if mid.startswith(t):
            return mid[len(t):]
    return mid


def catalog():
    url = cfg.PROXY.split("/v1/")[0].rstrip("/") + "/v1/models"
    with urllib.request.urlopen(url, timeout=30) as r:
        return json.load(r).get("data", []), url


def answers(base_url):
    if not base_url:
        return False
    r = subprocess.run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-m", "6",
                        base_url.rstrip("/") + "/models"], capture_output=True, text=True)
    return r.stdout.strip() not in ("000", "")


def model_row(entry):
    """A catalog entry -> one pi-agent model row. Only fields the catalog actually carries;
    a missing context window is left out rather than guessed, for the same reason the
    catalog distinguishes a missing price from the price zero."""
    row = {"id": _untag(entry["id"])}
    if entry.get("name"):
        row["name"] = entry["name"]
    if entry.get("reasoning"):
        row["reasoning"] = True
    if entry.get("ctx"):
        row["contextWindow"] = entry["ctx"]
    row["compat"] = {"supportsDeveloperRole": False}
    return row


def render(current, cat, gw):
    """Returns (new_providers, notes). Deterministic: same catalog, same output."""
    keep = sorted((e for e in cat if e.get("cost_class") in KEEP_CLASSES
                   and e.get("reachable", True)),
                  key=lambda e: (e.get("cost_class"), e["id"]))
    rows = [model_row(e) for e in keep]

    out, notes = {}, []
    for name in sorted(current):
        p = dict(current[name])
        base = urllib.parse.urlparse(p.get("baseUrl", ""))
        if base.hostname == gw.hostname and base.port == gw.port:
            if name == CATALOG_PROVIDER:
                before = {m.get("id") for m in p.get("models", [])}
                p["models"] = rows
                after = {m["id"] for m in rows}
                notes.append(f"{name}: regenerated from catalog "
                             f"({len(before)} -> {len(after)}; gone: "
                             f"{', '.join(sorted(before - after)) or 'none'})")
            else:
                # Alias: keep the pinned model, but say so when the catalog lost it —
                # a pin at a model the gateway no longer serves is a silent 404 later.
                known = {_untag(e["id"]) for e in cat}
                pinned = [m for m in p.get("models", [])]
                gone = [m.get("id") for m in pinned if _untag(m.get("id", "")) not in known]
                notes.append(f"{name}: alias, {len(pinned)} pinned model(s)"
                             + (f" — NOT IN CATALOG: {', '.join(gone)}" if gone else " — ok"))
            out[name] = p
        elif answers(p.get("baseUrl", "")):
            notes.append(f"{name}: left alone (direct backend, answers)")
            out[name] = p
        else:
            notes.append(f"{name}: DROPPED (direct backend, no answer at {p.get('baseUrl','')})")
    return out, notes


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("host")
    ap.add_argument("--apply", action="store_true")
    a = ap.parse_args()

    r = subprocess.run(["sic", a.host, "cat", MODELS_JSON], capture_output=True, text=True, timeout=60)
    if r.returncode != 0 or not r.stdout.strip():
        sys.exit(f"models-json: cannot read {a.host}:{MODELS_JSON}")
    current = json.loads(r.stdout).get("providers", {})

    cat, url = catalog()
    gw = urllib.parse.urlparse(url)
    new, notes = render(current, cat, gw)
    for n in notes:
        print("  " + n)

    text = json.dumps({"providers": new}, indent=2, ensure_ascii=False) + "\n"
    print(f"\n  {len(current)} providers -> {len(new)}; "
          f"{sum(len(p.get('models', [])) for p in current.values())} entries -> "
          f"{sum(len(p['models']) for p in new.values())}")
    if not a.apply:
        print("  (dry run — nothing written; pass --apply)")
        return

    subprocess.run(["sic", a.host, "sh", "-c",
                    f"cp -a {MODELS_JSON} {MODELS_JSON}.bak-$(date +%Y%m%d-%H%M%S)"], check=True)
    w = subprocess.run(["sic", a.host, "sh", "-c", f"cat > {MODELS_JSON}"],
                       input=text, capture_output=True, text=True, timeout=60)
    if w.returncode != 0:
        sys.exit(f"models-json: write failed: {w.stderr[:200]}")
    chk = subprocess.run(["sic", a.host, "python3", "-c",
                          f"import json;d=json.load(open('{MODELS_JSON}'));"
                          f"print(len(d['providers']),'providers',"
                          f"sum(len(p.get('models',[])) for p in d['providers'].values()),'entries')"],
                         capture_output=True, text=True, timeout=60)
    print(f"  written and re-read: {chk.stdout.strip() or chk.stderr[:120]}")


if __name__ == "__main__":
    main()
