/**
 * Main-process model bridge — Reginald's brain runs here. Model traffic (like
 * gitea's) stays in main: the renderer is CSP-locked and never talks to the
 * LLM directly. On `model:chat` it drives the agent loop against the configured
 * OpenAI-compatible endpoint, executing `query_project` by reconciling the repo
 * and building the requested view. v0 is read-only — writes still go through the
 * propose-approve controls.
 */

import {
  buildProjectView,
  captureWork,
  type ChangeProposal,
  type ChatMessage,
  createChatClient,
  describeChange,
  type ModelRouter,
  type ProjectView,
  proposalsFor,
  type ProposeChangeArgs,
  type QueryFilters,
  REGINALD_SYSTEM,
  REGINALD_TOOLS,
  runAgentTurn,
  toDirectiveInput,
} from '@commitea/core'
import { ipcMain } from 'electron'

import {
  AGENT_SNAPSHOT_TTL_MS,
  appendDirectiveEntry,
  getGiteaClient,
  getPmStateClient,
  getSnapshot,
} from './gitea.js'

/** Small local model for prose + the read tool; big model reserved for later decomposition. */
function resolveModelRouter(): ModelRouter | null {
  if (process.env.COMMITEA_E2E === '1') return null // e2e uses the scripted fixture Reginald
  const baseUrl = process.env.COMMITEA_MODEL_URL ?? 'http://localhost:1234/v1'
  if (!baseUrl) return null
  return {
    small: { baseUrl, model: process.env.COMMITEA_MODEL_SMALL ?? '' },
    big: { baseUrl, model: process.env.COMMITEA_MODEL_BIG ?? '' },
  }
}

/**
 * Resolve which model to actually ask for. An explicit env override wins;
 * otherwise ask the server which model is *loaded* (LM Studio's native
 * `/api/v0/models`) so Reginald follows whatever you load — no config churn on a
 * model switch. Falls back to the first non-embedding model, then a sane default.
 */
async function resolveLoadedModel(baseUrl: string, override: string): Promise<string> {
  if (override) return override
  const root = baseUrl.replace(/\/v1\/?$/, '')
  try {
    const res = await fetch(`${root}/api/v0/models`)
    if (res.ok) {
      const data = (await res.json()) as { data?: { id: string; state?: string; type?: string }[] }
      const loaded = (data.data ?? []).find((m) => m.state === 'loaded' && m.type !== 'embeddings')
      if (loaded) return loaded.id
    }
  } catch {
    // native API unavailable — fall through to the OpenAI listing
  }
  try {
    const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`)
    if (res.ok) {
      const data = (await res.json()) as { data?: { id: string }[] }
      const first = (data.data ?? []).find((m) => !/embed/i.test(m.id))
      if (first) return first.id
    }
  } catch {
    // ignore — use the default
  }
  return 'google/gemma-4-e4b'
}

export function registerModelIpc(): void {
  const router = resolveModelRouter()

  ipcMain.handle('model:status', async () => {
    if (!router) return { configured: false, model: null }
    const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
    return { configured: true, model }
  })

  ipcMain.handle('model:chat', async (_event, messages: ChatMessage[]) => {
    if (!router) return { ok: false as const, reason: 'unconfigured' as const }
    const client = getGiteaClient()
    const model = await resolveLoadedModel(router.small.baseUrl, router.small.model)
    const chat = createChatClient({ ...router.small, model }, fetch)

    // Proposals the model formulates this turn; the renderer approves them (the
    // write happens through gitea:applyChange, never inside the loop).
    const proposals: ChangeProposal[] = []

    const execute = async (name: string, args: unknown) => {
      if (!client) return { error: 'gitea is not configured' }
      if (name === 'query_project') {
        // reuse a recent reconcile — a multi-tool turn shouldn't refetch the repo each call
        const snap = await getSnapshot(client, { maxAgeMs: AGENT_SNAPSHOT_TTL_MS })
        const a = (args ?? {}) as { view: ProjectView; filters?: QueryFilters }
        return buildProjectView(a.view, a.filters, snap, new Date())
      }
      if (name === 'propose_change') {
        const a = (args ?? {}) as ProposeChangeArgs
        const issue = await client.getIssue(a.issue).catch(() => null)
        if (!issue) return { error: `issue #${a.issue} not found` }
        const built = proposalsFor(a, issue.labels, issue.title)
        proposals.push(...built)
        return built.length
          ? { proposed: built.map((p) => ({ issue: a.issue, diff: describeChange(p.plan) })) }
          : { proposed: [], note: 'no change — already at that value' }
      }
      if (name === 'record_directive') {
        const pm = getPmStateClient()
        if (!pm) return { error: 'pm-state is not configured' }
        try {
          const entry = await appendDirectiveEntry(pm, toDirectiveInput(args))
          return { recorded: { kind: entry.kind, quote: entry.quote } }
        } catch (e) {
          return { error: `could not record — is the pm-state repo created? (${e instanceof Error ? e.message : e})` }
        }
      }
      return { error: `unknown tool: ${name}` }
    }

    try {
      const turn = await runAgentTurn({
        complete: (m, t) => chat.complete(m, t),
        messages: [{ role: 'system', content: REGINALD_SYSTEM }, ...messages],
        tools: REGINALD_TOOLS,
        execute,
      })
      return { ok: true as const, content: turn.content, steps: turn.steps, proposals }
    } catch (e) {
      return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
    }
  })

  // capture_work — braindump → proposed issue set. The big model does the
  // decomposition (with one loaded local model, that's the loaded one). Returns
  // a proposal; nothing is filed until the Capture tray approves it.
  ipcMain.handle('model:capture', async (_event, braindump: string) => {
    if (!router) return { ok: false as const, reason: 'unconfigured' as const }
    const model = await resolveLoadedModel(router.big.baseUrl, router.big.model)
    const chat = createChatClient({ ...router.big, model }, fetch)
    try {
      const proposal = await captureWork((m, t) => chat.complete(m, t), braindump)
      return { ok: true as const, ...proposal }
    } catch (e) {
      return { ok: false as const, reason: 'error' as const, message: e instanceof Error ? e.message : String(e) }
    }
  })
}
