// `openthomas route` — configure per-agent model routing from the terminal.
// A thin client over the daemon's /api/routing surface, with parity to the
// model-routing dashboard.

import process from 'node:process'
import { Command } from 'commander'
import { logger } from '../../core/logger.ts'
import type { ModelApi, ModelEntry, ProviderEntry } from '../../core/model-registry.ts'
import { DAEMON_BASE_URL } from '../../core/paths.ts'
import type {
  ChainMember,
  RoutingPolicy,
  RoutingTarget,
} from '../../core/routing-policy.ts'
import {
  effectiveSubagentDowngrade,
  mutateRoutingPolicy,
  readRoutingPolicy,
} from '../../core/routing-policy.ts'
import { promptSecret } from '../util/prompt.ts'

const MODEL_APIS: ModelApi[] = ['anthropic-messages', 'openai-chat', 'openai-responses']

type RegistryResponse = {
  providers: ProviderEntry[]
  models: ModelEntry[]
  secretRefs: string[]
}
type RoutingRoute = {
  routeKey: string
  agent: string
  provider: string
  decoder: string
}
type PolicyResponse = { policy: RoutingPolicy; routes: RoutingRoute[] }

// ── daemon client ─────────────────────────────────────────────────

async function apiFetch<T>(method: string, path: string, body?: unknown): Promise<T> {
  let res: Response
  try {
    res = await fetch(`${DAEMON_BASE_URL}${path}`, {
      method,
      ...(body !== undefined
        ? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
        : {}),
      signal: AbortSignal.timeout(5000),
    })
  } catch {
    throw new Error(
      'cannot reach the OpenThomas daemon — start it with `openthomas daemon` or `openthomas wire`',
    )
  }
  const text = await res.text()
  let json: unknown = {}
  if (text) {
    try {
      json = JSON.parse(text)
    } catch {
      json = {}
    }
  }
  if (!res.ok) {
    const msg =
      json && typeof json === 'object' && 'error' in json
        ? String((json as { error: unknown }).error)
        : `HTTP ${res.status}`
    throw new Error(msg)
  }
  return json as T
}

function fail(message: string): void {
  logger.print(`error: ${message}`)
  process.exitCode = 1
}

async function run(fn: () => Promise<void>): Promise<void> {
  try {
    await fn()
  } catch (e) {
    fail((e as Error).message)
  }
}

function describeTarget(t: RoutingTarget): string {
  if (t.kind === 'chain') {
    if (t.members.length === 1) return `→ model ${t.members[0]!.modelId}`
    return `→ chain ${t.members.map((m) => m.modelId).join(' → ')}`
  }
  return 'passthrough'
}

// A bare model list becomes a clean error-failover chain: every member but
// the last advances on an upstream error; the last is the final fallback.
function chainMembers(modelIds: string[]): ChainMember[] {
  return modelIds.map((modelId, i) => ({
    modelId,
    ...(i < modelIds.length - 1 ? { switchOn: [{ kind: 'error' as const }] } : {}),
  }))
}

function collect(value: string, acc: string[]): string[] {
  return [...acc, value]
}

// ── route list / show / set ───────────────────────────────────────

const listCmd = new Command('list')
  .description('List wire routes and their model routing.')
  .action(() =>
    run(async () => {
      const { policy, routes } = await apiFetch<PolicyResponse>('GET', '/api/routing/policy')
      if (routes.length === 0) {
        logger.print('No wire routes yet. Wire an agent first with `openthomas wire`.')
        return
      }
      for (const route of routes) {
        const routing = policy.agents[route.routeKey]
        const target = routing ? describeTarget(routing.target) : 'passthrough'
        logger.print(`  ${route.routeKey.padEnd(36)} ${target}`)
      }
    }),
  )

const showCmd = new Command('show')
  .description('Show the routing for one <agent>/<provider> route.')
  .argument('<routeKey>', 'route key, e.g. openclaw/anthropic')
  .action((routeKey: string) =>
    run(async () => {
      const { policy, routes } = await apiFetch<PolicyResponse>('GET', '/api/routing/policy')
      const route = routes.find((r) => r.routeKey === routeKey)
      if (!route) return fail(`unknown route "${routeKey}"`)
      const routing = policy.agents[routeKey]
      logger.print(`Route:   ${route.routeKey}`)
      logger.print(`Decoder: ${route.decoder}`)
      logger.print(`Target:  ${routing ? describeTarget(routing.target) : 'passthrough'}`)
    }),
  )

