#!/bin/bash

# ============================================================
# Script Setup
# ============================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/utils.sh"
OPENCLAW_CLI_SCRIPT="$SCRIPT_DIR/openclaw_cli.sh"
CONFIG_FILE="$OPENCLAW_HOME_DIR/openclaw.json"

# ============================================================
# Environment Setup
# ============================================================
TARGET="${1:-}"
shift || true

PROVIDER=""
MODEL_ID=""

# ============================================================
# Usage Helpers
# ============================================================
# Print script usage and supported removal targets.
usage() {
    cat <<'EOF'
Usage:
  remove_model.sh <provider>
  remove_model.sh <provider>/<model-id>

Targets:
  <provider>            Remove the whole provider from models.providers.
  <provider>/<model-id> Remove one model, keeping the provider and its other models.

Examples:
  remove_model.sh ollama
  remove_model.sh ollama/gemma4:26b-a4b-it-Q8_0

Notes:
  Removal is refused while any agent still selects the target as its model —
  a dangling reference makes every turn of that agent fail. Repoint it first
  with setup_model.sh or `registry.py set-model`, then remove.

  Removal only unregisters the model from IndustrialClaw/OpenClaw. It does not
  delete, uninstall or unsubscribe the actual model on Ollama, vLLM, Copilot or
  any other provider — that stays exactly where it was.

Removes from:
  openclaw.json  models.providers.<provider>[.models]
  openclaw.json  agents.defaults.models entry left behind by set-model
  openclaw.json  auth.profiles of that provider (whole-provider removal only)
  agents/*/agent/models.json  runtime catalog read by set-model + the dashboard

Applied through the running Gateway (`gateway call config.apply`), which hot-reloads a
models change, so nothing is restarted and no running Task is interrupted.

Leaves alone:
  agents/*/agent/auth-profiles.json  credential; re-adding the provider overwrites it
  clawState.db agents.model          per-Agent selection; covered by the refusal check
  the model on the Ollama/inference host  use that server's own delete command
  task history and token ledger      past usage stays attributed to the model
EOF
}

# Print an error, then show usage before exiting.
fail_usage() {
    log_error "$1"
    echo
    usage
    exit 1
}

# ============================================================
# CLI Helpers
# ============================================================
# Run OpenClaw CLI commands through the shared wrapper script.
run_openclaw_cli() {
    "$OPENCLAW_CLI_SCRIPT" "$@"
}

# Apply the next config through the running Gateway.
#
# `config.patch` cannot express a removal: the Gateway merges it with
# `applyMergePatch(..., { mergeObjectArraysById: true })` (dist/merge-patch), which merges array
# entries by `id` and APPENDS unknown ones -- it never drops one. A shortened `models: [...]`
# therefore changes nothing and the Gateway answers `ok: true, noop: true`, which is how a
# removal that removed nothing got reported as success. `config.apply` takes the whole config
# and replaces it, so a model that is not in the body is gone. Both methods write through the
# Gateway's own reload rules and `models.*` is a hot path, so nothing is restarted.
#
# A refusal must stop removal; changing only the file could leave the running Gateway stale.
apply_config() {
    local raw="$1" hash params
    hash="$(run_openclaw_cli gateway call config.get --json 2>/dev/null \
        | python3 -c 'import json,sys; text=sys.stdin.read(); start=text.find("{"); print((json.loads(text[start:]) if start >= 0 else {}).get("hash") or "")' 2>/dev/null)"
    if [[ -z "$hash" ]]; then
        log_error "Could not read the Gateway config; removal was not applied."
        return 1
    fi
    params="$(python3 -c 'import json,sys; print(json.dumps({"baseHash": sys.argv[1], "raw": sys.argv[2], "note": sys.argv[3]}))' \
        "$hash" "$raw" "Removed $TARGET from the Dashboard.")"
    if ! run_openclaw_cli gateway call config.apply --params "$params" --json >/dev/null; then
        log_error "config.apply failed; removal was not applied."
        return 1
    fi
    log_info "Applied through the running Gateway; nothing was stopped."
}

# Read the config back and prove the target is gone: the Gateway reports `ok` for a change that
# merged into nothing, so success is what the file says, not what the RPC answered.
verify_removed() {
    python3 - "$CONFIG_FILE" "$PROVIDER" "$MODEL_ID" <<'PY'
import json
import pathlib
import sys
import time

path, provider, model_id = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3]
target = f"{provider}/{model_id}" if model_id else provider
for _ in range(10):
    try:
        config = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        time.sleep(0.3)
        continue
    providers = ((config.get("models") or {}).get("providers")) or {}
    entry = providers.get(provider)
    if model_id:
        models = [m for m in ((entry or {}).get("models") or []) if isinstance(m, dict)]
        ids = [str(m.get("id") or m.get("name") or "") for m in models]
        refs = ((config.get("agents") or {}).get("defaults") or {}).get("models") or {}
        gone = model_id not in ids and target not in refs
    else:
        refs = ((config.get("agents") or {}).get("defaults") or {}).get("models") or {}
        gone = provider not in providers and not any(str(ref).split("/", 1)[0] == provider for ref in refs)
    if gone:
        sys.exit(0)
    time.sleep(0.3)
sys.exit(f"{target} is still in the config; it was not removed.")
PY
}

