#!/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 NOT THIS FILE'S BUSINESS. The gateway is the junction — that is what makes
it the proxy — and its cost regulator is the single place deciding what may be used; it
answers 403 when a model may not. This file mirrors what the gateway serves and has no
opinion about it. An earlier version filtered to local + free as a "convenience", which made
it a SECOND policy source; on 2026-08-10 the two disagreed, the regulator standing at max
while the filter would have dropped 304 permitted models. One decision, one place.

  bin/models-json <host>            # print the diff, change nothing
  bin/models-json local             # run ON this machine (nested containers)
  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] ")
# Relative to the target's HOME, never to whatever directory the caller
# happens to stand in. `su - user` lands in the login directory, which on
# 2026-08-10 was not where the file lives: --apply died with "cannot create
# .pi/agent/models.json: Permission denied" on pipi and pica, after having
# rendered the whole thing.
MODELS_JSON = "$HOME/.pi/agent/models.json"
# 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."""
    # No cost filter here. The gateway's regulator decides what may be used and
    # answers 403 when it may not; a second filter in this file would be a
    # second policy source, and two sources for one decision drift apart. They
    # did: on 2026-08-10 the regulator stood at max while a hardcoded
    # local+free filter would have dropped 304 permitted models.
    keep = sorted((e for e in cat if 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 _run(host, argv, **kw):
    """Run argv on `host`, or here when host is "local".

    The nested containers are not sic hosts. Installing the generator inside
    them and pointing it at "local" keeps ONE code path for rendering; only
    the transport differs.
    """
    if host in ("local", "-"):
        return subprocess.run(argv, **kw)
    return subprocess.run(["sic", host] + list(argv), **kw)


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

    r = _run(a.host, ["sh", "-c", f"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

    _run(a.host, ["sh", "-c",
                  f"cp -a {MODELS_JSON} {MODELS_JSON}.bak-$(date +%Y%m%d-%H%M%S)"], check=True)
    w = _run(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 = _run(a.host, ["sh", "-c", "python3 -c \"$0\"",
                          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()