type SetOpts = { model?: string; chain?: string[]; passthrough?: boolean }

const setCmd = new Command('set')
  .description(
    'Route an <agent>/<sourceModel> (or the wildcard <agent>/*) to a model, a failover chain, or passthrough.\n' +
      '  Examples:\n' +
      '    openthomas route set claude-code/* --model glm-4.6\n' +
      '    openthomas route set claude-code/* --chain glm-4.6 --chain deepseek-v4    # error-failover\n' +
      '    openthomas route set claude-code/claude-opus-4-7 --passthrough            # pin Opus back to Anthropic',
  )
  .argument('<routeKey>', 'route key, e.g. claude-code/* or claude-code/claude-sonnet-4-6')
  .option('--model <id>', 'route to a single model (a 1-member chain)')
  .option(
    '--chain <id>',
    'append a model to a failover chain — repeat the flag in failover order. Every member but the last switches on upstream error; for budget/token rules, use the dashboard.',
    collect,
    [] as string[],
  )
  .option('--passthrough', 'restore plain passthrough (the default)')
  .action((routeKey: string, opts: SetOpts) =>
    run(async () => {
      const chainIds = opts.chain ?? []
      const chosen = [
        opts.model,
        chainIds.length > 0 ? '_chain' : undefined,
        opts.passthrough,
      ].filter(Boolean)
      if (chosen.length !== 1) {
        return fail('pass exactly one of --model, --chain, or --passthrough')
      }
      const target: RoutingTarget = opts.model
        ? { kind: 'chain', members: [{ modelId: opts.model }] }
        : chainIds.length > 0
          ? { kind: 'chain', members: chainMembers(chainIds) }
          : { kind: 'passthrough' }
      await apiFetch('POST', '/api/routing/agent', { routeKey, target })
      logger.print(`✓ ${routeKey} ${describeTarget(target)}`)
    }),
  )

const unsetCmd = new Command('unset')
  .description(
    "Remove a per-source-model routing entry. The source model inherits the agent's <agent>/* default again. " +
      'Use `route set <key> --passthrough` instead if you want to pin it to passthrough explicitly.',
  )
  .argument('<routeKey>', 'route key, e.g. claude-code/claude-opus-4-7')
  .action((routeKey: string) =>
    run(async () => {
      await apiFetch(
        'DELETE',
        `/api/routing/agent?routeKey=${encodeURIComponent(routeKey)}`,
      )
      logger.print(`✓ ${routeKey} unset (inherits agent default)`)
    }),
  )

// ── route provider ────────────────────────────────────────────────

type ProviderAddOpts = {
  baseUrl: string
  api: string
  auth: string
  header?: string
  label?: string
  key?: string
}

const providerAdd = new Command('add')
  .description('Register a model provider.')
  .argument('<id>', 'provider id')
  .requiredOption('--base-url <url>', 'provider base URL')
  .requiredOption('--api <api>', `wire format: ${MODEL_APIS.join(' | ')}`)
  .option('--auth <kind>', 'passthrough | bearer | api-key', 'passthrough')
  .option('--header <name>', 'header name for api-key auth, e.g. x-api-key')
  .option('--label <text>', 'display label')
  .option('--key <value>', 'API key (prompted without echo if omitted)')
  .action((id: string, opts: ProviderAddOpts) =>
    run(async () => {
      if (!MODEL_APIS.includes(opts.api as ModelApi)) {
        return fail(`--api must be one of ${MODEL_APIS.join(', ')}`)
      }
      if (!['passthrough', 'bearer', 'api-key'].includes(opts.auth)) {
        return fail('--auth must be passthrough, bearer, or api-key')
      }
      if (opts.auth === 'api-key' && !opts.header) {
        return fail('--header is required for api-key auth')
      }
      let apiKey = opts.key
      if ((opts.auth === 'bearer' || opts.auth === 'api-key') && !apiKey) {
        apiKey = await promptSecret(`API key for ${id}: `)
        if (!apiKey) return fail('an API key is required for this auth kind')
      }
      await apiFetch('POST', '/api/routing/provider', {
        id,
        baseUrl: opts.baseUrl,
        api: opts.api,
        authKind: opts.auth,
        ...(opts.header ? { authHeader: opts.header } : {}),
        ...(opts.label ? { label: opts.label } : {}),
        ...(apiKey ? { apiKey } : {}),
      })
      logger.print(`✓ provider ${id} registered`)
    }),
  )

