#!/usr/bin/env bash
# model-dispatch.sh  -  which rung answers this call.
#
# The one place `prefs.global.modelRouting` turns into a rung. Every call site
# that may be routed asks here, so the policy has a single implementation and
# `/multi-agent:route-status` describes something real.
#
# Usage:
#   model-dispatch.sh <call-site> [--persona P] [--phase N] [--task-kind K]
#                                 [--default RUNG] [--task-id ID]
#
# <call-site> is one of the `scope` members: subagent | bulk-read | research.
# stdout is a single rung NAME. Rung names are the contract; the model id behind
# one lives in cost-table.json and moves without a config edit.
#
# Exit is always 0 and stdout is always a usable rung. A router that can fail
# turns every call site into a place the run can die, for a feature that ships
# disabled; when anything is missing, unparseable or out of scope, the caller's
# default comes back unchanged.
#
# Two layers, and the difference is the safety argument the schema spells out:
#
#   Layer 1  an Anthropic rung. Reachable from every call site, because it
#            writes to a seam that already exists and opens no new network path.
#   Layer 2  a non-Anthropic rung. NO call site can dispatch to one today, and
#            the router refuses it rather than returning a rung its caller will
#            hand to a CLI that does not know the name:
#              - a subagent is dispatched by the HOST;
#              - bulk-read shells out to `claude -p --model <rung>`;
#              - research_ask runs in the MCP server and picks its own model.
#            `provider` in cost-table.json is what makes this checkable, and it
#            is the seam a future call site would use. Until one exists, the
#            refusal is the honest answer: an allowed rung nothing can honour is
#            a rule the user believes worked.

set -uo pipefail

CALL_SITE="${1:-}"
shift || true

PERSONA=""; PHASE=""; TASK_KIND=""; DEFAULT_RUNG=""; TASK_ID="${MULTI_AGENT_TASK_ID:-unknown}"
while [ $# -gt 0 ]; do
  case "$1" in
    --persona)   PERSONA="${2:-}"; shift 2 ;;
    --phase)     PHASE="${2:-}"; shift 2 ;;
    --task-kind) TASK_KIND="${2:-}"; shift 2 ;;
    --default)   DEFAULT_RUNG="${2:-}"; shift 2 ;;
    --task-id)   TASK_ID="${2:-}"; shift 2 ;;
    *) shift ;;
  esac
done

emit() { printf '%s\n' "$1"; exit 0; }

[ -n "$DEFAULT_RUNG" ] || DEFAULT_RUNG="sonnet"

PREFS=""
for candidate in \
  "${MULTI_AGENT_PREFS:-}" \
  "$HOME/.claude/multi-agent-preferences.json" \
  "$HOME/.config/multi-agent-pipeline/multi-agent-preferences.json"
do
  [ -n "$candidate" ] && [ -f "$candidate" ] && { PREFS="$candidate"; break; }
done

# The fable rung exists only while modelFallback.fableEnabled is true, and it
# ships false. A caller whose default is fable - a command that runs without
# Phase 0, such as review - gets the documented resolution, opus, on every path
# out of here: routing off, out of scope, no rule, or nothing readable.
FABLE_ON=false
if [ -n "$PREFS" ] && command -v jq >/dev/null 2>&1; then
  FABLE_ON=$(jq -r '.global.modelFallback.fableEnabled // false' "$PREFS" 2>/dev/null || echo false)
fi
[ "$DEFAULT_RUNG" = "fable" ] && [ "$FABLE_ON" != "true" ] && DEFAULT_RUNG="opus"

case "$CALL_SITE" in
  subagent|bulk-read|research) ;;
  *) emit "$DEFAULT_RUNG" ;;
esac

command -v jq >/dev/null 2>&1 || emit "$DEFAULT_RUNG"
[ -n "$PREFS" ] || emit "$DEFAULT_RUNG"

ENABLED=$(jq -r '.global.modelRouting.enabled // false' "$PREFS" 2>/dev/null) || emit "$DEFAULT_RUNG"
[ "$ENABLED" = "true" ] || emit "$DEFAULT_RUNG"

# Out of scope is not a failure and not a warning. The user named the call sites
# routing may touch; the ones they left out keep their existing behaviour, which
# is the point of naming them.
IN_SCOPE=$(jq -r --arg cs "$CALL_SITE" \
  '((.global.modelRouting.scope // ["subagent"]) | index($cs)) != null' "$PREFS" 2>/dev/null)
[ "$IN_SCOPE" = "true" ] || emit "$DEFAULT_RUNG"

# First rule whose every stated condition matches. A `when` with three keys has
# to match on all three - the schema requires at least one, so an always-matching
# rule cannot be written by accident.
MATCH=$(jq -r \
  --arg persona "$PERSONA" --arg phase "$PHASE" --arg kind "$TASK_KIND" \
  '[ (.global.modelRouting.rules // [])[]
     | select(
         ((.when.persona  // null) as $p | $p == null or $p == $persona)
         and ((.when.phase   // null) as $h | $h == null or ($phase != "" and ($h | tostring) == $phase))
         and ((.when.taskKind // null) as $k | $k == null or $k == $kind)
       )
   ] | first | (.prefer // []) | join(" ")' "$PREFS" 2>/dev/null) || emit "$DEFAULT_RUNG"
[ -n "$MATCH" ] && [ "$MATCH" != "null" ] || emit "$DEFAULT_RUNG"

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COST_TABLE="$HERE/../scripts/cost-table.json"

# The fable rung answers only when the fable switch is on (FABLE_ON, read
# above). Routing is a policy over what is available; it is not a second way to
# turn a rung on, or `/multi-agent:model off` would stop meaning anything the
# moment a rule named fable.

record() {
  [ -x "$HERE/../scripts/log-metric.sh" ] || return 0
  local rec
  rec=$(jq -r '.global.modelRouting.recordDecisions // true' "$PREFS" 2>/dev/null)
  [ "$rec" = "false" ] && return 0
  "$HERE/../scripts/log-metric.sh" "$TASK_ID" "${PHASE:-0}" model_routing.decision \
    call_site="$CALL_SITE" rung="$1" reason="$2" >/dev/null 2>&1 || true
}

for rung in $MATCH; do
  if [ ! -f "$COST_TABLE" ]; then
    record "$rung" "no cost table; rung taken on trust"
    emit "$rung"
  fi
  PROVIDER=$(jq -r --arg r "$rung" '.prices[$r].provider // ""' "$COST_TABLE" 2>/dev/null)
  # A rung nobody priced is a typo in a config far more often than it is a new
  # model. Skipping it silently would route to the next one and leave the user
  # certain their rule applied.
  if [ -z "$PROVIDER" ]; then
    echo "model-dispatch: rung '$rung' is not in cost-table.json; skipping it" >&2
    continue
  fi
  if [ "$rung" = "fable" ] && [ "$FABLE_ON" != "true" ]; then
    echo "model-dispatch: rule prefers 'fable' but modelFallback.fableEnabled is false; skipping it" >&2
    continue
  fi
  if [ "$PROVIDER" != "anthropic" ]; then
    # The honest limit, said out loud at the moment it bites. Every call site
    # that asks here hands the rung to something that only speaks the Anthropic
    # ladder, so returning an external rung would produce a failed call rather
    # than a cheaper one.
    echo "model-dispatch: rung '$rung' is $PROVIDER and no call site can dispatch to one yet; skipping it" >&2
    continue
  fi
  record "$rung" "rule matched"
  emit "$rung"
done

record "$DEFAULT_RUNG" "every preferred rung was unavailable"
emit "$DEFAULT_RUNG"