# ============================================================
# Functions
# ============================================================
# Split "<provider>" or "<provider>/<model-id>" into its parts.
parse_target() {
    [[ -n "$TARGET" ]] || fail_usage "Missing removal target"
    case "$TARGET" in
        -h|--help) usage; exit 0 ;;
        */*)
            PROVIDER="${TARGET%%/*}"
            MODEL_ID="${TARGET#*/}"
            [[ -n "$MODEL_ID" ]] || fail_usage "Missing model id after '/'"
            ;;
        *) PROVIDER="$TARGET" ;;
    esac
    [[ -n "$PROVIDER" ]] || fail_usage "Missing provider name"
}

# Verify the target exists and that no agent still selects it.
# Prints the whole config with the target removed, ready for config.apply.
plan_removal() {
    python3 - "$CONFIG_FILE" "$PROVIDER" "$MODEL_ID" <<'PY'
import json
import sys

path, provider, model_id = sys.argv[1], sys.argv[2], sys.argv[3]
try:
    config = json.loads(open(path, encoding="utf-8").read())
except OSError as exc:
    sys.exit(f"Cannot read {path}: {exc}")
except json.JSONDecodeError as exc:
    sys.exit(f"{path} is not valid JSON: {exc}")

providers = ((config.get("models") or {}).get("providers")) or {}
entry = providers.get(provider)
models = [m for m in ((entry or {}).get("models") or []) if isinstance(m, dict)]
ids = [str(m.get("id") or m.get("name") or "") for m in models]

# A model can be listed with no provider block of its own: applyPrimaryModel registers the
# ref under agents.defaults.models, and a provider the gateway enabled for itself leaves
# nothing else behind. The Dashboard shows those rows, so removing one has to work here too
# -- otherwise the only way to drop it is to hand-edit openclaw.json.
orphans = ((config.get("agents") or {}).get("defaults") or {}).get("models") or {}
orphan = f"{provider}/{model_id}" if model_id else ""
orphan = orphan if orphan in orphans else ""
registered = any(str(ref).split("/", 1)[0] == provider for ref in orphans)
if not isinstance(entry, dict) and not orphan and not (registered and not model_id):
    sys.exit(f"Provider '{provider}' is not configured in models.providers.")
if model_id and not orphan and model_id not in ids:
    sys.exit(f"Provider '{provider}' has no model '{model_id}'. Configured: {', '.join(ids) or '(none)'}")

# Every model ref that disappears once the target is removed.
doomed = {f"{provider}/{model_id}"} if model_id else {f"{provider}/{i}" for i in ids}


def refs(model):
    """Model refs held by agents.defaults or one agents.list item."""
    if isinstance(model, str):
        return [model]
    if not isinstance(model, dict):
        return []
    return [model.get("primary"), *(model.get("fallbacks") or [])]


agents = config.get("agents") or {}
users = []
for label, holder in [("agents.defaults", agents.get("defaults") or {})] + [
    (f"agents.list[{a.get('id')}]", a) for a in (agents.get("list") or []) if isinstance(a, dict)
]:
    for ref in refs(holder.get("model")):
        if ref in doomed:
            users.append(f"  {label} -> {ref}")
if users:
    sys.exit("Still selected by:\n" + "\n".join(users))

# The whole config goes back through config.apply, so the removal is an edit of the parsed
# object. Nothing that names the target may survive it: a registration or a credential left
# behind is what puts a model in the Dashboard that no provider can serve.
profiles = ((config.get("auth") or {}).get("profiles")) or {}
if model_id:
    if model_id in ids and isinstance(entry, dict):
        entry["models"] = [m for m in (entry.get("models") or [])
                           if str((m or {}).get("id") or (m or {}).get("name") or "") != model_id]
    if orphan:
        del orphans[orphan]
else:
    # Provider-level: the block, every model it registered under agents.defaults.models, and
    # the auth profiles that only existed for it. The provider is being disconnected, so a
    # profile kept "for next time" is a credential for a service this device no longer has.
    providers.pop(provider, None)
    for ref in [r for r in orphans if str(r).split("/", 1)[0] == provider]:
        del orphans[ref]
    for name in [n for n, profile in profiles.items()
                 if isinstance(profile, dict) and profile.get("provider") == provider]:
        del profiles[name]
print(json.dumps(config, indent=2, ensure_ascii=False))
PY
}

# Drop the target from every agent's runtime model catalog.
# set-model and the dashboard offer the UNION of openclaw.json and these files, so a model
# left here comes back as a selectable option and gets written straight back into the config.
# No CLI writes these, so they are edited in place (temp + rename).
prune_runtime_catalogs() {
    python3 - "$OPENCLAW_HOME_DIR" "$PROVIDER" "$MODEL_ID" <<'PY'
import json
import os
import pathlib
import sys

home, provider, model_id = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3]
for path in sorted(home.glob("agents/*/agent/models.json")):
    try:
        catalog = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"[WARN] skipped {path}: {exc}")
        continue
    entry = (catalog.get("providers") or {}).get(provider)
    if not isinstance(entry, dict):
        continue
    if model_id:
        kept = [m for m in (entry.get("models") or [])
                if str((m or {}).get("id") or (m or {}).get("name") or "") != model_id]
        if len(kept) == len(entry.get("models") or []):
            continue
        entry["models"] = kept
    else:
        del catalog["providers"][provider]
    tmp = path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    os.replace(tmp, path)
    print(f"[OK]   pruned {path}")
PY
}

# ============================================================
# Removal
# ============================================================
parse_target

NEXT_CONFIG=$(plan_removal) || exit 1

apply_config "$NEXT_CONFIG" || exit 1
verify_removed || exit 1
if [[ -n "$MODEL_ID" ]]; then
    log_success "Removed model $PROVIDER/$MODEL_ID"
else
    log_success "Removed provider $PROVIDER"
fi

prune_runtime_catalogs