const providerRm = new Command('rm')
  .description('Remove a provider and its models.')
  .argument('<id>', 'provider id')
  .action((id: string) =>
    run(async () => {
      await apiFetch('DELETE', `/api/routing/provider?id=${encodeURIComponent(id)}`)
      logger.print(`✓ provider ${id} removed`)
    }),
  )

const providerList = new Command('list')
  .description('List registered providers.')
  .action(() =>
    run(async () => {
      const reg = await apiFetch<RegistryResponse>('GET', '/api/routing/registry')
      if (reg.providers.length === 0) {
        logger.print('No providers.')
        return
      }
      for (const p of reg.providers) {
        logger.print(
          `  ${p.id.padEnd(20)} ${p.api.padEnd(20)} ${p.auth.kind.padEnd(12)} ${p.origin.padEnd(8)} ${p.baseUrl}`,
        )
      }
    }),
  )

const providerCmd = new Command('provider')
  .description('Manage model providers.')
  .addCommand(providerAdd)
  .addCommand(providerRm)
  .addCommand(providerList)

// ── route model ───────────────────────────────────────────────────

type ModelAddOpts = {
  provider: string
  api?: string
  vision?: boolean
  costIn?: string
  costOut?: string
  label?: string
}

const modelAdd = new Command('add')
  .description('Register a model.')
  .argument('<id>', 'model id — the value sent in the request `model` field')
  .requiredOption('--provider <id>', 'provider id this model belongs to')
  .option('--api <api>', `override the provider wire format: ${MODEL_APIS.join(' | ')}`)
  .option('--vision', 'the model can see images')
  .option('--cost-in <usd>', 'cost per million input tokens')
  .option('--cost-out <usd>', 'cost per million output tokens')
  .option('--label <text>', 'display label')
  .action((id: string, opts: ModelAddOpts) =>
    run(async () => {
      if (opts.api && !MODEL_APIS.includes(opts.api as ModelApi)) {
        return fail(`--api must be one of ${MODEL_APIS.join(', ')}`)
      }
      const costIn = opts.costIn != null ? Number(opts.costIn) : undefined
      const costOut = opts.costOut != null ? Number(opts.costOut) : undefined
      const hasCost =
        costIn != null &&
        costOut != null &&
        Number.isFinite(costIn) &&
        Number.isFinite(costOut)
      await apiFetch('POST', '/api/routing/model', {
        id,
        providerId: opts.provider,
        ...(opts.api ? { api: opts.api } : {}),
        input: opts.vision ? ['text', 'image'] : ['text'],
        ...(hasCost ? { cost: { input: costIn, output: costOut } } : {}),
        ...(opts.label ? { label: opts.label } : {}),
      })
      logger.print(`✓ model ${id} registered`)
    }),
  )

const modelRm = new Command('rm')
  .description('Remove a model.')
  .argument('<id>', 'model id')
  .action((id: string) =>
    run(async () => {
      await apiFetch('DELETE', `/api/routing/model?id=${encodeURIComponent(id)}`)
      logger.print(`✓ model ${id} removed`)
    }),
  )

const modelList = new Command('list')
  .description('List registered models.')
  .action(() =>
    run(async () => {
      const reg = await apiFetch<RegistryResponse>('GET', '/api/routing/registry')
      if (reg.models.length === 0) {
        logger.print('No models.')
        return
      }
      for (const m of reg.models) {
        const flags = m.input.includes('image') ? 'vision' : ''
        logger.print(
          `  ${m.id.padEnd(28)} ${m.providerId.padEnd(20)} ${m.origin.padEnd(8)} ${flags}`,
        )
      }
    }),
  )

const modelCmd = new Command('model')
  .description('Manage models.')
  .addCommand(modelAdd)
  .addCommand(modelRm)
  .addCommand(modelList)

