// /api/pricing — list known models (recent traffic + bundled catalog +
// user overrides) and CRUD user overrides at ~/.thomas/pricing.json.
//
// Display convention: USD per MILLION tokens (the models.dev convention,
// readable by humans). On disk we keep the legacy USD-per-token shape so
// older pricing.json files keep working.

import { readFile, stat, writeFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import type { Context } from 'hono'
import { paths, PRICING_OVERRIDE } from '../../core/paths.ts'
import { CATALOG } from '../cost/catalog.ts'
import { pricingFor } from '../cost/pricing.ts'
import type { DecoderKind } from '../../core/routes.ts'
import { recentModels } from '../store/models-queries.ts'

const ONE_DAY = 24 * 3_600_000

const VALID_SCOPES = new Set<DecoderKind | 'any'>([
  'any',
  'anthropic',
  'openai-chat',
  'openai-responses',
  'gemini',
  'bedrock',
  'mcp',
  'passthrough',
])

// ── GET /api/pricing ──────────────────────────────────────────────

// Stale-while-revalidate cache. The "recent" section aggregates over
// last-30d model_calls, which is index-backed since we added
// actions.model but still takes a few seconds on a busy DB. The
// keep-warm loop in daemon/index.ts refreshes this in the background
// so the Models page is instant after the first cold compute.
import { WarmCache } from '../cache/keep-warm.ts'
export const pricingCache = new WarmCache<number, unknown>('pricing')
pricingCache.register(30, () => buildPricingResponse(30))

export async function handleGetPricing(c: Context): Promise<Response> {
  const sinceRaw = c.req.query('since')
  const sinceDays = sinceRaw ? Number.parseInt(sinceRaw, 10) : 30
  const days = Number.isFinite(sinceDays) ? sinceDays : 30

  // Most callers (the Models page) use the default 30d window — that's
  // what the keep-warm loop refreshes. For other windows, fall through
  // to a one-off compute (no caching) — they're rare.
  if (days !== 30 || c.req.query('nocache') === '1') {
    return c.json(await buildPricingResponse(days))
  }
  return c.json(await pricingCache.get(30))
}

async function buildPricingResponse(sinceDays: number): Promise<unknown> {
  const sinceMs = Date.now() - sinceDays * ONE_DAY

  const recent = (await recentModels(sinceMs)).map((r) => {
    const decoder = agentToDecoder(r.agent)
    const live = pricingFor(decoder, r.model)
    // why: the stored cost_usd is what we captured at request time with
    // the pricing that existed then. When a user adds a custom price for
    // a previously-unpriced model, they expect the Models page to reflect
    // that immediately — so recompute using the CURRENT pricing and
    // surface both numbers. costUsd here is the live-recomputed total;
    // capturedCostUsd is the historical bucket sum for transparency.
    const liveCost = live
      ? computeLiveCost(
          live,
          r.tokensIn,
          r.tokensOut,
          r.cacheReadTokens,
          r.cacheWriteTokens,
        )
      : 0
    return {
      agent: r.agent,
      model: r.model,
      uses: r.uses,
      errorCount: r.errorCount,
      tokensIn: r.tokensIn,
      tokensOut: r.tokensOut,
      cacheReadTokens: r.cacheReadTokens,
      cacheWriteTokens: r.cacheWriteTokens,
      costUsd: liveCost,
      lastUsed: r.lastUsed,
      pricing: pricingDisplay(decoder, r.model),
    }
  })

  const catalog = CATALOG.map((p) => ({
    provider: p.id,
    providerName: p.name,
    models: p.models.map((m) => ({
      id: m.id,
      family: m.family,
      cost: m.cost,
    })),
  }))

  const custom = await readCustomPricing()
  return { recent, catalog, custom }
}

/** Invalidate the /api/pricing cache. Called when the user edits the
 *  custom pricing so the next read shows their change immediately. */
function invalidatePricingCache(): void {
  pricingCache.invalidate()
}

// ── POST /api/pricing/custom ──────────────────────────────────────

type PostBody = {
  scope?: string
  model?: string
  // USD per million tokens (display convention).
  input?: number
  output?: number
  cacheRead?: number
  cacheWrite?: number
}

export async function handlePostPricing(c: Context): Promise<Response> {
  let body: PostBody
  try {
    body = (await c.req.json()) as PostBody
  } catch {
    return c.json({ error: 'malformed JSON body' }, 400)
  }

  const scope = (body.scope ?? 'any') as DecoderKind | 'any'
  if (!VALID_SCOPES.has(scope)) {
    return c.json({ error: `invalid scope: ${scope}` }, 400)
  }
  if (typeof body.model !== 'string' || body.model.trim() === '') {
    return c.json({ error: 'missing model name' }, 400)
  }
  if (typeof body.input !== 'number' || body.input < 0) {
    return c.json({ error: 'input must be a non-negative number (USD per million tokens)' }, 400)
  }
  if (typeof body.output !== 'number' || body.output < 0) {
    return c.json({ error: 'output must be a non-negative number (USD per million tokens)' }, 400)
  }

  const current = await readCustomPricing()
  const scoped = current[scope] ?? {}
  scoped[body.model] = {
    inputCostPerToken: body.input / 1_000_000,
    outputCostPerToken: body.output / 1_000_000,
    ...(typeof body.cacheRead === 'number' && body.cacheRead >= 0
      ? { cacheReadCostPerToken: body.cacheRead / 1_000_000 }
      : {}),
    ...(typeof body.cacheWrite === 'number' && body.cacheWrite >= 0
      ? { cacheWriteCostPerToken: body.cacheWrite / 1_000_000 }
      : {}),
  }
  current[scope] = scoped

  await writeCustomPricing(current)
  invalidatePricingCache()
  return c.json({ ok: true, scope, model: body.model })
}

// ── DELETE /api/pricing/custom?scope=any&model=foo ────────────────

export async function handleDeletePricing(c: Context): Promise<Response> {
  const scope = (c.req.query('scope') ?? 'any') as DecoderKind | 'any'
  const model = c.req.query('model')
  if (!VALID_SCOPES.has(scope)) {
    return c.json({ error: `invalid scope: ${scope}` }, 400)
  }
  if (!model) return c.json({ error: 'missing model' }, 400)

  const current = await readCustomPricing()
  const scoped = current[scope]
  if (scoped && model in scoped) {
    delete scoped[model]
    if (Object.keys(scoped).length === 0) delete current[scope]
    await writeCustomPricing(current)
    invalidatePricingCache()
  }
  return c.json({ ok: true })
}

// ── helpers ───────────────────────────────────────────────────────

type StoredEntry = {
  inputCostPerToken: number
  outputCostPerToken: number
  cacheReadCostPerToken?: number
  cacheWriteCostPerToken?: number
}
type StoredPricing = Partial<Record<DecoderKind | 'any', Record<string, StoredEntry>>>

async function readCustomPricing(): Promise<StoredPricing> {
  try {
    await stat(PRICING_OVERRIDE)
    const raw = await readFile(PRICING_OVERRIDE, 'utf8')
    return JSON.parse(raw) as StoredPricing
  } catch {
    return {}
  }
}

async function writeCustomPricing(data: StoredPricing): Promise<void> {
  await mkdir(dirname(PRICING_OVERRIDE), { recursive: true })
  // why: pretty-printed so the user can also edit the file by hand.
  await writeFile(PRICING_OVERRIDE, `${JSON.stringify(data, null, 2)}\n`)
  // Touch path so the file mtime advances (mkdir doesn't update mtime if
  // the dir already existed). writeFile already does that.
  void paths
}

function computeLiveCost(
  p: NonNullable<ReturnType<typeof pricingFor>>,
  tokensIn: number,
  tokensOut: number,
  cacheReadTokens: number,
  cacheWriteTokens: number,
): number {
  // why: storage is normalized — tokensIn is always fresh input only,
  // never includes cache_read/cache_write (decoders subtract at write).
  const cacheReadRate = p.cacheReadCostPerToken ?? p.inputCostPerToken
  const cacheWriteRate = p.cacheWriteCostPerToken ?? p.inputCostPerToken
  return (
    tokensIn * p.inputCostPerToken +
    tokensOut * p.outputCostPerToken +
    cacheReadTokens * cacheReadRate +
    cacheWriteTokens * cacheWriteRate
  )
}

function pricingDisplay(
  decoder: DecoderKind,
  model: string,
): {
  input: number
  output: number
  cacheRead?: number
  cacheWrite?: number
  resolved: boolean
} | null {
  const p = pricingFor(decoder, model)
  if (!p) return { input: 0, output: 0, resolved: false }
  return {
    input: p.inputCostPerToken * 1_000_000,
    output: p.outputCostPerToken * 1_000_000,
    ...(p.cacheReadCostPerToken != null
      ? { cacheRead: p.cacheReadCostPerToken * 1_000_000 }
      : {}),
    ...(p.cacheWriteCostPerToken != null
      ? { cacheWrite: p.cacheWriteCostPerToken * 1_000_000 }
      : {}),
    resolved: true,
  }
}

function agentToDecoder(agent: string): DecoderKind {
  // Best-effort hint for the recent-models pricing lookup; we don't have
  // the per-row decoder stored on actions, so infer from agent id.
  if (agent === 'claude-code') return 'anthropic'
  if (agent === 'codex') return 'openai-responses'
  return 'openai-chat'
}