// ── route secret ──────────────────────────────────────────────────

const secretSet = new Command('set')
  .description('Store a secret value under a ref (prompted, no echo).')
  .argument('<ref>', 'secret ref, e.g. provider:my-provider')
  .action((ref: string) =>
    run(async () => {
      const value = await promptSecret(`Value for ${ref}: `)
      if (!value) return fail('no value entered')
      await apiFetch('POST', '/api/routing/secret', { ref, value })
      logger.print(`✓ secret ${ref} stored`)
    }),
  )

const secretRm = new Command('rm')
  .description('Remove a secret.')
  .argument('<ref>', 'secret ref')
  .action((ref: string) =>
    run(async () => {
      await apiFetch('DELETE', `/api/routing/secret?ref=${encodeURIComponent(ref)}`)
      logger.print(`✓ secret ${ref} removed`)
    }),
  )

const secretList = new Command('list')
  .description('List the refs of stored secrets — values are never shown.')
  .action(() =>
    run(async () => {
      const reg = await apiFetch<RegistryResponse>('GET', '/api/routing/registry')
      if (reg.secretRefs.length === 0) {
        logger.print('No secrets.')
        return
      }
      for (const ref of reg.secretRefs) logger.print(`  ${ref}`)
    }),
  )

const secretCmd = new Command('secret')
  .description('Manage API-key secrets (write-only — values never leave the daemon).')
  .addCommand(secretSet)
  .addCommand(secretRm)
  .addCommand(secretList)

// ── route subagent (the Claude Code cost-saver) ───────────────────

const subagentCmd = new Command('subagent')
  .description(
    'The Claude Code subagent cost-saver: route dynamic-workflow subagents to a ' +
      'cheaper model while the planner stays on its model (default on).',
  )
  .option('--on', 'enable the cost-saver')
  .option('--off', 'disable it — every subagent runs on its requested model')
  .option('--model <id>', 'the cheaper model subagent calls are routed to')
  .option('--floor <maxTokens>', 'skip utility calls below this max_tokens')
  .action((opts: { on?: boolean; off?: boolean; model?: string; floor?: string }) =>
    run(async () => {
      const cur = effectiveSubagentDowngrade(await readRoutingPolicy())
      const wantsChange = opts.on || opts.off || opts.model != null || opts.floor != null
      if (!wantsChange) {
        logger.print('Claude Code subagent cost-saver')
        logger.print(`  status : ${cur.enabled ? 'ON' : 'off'}`)
        logger.print(`  target : ${cur.modelId}`)
        logger.print(`  floor  : skip calls under ${cur.minMaxTokens} max_tokens`)
        logger.print('')
        logger.print('  Planner calls (those carrying the Agent tool) are never downgraded.')
        logger.print('  openthomas route subagent --off          disable')
        logger.print('  openthomas route subagent --model <id>   retarget')
        return
      }
      if (opts.on && opts.off) throw new Error('pass only one of --on / --off')
      const next = { ...cur }
      if (opts.on) next.enabled = true
      if (opts.off) next.enabled = false
      if (opts.model != null) {
        if (opts.model === '') throw new Error('--model needs a model id')
        next.modelId = opts.model
      }
      if (opts.floor != null) {
        const n = Number.parseInt(opts.floor, 10)
        if (!Number.isFinite(n) || n < 0) {
          throw new Error('--floor needs a non-negative integer')
        }
        next.minMaxTokens = n
      }
      await mutateRoutingPolicy((p) => ({ ...p, subagentDowngrade: next }))
      logger.print(
        `subagent cost-saver ${next.enabled ? 'ON' : 'off'} → ${next.modelId} (floor ${next.minMaxTokens})`,
      )
    }),
  )

// ── route ─────────────────────────────────────────────────────────

export const routeCommand = new Command('route')
  .description('Configure per-agent model routing, failover chains, and the subagent cost-saver.')
  .addCommand(subagentCmd)
  .addCommand(listCmd)
  .addCommand(showCmd)
  .addCommand(setCmd)
  .addCommand(unsetCmd)
  .addCommand(providerCmd)
  .addCommand(modelCmd)
  .addCommand(secretCmd)
