import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react"
import { createPortal } from "react-dom"
import {
  Archive,
  ArrowRight,
  BookmarkPlus,
  ChevronDown,
  Command as CommandIcon,
  Copy,
  Download,
  FileText,
  Loader2,
  Mic,
  MicOff,
  Plus,
  RefreshCw,
  RotateCcw,
  Sparkles,
  Square,
  Trash2,
  Undo2,
  X,
} from "lucide-react"

import {
  LLMProvider,
  MockLLM,
  listModels,
  useChat,
  useCompletion,
  type LLMAdapter,
} from "@crema/llm-ui"
import {
  buildAdapter,
  getProvider,
  useSettings as useProviderSettings,
} from "@crema/llm-providers-ui"
import { TypingIndicator } from "@crema/chat-ui"
import { useToast } from "@crema/notification-ui"

import { AppShell } from "~/components/layout/app-shell"
import { MessageBody } from "~/components/assistant/message-body"
import { Button } from "~/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "~/components/ui/popover"
import {
  loadActiveAgentId,
  saveActiveAgentId,
  useAgents,
  type Agent,
} from "~/lib/agents"
import { addLibraryItem } from "~/lib/library"
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
import { pageTitle } from "~/lib/page-meta"
import { useArcadiaClient } from "@crema/arcadia-client"
import type { Message as LLMMessage, ToolCall } from "@crema/llm-ui"
import type { DocHit } from "~/lib/docs-search"
import {
  AgentAvatar,
  ToolCallCard,
  type ToolCall as AgentToolCall,
  type ToolCallStatus,
} from "@crema/agent-ui"
import {
  buildDenialMessages,
  classifyCalls,
  getOpenAITools,
  runLLMToolCalls,
} from "~/lib/admin-tools"
import { ARCADIA_KNOWLEDGE } from "~/lib/arcadia-knowledge"
import {
  loadActiveReasoning,
  saveActiveReasoning,
  subscribeActiveReasoning,
  type ReasoningEffort,
} from "~/lib/arcadia/llm-configs"
import { formatAdminContextForPrompt } from "~/lib/admin-context"
import { ConfirmCard } from "~/components/assistant/confirm-card"
import { renderToolResult } from "~/components/assistant/tool-result-renderers"

function ToolResultBlock({ name, result }: { name: string; result: unknown }) {
  const rich = renderToolResult(name, result)
  if (!rich) return null
  return <div className="px-1">{rich}</div>
}

// Synthetic assistant message that exercises every typed rich-output block.
// Wired to the "preview rich-output blocks" button in the empty state — used
// to eyeball renderer + theme without driving a live model. Safe to delete
// once Phase 2 has been validated end-to-end.
const BLOCK_SAMPLES_CONTENT = `Here's one example of every rich-output block, in roughly the order a model would emit them.

A **kpi** strip for headline numbers:

\`\`\`kpi
{ "items": [
    { "label": "Tenants", "value": 42 },
    { "label": "Active users", "value": 318, "unit": "/day" },
    { "label": "Suspended", "value": 4 },
    { "label": "Storage", "value": "1.2", "unit": "TB" }
  ] }
\`\`\`

A **table** for tabular data:

\`\`\`table
{ "columns": [
    { "id": "slug", "header": "Tenant" },
    { "id": "users", "header": "Users", "align": "right" },
    { "id": "status", "header": "Status" }
  ],
  "rows": [
    { "slug": "acme", "users": 42, "status": "active" },
    { "slug": "globex", "users": 18, "status": "suspended" },
    { "slug": "initech", "users": 73, "status": "active" }
  ],
  "idKey": "slug" }
\`\`\`

A **chart-bar** for category comparison and a **chart-line** for a trend:

\`\`\`chart-bar
{ "title": "Users by tenant",
  "data": [
    { "label": "acme", "value": 42 },
    { "label": "globex", "value": 18 },
    { "label": "initech", "value": 73 },
    { "label": "umbrella", "value": 11 }
  ] }
\`\`\`

\`\`\`chart-line
{ "title": "Signups over time",
  "series": [
    { "x": 1, "y": 12 }, { "x": 2, "y": 19 }, { "x": 3, "y": 24 },
    { "x": 4, "y": 31 }, { "x": 5, "y": 28 }, { "x": 6, "y": 42 }
  ] }
\`\`\`

A **chart-donut** for part-to-whole and a **chart-spark** inline:

\`\`\`chart-donut
{ "title": "Status breakdown",
  "data": [
    { "label": "active", "value": 38 },
    { "label": "suspended", "value": 4 },
    { "label": "deactivated", "value": 2 }
  ] }
\`\`\`

\`\`\`chart-spark
{ "values": [3, 5, 4, 8, 12, 9, 14, 11, 18, 16, 22] }
\`\`\`

A **code** block and a **diff**:

\`\`\`code
{ "code": "SELECT slug, count(*) AS users\\nFROM tenants t\\nJOIN users u ON u.tenant_id = t.id\\nWHERE t.status = 'active'\\nGROUP BY slug\\nORDER BY users DESC;",
  "language": "sql",
  "title": "Active tenants by user count",
  "lineNumbers": true }
\`\`\`

\`\`\`diff
{ "oldCode": "max_users: 100\\nplan: free\\n",
  "newCode": "max_users: 250\\nplan: pro\\n",
  "language": "yaml",
  "title": "Tenant quota change",
  "mode": "unified" }
\`\`\`

A **flowchart** for control flow and an **orgchart** for hierarchy:

\`\`\`flowchart
{ "nodes": [
    { "id": "a", "type": "start",    "label": "Receive request", "x": 80,  "y": 20 },
    { "id": "b", "type": "process",  "label": "Validate token",  "x": 80,  "y": 110 },
    { "id": "c", "type": "decision", "label": "Token valid?",    "x": 80,  "y": 200 },
    { "id": "d", "type": "process",  "label": "Process",         "x": 260, "y": 200 },
    { "id": "e", "type": "end",      "label": "Reject (401)",    "x": 80,  "y": 310 }
  ],
  "edges": [
    { "from": "a", "to": "b" },
    { "from": "b", "to": "c" },
    { "from": "c", "to": "d", "label": "yes" },
    { "from": "c", "to": "e", "label": "no" }
  ] }
\`\`\`

\`\`\`orgchart
{ "data": {
    "id": "root", "name": "Platform", "title": "Tenant",
    "children": [
      { "id": "a", "name": "Auth", "title": "Service",
        "children": [
          { "id": "a1", "name": "Sessions", "title": "Module" },
          { "id": "a2", "name": "MFA", "title": "Module" }
        ] },
      { "id": "b", "name": "Billing", "title": "Service",
        "children": [
          { "id": "b1", "name": "Invoices", "title": "Module" }
        ] }
    ] } }
\`\`\`

A **steps** trail for a multi-step plan:

\`\`\`steps
{ "steps": [
    { "id": "1", "title": "List tenants",    "status": "done",    "detail": "Found 42 tenants" },
    { "id": "2", "title": "Filter suspended", "status": "running" },
    { "id": "3", "title": "Build report",    "status": "queued" },
    { "id": "4", "title": "Email summary",   "status": "queued" }
  ] }
\`\`\`

A **welcome** hero, a **checklist**, and a **hint**:

\`\`\`welcome
{ "title": "Welcome to Arcadia Admin",
  "description": "Manage tenants, users, and platform settings from one place.",
  "badge": "v2",
  "primaryAction":   { "label": "Create your first tenant", "href": "/tenants" },
  "secondaryAction": { "label": "Read the docs",            "href": "/library" } }
\`\`\`

\`\`\`checklist
{ "title": "Get started",
  "description": "Finish setting up your tenant.",
  "tasks": [
    { "id": "1", "title": "Invite your team",         "description": "Add at least one admin.", "completed": true,  "estimate": "2 min" },
    { "id": "2", "title": "Connect a storage bucket", "completed": false, "href": "/buckets",   "estimate": "5 min" },
    { "id": "3", "title": "Set up SSO",               "completed": false, "optional": true, "href": "/sso", "estimate": "10 min" }
  ] }
\`\`\`

\`\`\`hint
{ "title": "Tip", "tone": "info", "body": "Suspending a tenant blocks login but preserves data — use deactivate to permanently disable.", "action": { "label": "See suspension docs", "href": "/library?q=suspend" } }
\`\`\`

And the legacy **card** kinds — pill, stat, callout:

\`\`\`card
{ "kind": "pill", "status": "active", "label": "active" }
\`\`\`

\`\`\`card
{ "kind": "stat", "label": "MRR", "value": "$12.4k" }
\`\`\`

\`\`\`card
{ "kind": "callout", "tone": "warning", "title": "Heads up", "body": "Suspending a tenant blocks all of its users immediately." }
\`\`\`

Clear the conversation to dismiss the preview.`

const SNAPSHOT_KEY = "crema.ai.snapshot"
// Separate key for the live conversation that survives navigation. The
// compact snapshot is reserved for the user-triggered Compact/Restore flow.
const LIVE_KEY = "crema.ai.live"

function loadLive(): LLMMessage[] | null {
  if (typeof window === "undefined") return null
  try {
    const raw = localStorage.getItem(LIVE_KEY)
    if (!raw) return null
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed)) return parsed as LLMMessage[]
  } catch {}
  return null
}
function saveLive(msgs: LLMMessage[]) {
  if (typeof window === "undefined") return
  if (msgs.length === 0) {
    localStorage.removeItem(LIVE_KEY)
    return
  }
  try {
    localStorage.setItem(LIVE_KEY, JSON.stringify(msgs))
  } catch {
    // Quota exceeded or similar — silently drop persistence.
  }
}
/* Per-message agent attribution + the set of agents that have produced
 * turns in the current conversation. Persisted alongside LIVE_KEY so a
 * reload mid-thread preserves both who-said-what and the hand-off note
 * the next turn carries.
 *
 * Stored as plain JSON shapes (Maps don't serialize):
 *   AGENTS_KEY:           Array<[agentId, Agent]>      ← agentHistory
 *   MSG_AGENTS_KEY:       Array<[index, Agent]>        ← messageAgents
 */
const AGENTS_KEY = "crema.ai.agent-history"
const MSG_AGENTS_KEY = "crema.ai.message-agents"

function loadAgentHistory(): Map<string, Agent> {
  if (typeof window === "undefined") return new Map()
  try {
    const raw = localStorage.getItem(AGENTS_KEY)
    if (!raw) return new Map()
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][])
  } catch {}
  return new Map()
}
function saveAgentHistory(m: Map<string, Agent>) {
  if (typeof window === "undefined") return
  if (m.size === 0) {
    localStorage.removeItem(AGENTS_KEY)
    return
  }
  try {
    localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()]))
  } catch {}
}

function loadMessageAgents(): Map<number, Agent> {
  if (typeof window === "undefined") return new Map()
  try {
    const raw = localStorage.getItem(MSG_AGENTS_KEY)
    if (!raw) return new Map()
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][])
  } catch {}
  return new Map()
}
function saveMessageAgents(m: Map<number, Agent>) {
  if (typeof window === "undefined") return
  if (m.size === 0) {
    localStorage.removeItem(MSG_AGENTS_KEY)
    return
  }
  try {
    localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()]))
  } catch {}
}
function clearAgentMaps() {
  if (typeof window === "undefined") return
  localStorage.removeItem(AGENTS_KEY)
  localStorage.removeItem(MSG_AGENTS_KEY)
}

function clearLive() {
  if (typeof window === "undefined") return
  localStorage.removeItem(LIVE_KEY)
}

/* Per-conversation reasoning override. Cycle order matters — the composer
 * chip walks this array. Storage helpers (load/save/subscribe) live in
 * lib/arcadia/llm-configs.ts so the settings panel and the /ai composer
 * coordinate via the same crema.ai.reasoning key. */
const REASONING_LEVELS: ReasoningEffort[] = ["off", "low", "medium", "high", "max"]

function withReasoning<T extends Record<string, unknown>>(
  extras: T,
  effort: ReasoningEffort,
): T & { reasoning_effort?: string } {
  if (effort === "off") return extras
  return { ...extras, reasoning_effort: effort }
}

type StoredMessage = { role: "user" | "assistant"; content: string }
function loadAISnapshot(): StoredMessage[] | null {
  if (typeof window === "undefined") return null
  try {
    const raw = localStorage.getItem(SNAPSHOT_KEY)
    if (!raw) return null
    const parsed = JSON.parse(raw)
    if (Array.isArray(parsed)) return parsed as StoredMessage[]
  } catch {}
  return null
}
function saveAISnapshot(msgs: StoredMessage[]) {
  if (typeof window === "undefined") return
  localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(msgs))
}
function clearAISnapshot() {
  if (typeof window === "undefined") return
  localStorage.removeItem(SNAPSHOT_KEY)
}

export const meta = () => pageTitle("AI")

const MODEL_KEY = "crema.ai.model"
const PROBE_TIMEOUT_MS = 3000

type Status =
  | { kind: "probing" }
  | { kind: "live"; models: string[] }
  | { kind: "mock"; reason: string }

const mockAdapter = new MockLLM({
  label: "Mock",
  delayMs: 18,
  fallback:
    "I'm a stand-in for the local model. Start LM Studio at localhost:1234 and reload to swap me out.",
  responses: [],
})

function withTimeout<T>(p: Promise<T>, ms: number, signal: AbortSignal) {
  return new Promise<T>((resolve, reject) => {
    const t = setTimeout(() => reject(new Error("timeout")), ms)
    signal.addEventListener("abort", () => {
      clearTimeout(t)
      reject(new DOMException("Aborted", "AbortError"))
    })
    p.then(
      (v) => {
        clearTimeout(t)
        resolve(v)
      },
      (e) => {
        clearTimeout(t)
        reject(e)
      },
    )
  })
}

export default function AIRoute() {
  const settings = useProviderSettings()
  const arcadia = useArcadiaClient()
  const provider = getProvider(settings.providerId)
  const agents = useAgents()
  const [status, setStatus] = useState<Status>({ kind: "probing" })
  const [model, setModel] = useState<string>(() => {
    if (typeof window === "undefined") return ""
    return localStorage.getItem(MODEL_KEY) ?? ""
  })
  const [adapter, setAdapter] = useState<LLMAdapter>(mockAdapter)
  const [activeAgentId, setActiveAgentIdState] = useState<string>(() =>
    loadActiveAgentId(),
  )
  const setActiveAgentId = useCallback((id: string) => {
    saveActiveAgentId(id)
    setActiveAgentIdState(id)
  }, [])
  const activeAgent =
    agents.find((a) => a.id === activeAgentId) ?? agents[0]

  // When the user changes provider/model in Settings, follow along.
  useEffect(() => {
    if (settings.model) setModel(settings.model)
  }, [settings.providerId, settings.model])

  // Resolve the API key from the vault (direct mode) or build the proxy
  // adapter (proxy mode), then refresh the model list.
  const probe = useCallback(() => {
    const ac = new AbortController()
    setStatus({ kind: "probing" })

    const resolveSecret = async (name: string): Promise<string> => {
      const res = await arcadia.GET<{ data: { value: string } }>(
        `/api/v1/secrets/${encodeURIComponent(name)}`,
      )
      return res.data.value
    }

    const arcadiaBaseURL =
      (import.meta.env.VITE_ARCADIA_URL as string | undefined) ?? "http://localhost:4000"
    const arcadiaTenantId =
      (import.meta.env.VITE_ARCADIA_TENANT as string | undefined) ?? "default"
    const arcadiaAuthToken =
      typeof window !== "undefined"
        ? sessionStorage.getItem("arcadia_access_token") ?? undefined
        : undefined
    ;(async () => {
      // Build the adapter first so chat works even if the model probe fails.
      try {
        const a = await buildAdapter({
          settings,
          resolveSecret,
          arcadiaBaseURL,
          arcadiaAuthToken,
          arcadiaTenantId,
        })
        setAdapter(a)
      } catch {
        setAdapter(mockAdapter)
      }

      // Probe for a live model list. Anthropic has no /models endpoint, so
      // fall back to the provider catalog's default models.
      if (provider.transport === "anthropic") {
        const ids = provider.defaultModels.length
          ? provider.defaultModels
          : ["claude-opus-4-7"]
        setStatus({ kind: "live", models: ids })
        setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
        return
      }

      const baseURL = settings.baseURL || provider.baseURL
      let apiKey: string | undefined
      if (provider.requiresKey && settings.secretName) {
        try {
          apiKey = await resolveSecret(settings.secretName)
        } catch {
          // Fall through; listModels may still work for some providers without a key.
        }
      }

      try {
        const rows = await withTimeout(
          listModels({ baseURL, apiKey, signal: ac.signal }),
          PROBE_TIMEOUT_MS,
          ac.signal,
        )
        const ids = rows.map((m) => m.id)
        if (ids.length === 0) {
          setStatus({ kind: "mock", reason: "endpoint returned no models" })
          return
        }
        setStatus({ kind: "live", models: ids })
        setModel((cur) => (cur && ids.includes(cur) ? cur : settings.model || ids[0]))
      } catch {
        // Probe failed but adapter may still be usable; show the catalog default
        // models so the user can pick one and just try sending.
        if (provider.defaultModels.length) {
          setStatus({ kind: "live", models: provider.defaultModels })
          setModel((cur) =>
            cur && provider.defaultModels.includes(cur)
              ? cur
              : settings.model || provider.defaultModels[0],
          )
        } else {
          setStatus({ kind: "mock", reason: "endpoint unreachable" })
        }
      }
    })()

    return () => ac.abort()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    arcadia,
    settings.providerId,
    settings.baseURL,
    settings.secretName,
    settings.mode,
    settings.model,
    provider.transport,
    provider.baseURL,
    provider.requiresKey,
  ])

  useEffect(() => probe(), [probe])

  useEffect(() => {
    if (model) localStorage.setItem(MODEL_KEY, model)
  }, [model])

  const activeModel =
    status.kind === "live" ? model || status.models[0] : "mock"

  const availableModels = status.kind === "live" ? status.models : ["mock"]

  return (
    <AppShell title="AI">
      <LLMProvider adapter={adapter} model={activeModel}>
        {/* Console aesthetic is scoped to this wrapper only, so the appbar
         * and sidebar keep using the global skyrise tokens (light/dark
         * toggle still works for them). */}
        <div
          data-theme="console"
          className="-m-6 flex h-full min-h-0 flex-col bg-[var(--console-ink)] text-[var(--console-text)]"
        >
          <ChatSurface
            models={availableModels}
            model={activeModel}
            onModelChange={setModel}
            agents={agents}
            activeAgent={activeAgent}
            onAgentChange={setActiveAgentId}
            isMock={status.kind === "mock"}
            onRetryProbe={probe}
          />
        </div>
      </LLMProvider>
    </AppShell>
  )
}

function ChatSurface({
  models,
  model,
  onModelChange,
  agents,
  activeAgent,
  onAgentChange,
  isMock,
  onRetryProbe,
}: {
  models: string[]
  model: string
  onModelChange: (m: string) => void
  agents: Agent[]
  activeAgent: Agent | undefined
  onAgentChange: (id: string) => void
  isMock: boolean
  onRetryProbe: () => void
}) {
  const persona = activeAgent
    ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}`
    : ""

  // Track every agent that has produced turns in the current conversation.
  // When the operator switches mid-thread, we augment the system prompt so
  // the new agent knows it's stepping into a transcript started by another
  // persona — without that note it answers as if it produced every prior
  // turn itself, which is jarring.
  // Both maps are seeded from localStorage so a reload mid-thread keeps
  // attribution + hand-off context intact. Persisted on every change via
  // the effect lower down, cleared together with the live snapshot.
  const [agentHistory, setAgentHistory] = useState<Map<string, Agent>>(
    () => loadAgentHistory(),
  )
  const prevAgentRef = useRef<Agent | undefined>(activeAgent)

  // Per-message agent attribution: which agent produced the assistant
  // message at each index. Populated when a turn finishes streaming, used
  // in MessageRow to show the right name in the signature line.
  const [messageAgents, setMessageAgents] = useState<Map<number, Agent>>(
    () => loadMessageAgents(),
  )

  // Persist whenever either map changes.
  useEffect(() => {
    saveAgentHistory(agentHistory)
  }, [agentHistory])
  useEffect(() => {
    saveMessageAgents(messageAgents)
  }, [messageAgents])

  // Hand-off prompt — only emitted when this conversation has been touched
  // by more than one agent. Lists prior personas the new agent might see in
  // the transcript.
  const handoffNote = useMemo(() => {
    if (agentHistory.size <= 1) return ""
    if (!activeAgent || !agentHistory.has(activeAgent.id)) return ""
    const others = [...agentHistory.values()].filter((a) => a.id !== activeAgent.id)
    if (others.length === 0) return ""
    const list = others
      .map((a) => `• ${a.name} (${a.role})`)
      .join("\n")
    return [
      "PRIOR HAND-OFF:",
      "Earlier turns in this conversation were produced by other agent personas. Their responses appear in the transcript as assistant turns. Read them as context — they reflect a different voice and may have different style or focus — but answer the next message in your own voice as the current persona. Don't re-introduce yourself unless the user asks who they're talking to.",
      `Prior personas in this thread:\n${list}`,
    ].join("\n\n")
  }, [agentHistory, activeAgent])

  const systemPrompt = [
    "You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.",
    ARCADIA_KNOWLEDGE,
    persona,
    handoffNote,
    formatAdminContextForPrompt(),
  ]
    .filter(Boolean)
    .join("\n\n")
  const arcadia = useArcadiaClient()
  // Hydrate from the persisted live conversation so navigating away and
  // back doesn't reset the chat. Read once on mount.
  const initialLive = useRef<LLMMessage[] | null>(null)
  if (initialLive.current === null) {
    initialLive.current = loadLive() ?? []
  }
  const { messages, setMessages, send, continueChat, abort, isStreaming } = useChat({
    system: systemPrompt,
    initialMessages: initialLive.current,
  })

  // Persist on every change. Streaming partials get saved too, which is what
  // we want — refreshing mid-stream restores the partial assistant message.
  useEffect(() => {
    saveLive(messages)
  }, [messages])

  // "Clear conversation" must drop three things in lockstep:
  //   1. The in-memory messages (setMessages([])).
  //   2. The persisted live snapshot (clearLive()).
  //   3. The initialLive ref — otherwise on the next render or hook
  //      reconciliation, useChat's reset() would re-seed from the
  //      captured-at-mount initialMessages and the old conversation
  //      pops back. (This was the bug.)
  // We deliberately don't call useChat's reset() here because reset
  // restores to opts.initialMessages, which we want to be empty.
  const resetAndClear = useCallback(() => {
    initialLive.current = []
    clearLive()
    clearAgentMaps()
    setMessages([])
    setAgentHistory(new Map())
    setMessageAgents(new Map())
    // Keep reasoningEffort as-is. It's bound to the active config's
    // default (set when the operator stars a config in Settings) and
    // resetting it would silently undo their intent on every clear.
  }, [setMessages])

  // Auto tool-loop using native function calls. Reads run automatically;
  // writes are held in `pendingConfirm` until the operator clicks Confirm
  // or Deny in the inline ConfirmCard.
  const toolIterationsRef = useRef(0)
  const processedTurnRef = useRef(-1)
  const prevStreamingRef = useRef(isStreaming)
  // Mirror of reasoningEffort state, kept current via the effect below so
  // regenerate/continue callbacks (declared before the state hook) can
  // read the latest value without becoming reasoningEffort dependents.
  const reasoningEffortRef = useRef<ReasoningEffort>("off")

  // Maintain agent-history. Two triggers:
  //   1. When a turn finishes streaming and at least one user/assistant
  //      pair exists, the *current* active agent has demonstrably been
  //      involved — add it.
  //   2. When the operator switches the active agent and there are already
  //      messages in the thread, the *previous* agent was the one talking
  //      until that moment — add it (the new one will be added on its
  //      first turn finish).
  // Also reset everything when the conversation is cleared.
  useEffect(() => {
    if (messages.length === 0) {
      // Fresh thread — drop any stale history so empty-state behaves.
      if (agentHistory.size > 0) setAgentHistory(new Map())
      prevAgentRef.current = activeAgent
      return
    }
    const prev = prevAgentRef.current
    if (prev && prev.id !== activeAgent?.id) {
      // Operator just switched. Lock in the prior agent so the new one
      // sees it in the hand-off note.
      setAgentHistory((m) => {
        if (m.has(prev.id)) return m
        const next = new Map(m)
        next.set(prev.id, prev)
        return next
      })
    }
    prevAgentRef.current = activeAgent
    // Also add the current agent if it has just produced something.
    if (activeAgent && !agentHistory.has(activeAgent.id) && !isStreaming) {
      const last = messages[messages.length - 1]
      if (last?.role === "assistant" && last.content.trim()) {
        setAgentHistory((m) => {
          if (m.has(activeAgent.id)) return m
          const next = new Map(m)
          next.set(activeAgent.id, activeAgent)
          return next
        })
      }
    }
  }, [activeAgent, messages, isStreaming, agentHistory])
  const MAX_TOOL_ITERATIONS = 3
  const [pendingConfirm, setPendingConfirm] = useState<{
    /** Message index that emitted the write calls. */
    afterIndex: number
    writes: ToolCall[]
    readMessages: { role: "tool"; content: string; toolCallId: string; name: string }[]
  } | null>(null)
  const [confirmBusy, setConfirmBusy] = useState(false)

  useEffect(() => {
    const justFinished = prevStreamingRef.current && !isStreaming
    prevStreamingRef.current = isStreaming
    if (!justFinished) return
    const lastIdx = messages.length - 1
    if (lastIdx < 0) return
    const last = messages[lastIdx]
    if (last.role !== "assistant") return

    // Stamp the agent that produced this turn so the UI signature is
    // accurate even after the operator switches personas later. Stamps
    // the *current* activeAgent — by definition the producer of the
    // turn that just finished.
    if (activeAgent) {
      setMessageAgents((m) => {
        if (m.get(lastIdx)?.id === activeAgent.id) return m
        const next = new Map(m)
        next.set(lastIdx, activeAgent)
        return next
      })
    }

    if (processedTurnRef.current === lastIdx) return
    processedTurnRef.current = lastIdx
    const calls = last.toolCalls ?? []
    if (calls.length === 0) {
      toolIterationsRef.current = 0
      return
    }
    if (toolIterationsRef.current >= MAX_TOOL_ITERATIONS) return
    toolIterationsRef.current += 1
    void (async () => {
      const { reads, writes } = classifyCalls(calls)
      const { toolMessages: readMsgs } =
        reads.length > 0
          ? await runLLMToolCalls(reads, { arcadia })
          : { toolMessages: [] }
      if (writes.length > 0) {
        setPendingConfirm({ afterIndex: lastIdx, writes, readMessages: readMsgs })
        return
      }
      void continueChat(readMsgs, {
        system: systemPrompt,
        tools: getOpenAITools(),
      })
    })()
  }, [messages, isStreaming, arcadia, continueChat, systemPrompt])

  const onConfirmWrites = useCallback(async () => {
    if (!pendingConfirm) return
    setConfirmBusy(true)
    try {
      const { toolMessages: writeMsgs } = await runLLMToolCalls(
        pendingConfirm.writes,
        { arcadia },
        { allowWrites: true },
      )
      void continueChat([...pendingConfirm.readMessages, ...writeMsgs], {
        system: systemPrompt,
        tools: getOpenAITools(),
      })
    } finally {
      setPendingConfirm(null)
      setConfirmBusy(false)
    }
  }, [pendingConfirm, arcadia, continueChat, systemPrompt])

  const onDenyWrites = useCallback(() => {
    if (!pendingConfirm) return
    const denials = buildDenialMessages(pendingConfirm.writes)
    void continueChat([...pendingConfirm.readMessages, ...denials], {
      system: systemPrompt,
      tools: getOpenAITools(),
    })
    setPendingConfirm(null)
  }, [pendingConfirm, continueChat, systemPrompt])
  const { complete: completeOneShot, isLoading: compacting } = useCompletion()
  const [input, setInput] = useState("")
  const [showPromptOpen, setShowPromptOpen] = useState(false)
  const [hasCompactSnapshot, setHasCompactSnapshot] = useState(
    () => !!loadAISnapshot(),
  )

  // Session label — stable for the duration of the page load. Encoded in
  // base36 from the mount timestamp; just a unique-feeling moniker for
  // the operator's eye, not anything semantic.
  const [sessionLabel] = useState(() =>
    typeof window === "undefined"
      ? "0000-0000"
      : `${Math.floor(Date.now() / 1000).toString(36).slice(-4).toUpperCase()}-${Math.random()
          .toString(36)
          .slice(2, 6)
          .toUpperCase()}`,
  )

  // Live clock for the modeline / signatures, ticking every second.
  const [now, setNow] = useState(() => new Date())
  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000)
    return () => clearInterval(id)
  }, [])
  const clockLabel = now
    .toISOString()
    .slice(11, 19) /* HH:MM:SS in UTC */
    + "Z"

  const hasAssistantReply = messages.some((m) => m.role === "assistant")

  const buildTranscript = useCallback(() => {
    const lines: string[] = [
      `# Conversation`,
      "",
      activeAgent
        ? `**Persona:** ${activeAgent.name} — ${activeAgent.role}`
        : "",
      `**Date:** ${new Date().toISOString()}`,
      "",
    ].filter(Boolean)
    for (const m of messages) {
      lines.push(`### ${m.role === "user" ? "User" : "Assistant"}`)
      lines.push("")
      lines.push(m.content.trim())
      lines.push("")
    }
    return lines.join("\n")
  }, [messages, activeAgent])

  const toast = useToast()

  const copyMarkdown = useCallback(async () => {
    if (messages.length === 0) return
    try {
      await navigator.clipboard.writeText(buildTranscript())
      toast.success("Copied as Markdown", {
        description: `${messages.length} message${messages.length === 1 ? "" : "s"} on the clipboard.`,
      })
    } catch {
      toast.error("Couldn't copy", {
        description: "Clipboard access was blocked.",
      })
    }
  }, [buildTranscript, messages.length, toast])

  const exportMarkdown = useCallback(() => {
    if (messages.length === 0) return
    const md = buildTranscript()
    const blob = new Blob([md], { type: "text/markdown;charset=utf-8" })
    const url = URL.createObjectURL(blob)
    const a = document.createElement("a")
    a.href = url
    const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
    const filename = `ai-${stamp}.md`
    a.download = filename
    a.click()
    URL.revokeObjectURL(url)
    toast.success("Exported transcript", { description: filename })
  }, [buildTranscript, messages.length, toast])

  const saveToLibrary = useCallback(() => {
    if (messages.length === 0) return
    const md = buildTranscript()
    const title =
      messages[0]?.content.slice(0, 60).replace(/\s+/g, " ").trim() ||
      "AI conversation"
    addLibraryItem({
      kind: "conversation",
      title,
      content: md,
      tags: activeAgent ? [activeAgent.role.toLowerCase()] : [],
      agentName: activeAgent?.name,
      agentRole: activeAgent?.role,
      messageCount: messages.length,
    })
    toast.success("Saved to Library", { description: title })
  }, [buildTranscript, messages, activeAgent, toast])

  const regenerateLast = useCallback(() => {
    if (isStreaming) return
    let lastUserIdx = -1
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i].role === "user") {
        lastUserIdx = i
        break
      }
    }
    if (lastUserIdx === -1) return
    const text = messages[lastUserIdx].content
    setMessages(messages.slice(0, lastUserIdx))
    // Defer so the state flush completes before send() reads `messages`.
    setTimeout(
      () => void send(text, withReasoning({ tools: getOpenAITools() }, reasoningEffortRef.current)),
      0,
    )
  }, [messages, setMessages, send, isStreaming])

  const continueLast = useCallback(() => {
    if (isStreaming || messages.length === 0) return
    void send(
      "Please continue your previous reply.",
      withReasoning({ tools: getOpenAITools() }, reasoningEffortRef.current),
    )
  }, [isStreaming, messages.length, send])

  const compactConversation = useCallback(async () => {
    if (compacting || isStreaming || messages.length < 2) return
    const transcript = messages
      .map(
        (m) =>
          `${m.role === "user" ? "User" : "Assistant"}: ${m.content.trim()}`,
      )
      .join("\n\n")
    const summarySystem =
      "You compress conversations. Output a tight 1–2 paragraph summary that preserves: user goals, key facts, names, decisions, file paths, code snippets, and unfinished tasks. Use third person ('the user wants X'). No commentary, no preamble, no markdown headings."
    try {
      const summary = await completeOneShot(
        [
          { role: "system", content: summarySystem },
          {
            role: "user",
            content: `Summarize this conversation:\n\n${transcript}`,
          },
        ],
        { maxTokens: 800 },
      )
      // Snapshot first so Restore can undo.
      saveAISnapshot(messages as StoredMessage[])
      setHasCompactSnapshot(true)
      setMessages([
        {
          role: "assistant",
          content: `📋 **Conversation summary** (older turns compacted)\n\n${summary.trim()}`,
        },
      ])
    } catch {}
  }, [compacting, isStreaming, messages, completeOneShot, setMessages])

  const restoreCompact = useCallback(() => {
    const snap = loadAISnapshot()
    if (!snap) return
    setMessages(snap)
    clearAISnapshot()
    setHasCompactSnapshot(false)
  }, [setMessages])
  const endRef = useRef<HTMLDivElement | null>(null)
  const composerRef = useRef<HTMLDivElement | null>(null)
  const lastContent = messages.at(-1)?.content ?? ""

  // Track the composer's actual height so the auto-scroll sentinel can
  // keep the latest text ~24px above its top edge regardless of how many
  // lines the textarea has grown to.
  const [composerHeight, setComposerHeight] = useState(160)
  useEffect(() => {
    const el = composerRef.current
    if (!el || typeof ResizeObserver === "undefined") return
    const ro = new ResizeObserver(([entry]) => {
      if (entry) setComposerHeight(entry.contentRect.height)
    })
    ro.observe(el)
    return () => ro.disconnect()
  }, [])

  // Auto-stick to the bottom only when the user is already near it. If they
  // scroll up to read earlier turns mid-stream, don't yank them back down.
  // The ref dodges React render cycles so scroll events feel instant.
  const stickRef = useRef(true)
  useEffect(() => {
    const onScroll = () => {
      const distFromBottom =
        document.documentElement.scrollHeight -
        (window.scrollY + window.innerHeight)
      stickRef.current = distFromBottom < 120
    }
    window.addEventListener("scroll", onScroll, { passive: true })
    onScroll()
    return () => window.removeEventListener("scroll", onScroll)
  }, [])

  useEffect(() => {
    if (!stickRef.current) return
    endRef.current?.scrollIntoView({ block: "end" })
  }, [messages.length, lastContent, isStreaming])

  // Per-conversation reasoning override. Persists across page reloads via
  // localStorage so the operator's chosen level survives a refresh, but
  // resets when they clear the conversation. "off" = pass nothing through.
  // Initialize from the shared key (settings panel writes this when the
  // operator stars a config), persist on change, and live-subscribe so a
  // star action in another tab/route updates the chip without a refresh.
  const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
    () => loadActiveReasoning(),
  )
  useEffect(() => {
    saveActiveReasoning(reasoningEffort)
    reasoningEffortRef.current = reasoningEffort
  }, [reasoningEffort])
  useEffect(() => {
    return subscribeActiveReasoning((next) => {
      // Don't loop on our own writes — we already wrote `reasoningEffort`
      // when it changed. Only pick up writes that disagree with state.
      setReasoningEffort((cur) => (cur === next ? cur : next))
    })
  }, [])

  const cycleReasoning = useCallback(() => {
    setReasoningEffort((cur) => {
      const idx = REASONING_LEVELS.indexOf(cur)
      return REASONING_LEVELS[(idx + 1) % REASONING_LEVELS.length]
    })
  }, [])

  const submit = useCallback(() => {
    const text = input.trim()
    if (!text || isStreaming) return
    setInput("")
    stickRef.current = true
    void send(text, withReasoning({ tools: getOpenAITools() }, reasoningEffort))
  }, [input, isStreaming, send, reasoningEffort])

  const isEmpty = messages.length === 0

  // Token estimate for the modeline. Cheap heuristic, adequate for
  // operator-glance display.
  const estTokensTotal = messages.reduce(
    (n, m) => n + Math.ceil((m.content?.length ?? 0) / 4),
    0,
  )
  const userTurns = messages.filter((m) => m.role === "user").length

  return (
    <div className="relative -mb-6 flex h-full min-h-0 flex-col">
      {/* Session header — flight-recorder strip. Hidden in the empty state
       * because the empty state already shows session metadata. */}
      {!isEmpty && (
        <div className="console-header sticky top-0 z-10 px-4 py-3 sm:px-6">
          <div className="mx-auto flex w-full max-w-3xl items-end justify-between gap-6">
            <div className="flex flex-col gap-0.5">
              <span className="console-meta-key">session</span>
              <span className="console-session-id">
                {sessionLabel.split("-")[0]}
                <span>·</span>
                {sessionLabel.split("-")[1]}
              </span>
            </div>
            <div className="flex flex-wrap items-end justify-end gap-x-6 gap-y-1.5">
              <SessionMeta label="agent" value={(activeAgent?.name ?? "Atlas").toLowerCase()} />
              <SessionMeta label="model" value={truncateModel(model)} />
              <SessionMeta label="turns" value={userTurns.toString().padStart(2, "0")} />
              <SessionMeta
                label="status"
                value={
                  isStreaming
                    ? "STREAMING"
                    : pendingConfirm
                      ? "AWAIT-CONFIRM"
                      : isMock
                        ? "MOCK"
                        : "READY"
                }
                tone={
                  isStreaming
                    ? "amber"
                    : pendingConfirm
                      ? "rose"
                      : isMock
                        ? "muted"
                        : "mint"
                }
              />
            </div>
          </div>
        </div>
      )}

      {/* Empty state — flight-recorder card with staggered reveal */}
      <div
        aria-hidden={!isEmpty}
        className="pointer-events-none absolute inset-x-0 top-[10%] px-8 transition-opacity duration-300"
        style={{ opacity: isEmpty ? 1 : 0 }}
      >
        <div className="mx-auto flex max-w-3xl flex-col gap-4">
          <div className="console-empty-line console-mono flex items-center justify-between text-[10.5px] tracking-[0.18em] uppercase text-[var(--console-muted)]">
            <span>arcadia // operator console</span>
            <span>session {sessionLabel}</span>
          </div>
          <div className="console-empty-line h-px bg-[var(--console-rule-soft)]" />
          <h1 className="console-empty-line console-empty-headline">
            ATLAS<span className="text-[var(--console-amber)]">.</span>{" "}
            <em>standing&nbsp;by</em>
          </h1>
          <p className="console-empty-line console-mono max-w-[58ch] text-[13.5px] leading-[1.7] text-[var(--console-text-2)]">
            <span className="text-[var(--console-amber)]">›</span>{" "}
            Issue an instruction. Read tools run automatically. Writes pause for
            confirmation. Tab&nbsp;⇥ for command palette.
          </p>
          <div className="console-empty-line pointer-events-auto">
            <button
              type="button"
              onClick={() =>
                setMessages([
                  { role: "assistant", content: BLOCK_SAMPLES_CONTENT } as LLMMessage,
                ])
              }
              className="console-mono inline-flex items-center gap-1.5 rounded-md border border-[var(--console-rule-soft)] bg-transparent px-2.5 py-1 text-[10.5px] uppercase tracking-[0.18em] text-[var(--console-muted)] transition-colors hover:border-[var(--console-amber)] hover:text-[var(--console-amber)]"
            >
              › preview rich-output blocks
            </button>
          </div>
        </div>
      </div>

      {/* Messages — rendered when there are any. In empty state a flex-grow
       * spacer takes its place so the sticky-bottom composer lands at the
       * actual bottom of the surface (otherwise it'd sit at the top with
       * nothing above it, and the lift transform would push it off-screen). */}
      {isEmpty ? (
        <div className="flex-1" aria-hidden="true" />
      ) : (
        <div className="flex-1 px-4 py-6 sm:px-6">
          <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
            {messages.map((m, i) => {
              if (m.role === "system" || m.role === "tool") return null
              const calls =
                m.role === "assistant" && m.toolCalls
                  ? m.toolCalls.map((tc) =>
                      buildAgentToolCall(tc, messages, isStreaming, !!pendingConfirm),
                    )
                  : []
              const isWritePending =
                pendingConfirm?.afterIndex === i ? pendingConfirm.writes : null
              return (
                <div key={i} className="contents">
                  <MessageRow
                    role={m.role as "user" | "assistant"}
                    content={m.content}
                    toolCalls={m.toolCalls}
                    turnNum={i + 1}
                    // Use the agent stamped on this index when known, fall
                    // back to the active agent (covers the live stream
                    // before the post-stream effect fires).
                    agentName={
                      messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas"
                    }
                    timestamp={clockLabel}
                    sources={
                      m.role === "assistant"
                        ? extractDocSources(messages, i)
                        : undefined
                    }
                  />
                  {calls.length > 0 && (
                    <div className="self-start flex w-full max-w-[80ch] flex-col gap-2">
                      {calls.map((c) => (
                        <div key={c.id} className="flex flex-col gap-2">
                          <ToolCallCard call={c} defaultExpanded={false} />
                          {c.status === "success" && (
                            <ToolResultBlock name={c.name} result={c.result} />
                          )}
                        </div>
                      ))}
                    </div>
                  )}
                  {isWritePending && (
                    <ConfirmCard
                      calls={isWritePending}
                      onConfirm={onConfirmWrites}
                      onDeny={onDenyWrites}
                      busy={confirmBusy}
                    />
                  )}
                </div>
              )
            })}
            {(() => {
              const activity = deriveAgentActivity({
                isStreaming,
                lastMessage: messages.at(-1),
                pendingConfirm: !!pendingConfirm,
                confirmBusy,
              })
              const isIdle = activity === "idle"
              return (
                <div
                  className={`self-start transition-opacity duration-300 ${
                    isIdle ? "opacity-50" : "opacity-100"
                  }`}
                >
                  <AgentAvatar
                    name={activeAgent?.name ?? "Atlas"}
                    activity={activity}
                    initials={activeAgent ? agentInitials(activeAgent.name) : "AT"}
                    size={isIdle ? "sm" : "md"}
                    showLabel={!isIdle}
                  />
                </div>
              )
            })()}
            <div
              ref={endRef}
              aria-hidden="true"
              style={{ scrollMarginBottom: `${composerHeight + 24}px` }}
            />
          </div>
        </div>
      )}

      {/* Composer — single persistent mount; transform lifts it to viewport
       * center when empty, then springs to sticky-bottom on the first message. */}
      <div
        ref={composerRef}
        className="sticky bottom-0 z-20 px-4 pb-3 pt-3 sm:px-6"
        style={{
          transform: isEmpty
            ? "translateY(calc(-50dvh + 50% + 4rem))"
            : "translateY(0)",
        }}
      >
        <div className="mx-auto w-full max-w-3xl">
          <Composer
            value={input}
            onChange={setInput}
            onSubmit={submit}
            onAbort={abort}
            isStreaming={isStreaming}
            models={models}
            model={model}
            onModelChange={onModelChange}
            agents={agents}
            activeAgent={activeAgent}
            onAgentChange={onAgentChange}
            onRegenerate={regenerateLast}
            onContinue={continueLast}
            onCompact={() => void compactConversation()}
            onRestoreCompact={restoreCompact}
            onCopyMarkdown={() => void copyMarkdown()}
            onExportMarkdown={exportMarkdown}
            onSaveToLibrary={saveToLibrary}
            onShowPrompt={() => setShowPromptOpen(true)}
            onRetryProbe={onRetryProbe}
            onClear={resetAndClear}
            hasMessages={messages.length > 0}
            hasUserMessage={messages.some((m) => m.role === "user")}
            hasCompactSnapshot={hasCompactSnapshot}
            isMock={isMock}
            isCompacting={compacting}
            placeholder={isEmpty ? "Ask anything…" : "Reply…"}
            reasoning={reasoningEffort}
            onCycleReasoning={cycleReasoning}
          />
          {showPromptOpen && (
            <SystemPromptDialog
              prompt={systemPrompt}
              onClose={() => setShowPromptOpen(false)}
            />
          )}
        </div>
      </div>

      {/* Modeline — vim-style status strip. Pinned above the AppShell's own
       * footer/padding so it always reads in the operator's bottom band. */}
      <div className="console-modeline px-4 py-1.5 sm:px-6">
        <div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-x-6 gap-y-0.5 tabular-nums">
          <div className="flex items-center gap-4">
            <span>
              <span className="console-modeline-key">utc</span>
              <span className="console-modeline-val">{clockLabel}</span>
            </span>
            <span>
              <span className="console-modeline-key">turn</span>
              <span className="console-modeline-val">
                {userTurns.toString().padStart(2, "0")}
              </span>
            </span>
            <span>
              <span className="console-modeline-key">tok</span>
              <span className="console-modeline-val">~{estTokensTotal.toLocaleString()}</span>
            </span>
          </div>
          <div className="flex items-center gap-4">
            {isStreaming ? (
              <span className="flex items-center gap-2">
                <span className="console-streaming-bar" />
                <span className="console-modeline-val text-[var(--console-amber)]">
                  STREAM
                </span>
              </span>
            ) : (
              <span>
                <span className="console-modeline-key">enter</span>
                <span className="console-modeline-val">send</span>
                <span className="ml-3 console-modeline-key">⇧</span>
                <span className="console-modeline-key ml-1">enter</span>
                <span className="console-modeline-val">newline</span>
              </span>
            )}
          </div>
        </div>
      </div>
    </div>
  )
}

function deriveAgentActivity({
  isStreaming,
  lastMessage,
  pendingConfirm,
  confirmBusy,
}: {
  isStreaming: boolean
  lastMessage: LLMMessage | undefined
  pendingConfirm: boolean
  confirmBusy: boolean
}): "idle" | "thinking" | "working" | "waiting" | "speaking" {
  if (confirmBusy) return "working"
  if (pendingConfirm) return "waiting"
  if (!isStreaming) return "idle"
  if (!lastMessage || lastMessage.role !== "assistant") return "thinking"
  if (lastMessage.content.trim().length > 0) return "speaking"
  return "thinking"
}

function buildAgentToolCall(
  tc: ToolCall,
  allMessages: LLMMessage[],
  isStreaming: boolean,
  pendingConfirm: boolean,
): AgentToolCall {
  const result = allMessages.find(
    (m) => m.role === "tool" && m.toolCallId === tc.id,
  )
  let parsedArgs: Record<string, unknown> | undefined
  try {
    parsedArgs = tc.arguments ? JSON.parse(tc.arguments) : undefined
  } catch {
    parsedArgs = undefined
  }
  if (result) {
    let parsedResult: unknown = result.content
    let errorMsg: string | undefined
    try {
      const obj = JSON.parse(result.content) as Record<string, unknown>
      if (obj && typeof obj === "object" && typeof obj.error === "string") {
        errorMsg = obj.error
      } else {
        parsedResult = obj
      }
    } catch {
      // leave as raw text
    }
    return {
      id: tc.id,
      name: tc.name,
      status: errorMsg ? "error" : "success",
      args: parsedArgs,
      result: errorMsg ? undefined : parsedResult,
      error: errorMsg,
    }
  }
  let status: ToolCallStatus = "running"
  if (pendingConfirm) status = "pending"
  else if (!isStreaming) status = "running"
  return {
    id: tc.id,
    name: tc.name,
    status,
    args: parsedArgs,
  }
}

function SessionMeta({
  label,
  value,
  tone = "default",
}: {
  label: string
  value: string
  tone?: "default" | "amber" | "rose" | "mint" | "muted"
}) {
  const toneColor = {
    default: "var(--console-text)",
    amber: "var(--console-amber)",
    rose: "var(--console-rose)",
    mint: "var(--console-mint)",
    muted: "var(--console-muted)",
  }[tone]
  return (
    <div className="flex flex-col gap-0.5 text-right">
      <span className="console-meta-key">{label}</span>
      <span className="console-meta-val tabular-nums" style={{ color: toneColor }}>
        {value}
      </span>
    </div>
  )
}

function truncateModel(m: string): string {
  if (!m) return "—"
  if (m.length <= 22) return m
  return m.slice(0, 10) + "…" + m.slice(-9)
}

/** Walk forward from an assistant message and collect doc-search hits from
 *  any matching `tool` result messages. Deduped by sourcePath so a chunk and
 *  its sibling chunk in the same file collapse to one citation. */
function extractDocSources(
  messages: LLMMessage[],
  assistantIdx: number,
): DocHit[] {
  const msg = messages[assistantIdx]
  if (msg?.role !== "assistant" || !msg.toolCalls?.length) return []
  const docCallIds = new Set(
    msg.toolCalls.filter((tc) => tc.name === "search_docs").map((tc) => tc.id),
  )
  if (docCallIds.size === 0) return []

  const seen = new Set<string>()
  const out: DocHit[] = []
  for (let i = assistantIdx + 1; i < messages.length; i++) {
    const m = messages[i]
    if (m.role === "assistant") break // hit the next turn
    if (m.role !== "tool" || !m.toolCallId || !docCallIds.has(m.toolCallId)) {
      continue
    }
    try {
      const parsed = JSON.parse(m.content) as { hits?: DocHit[] }
      for (const h of parsed.hits ?? []) {
        if (seen.has(h.sourcePath + "#" + h.id)) continue
        seen.add(h.sourcePath + "#" + h.id)
        out.push(h)
      }
    } catch {
      // Tool errors come back as { error } — no hits to surface.
    }
  }
  return out
}

function SourcesFooter({ sources }: { sources: DocHit[] }) {
  if (sources.length === 0) return null
  return (
    <div className="console-mono mt-3 flex flex-wrap items-center gap-1.5 text-[10.5px] tracking-[0.08em] text-[var(--console-muted)]">
      <span className="uppercase text-[var(--console-muted-2)]">sources</span>
      <span className="text-[var(--console-muted-2)]">›</span>
      {sources.map((s) => (
        <span
          key={s.id}
          title={`${s.sourcePath}\n\n${s.excerpt}`}
          className="inline-flex max-w-[28ch] items-center gap-1 truncate rounded border border-[var(--console-rule-soft)] bg-[var(--console-deck)] px-1.5 py-0.5 text-[var(--console-text-2)]"
        >
          <span className="truncate">{s.title}</span>
        </span>
      ))}
    </div>
  )
}

function MessageRow({
  role,
  content,
  toolCalls,
  turnNum,
  agentName,
  timestamp,
  sources,
}: {
  role: "user" | "assistant"
  content: string
  toolCalls?: ToolCall[]
  turnNum?: number
  agentName?: string
  timestamp?: string
  sources?: DocHit[]
}) {
  // Operator turn — monospace, sodium-amber prompt, no bubble. The whole
  // row hangs from a left gutter showing the turn number.
  if (role === "user") {
    return (
      <div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
        <div className="flex flex-col items-end pt-[3px]">
          <span className="console-turn-num">
            T{(turnNum ?? 0).toString().padStart(2, "0")}
          </span>
          {timestamp ? (
            <span className="console-mono mt-0.5 text-[9.5px] tracking-[0.1em] text-[var(--console-muted-2)]">
              {timestamp}
            </span>
          ) : null}
        </div>
        <div className="border-l border-[var(--console-rule-soft)] pl-4">
          <div className="console-op-line whitespace-pre-wrap">
            <span className="console-op-prompt">›&nbsp;</span>
            {content}
          </div>
        </div>
      </div>
    )
  }

  // Assistant turn — set in serif, with a tiny mono signature beneath. If
  // there's no prose (just tool calls), suppress the row entirely.
  if (!content.trim()) return null
  return (
    <div className="grid grid-cols-[3.5rem_1fr] gap-x-3 self-stretch">
      <div className="flex flex-col items-end pt-[2px]">
        <span className="console-turn-num text-[var(--console-cyan)]">
          T{(turnNum ?? 0).toString().padStart(2, "0")}
        </span>
        <span className="console-mono mt-0.5 text-[9.5px] tracking-[0.1em] text-[var(--console-muted-2)]">
          {agentName?.slice(0, 6).toLowerCase() ?? "atlas"}
        </span>
      </div>
      <div className="border-l border-[var(--console-cyan-deep)]/40 pl-4">
        <div className="console-agent-prose">
          <MessageBody content={content} toolCalls={toolCalls} />
        </div>
        {sources && sources.length > 0 && <SourcesFooter sources={sources} />}
        <div className="console-sig mt-2 flex items-center gap-2">
          <span className="console-sig-name">
            {agentName?.toLowerCase() ?? "atlas"}»
          </span>
          {timestamp ? <span>{timestamp}</span> : null}
          <span className="text-[var(--console-muted-2)]">·</span>
          <span>recv</span>
        </div>
      </div>
    </div>
  )
}

function Composer({
  value,
  onChange,
  onSubmit,
  onAbort,
  isStreaming,
  models,
  model,
  onModelChange,
  agents,
  activeAgent,
  onAgentChange,
  onRegenerate,
  onContinue,
  onCompact,
  onRestoreCompact,
  onCopyMarkdown,
  onExportMarkdown,
  onSaveToLibrary,
  onShowPrompt,
  onRetryProbe,
  onClear,
  hasMessages,
  hasUserMessage,
  hasCompactSnapshot,
  isMock,
  isCompacting,
  placeholder,
  reasoning,
  onCycleReasoning,
}: {
  value: string
  onChange: (v: string) => void
  onSubmit: () => void
  onAbort: () => void
  isStreaming: boolean
  models: string[]
  model: string
  onModelChange: (m: string) => void
  agents: Agent[]
  activeAgent: Agent | undefined
  onAgentChange: (id: string) => void
  onRegenerate: () => void
  onContinue: () => void
  onCompact: () => void
  onRestoreCompact: () => void
  onCopyMarkdown: () => void
  onExportMarkdown: () => void
  onSaveToLibrary: () => void
  onShowPrompt: () => void
  onRetryProbe: () => void
  onClear: () => void
  hasMessages: boolean
  hasUserMessage: boolean
  hasCompactSnapshot: boolean
  isMock: boolean
  isCompacting: boolean
  placeholder: string
  reasoning: ReasoningEffort
  onCycleReasoning: () => void
}) {
  const taRef = useRef<HTMLTextAreaElement | null>(null)

  // Auto-grow the textarea.
  useEffect(() => {
    const el = taRef.current
    if (!el) return
    el.style.height = "auto"
    el.style.height = Math.min(el.scrollHeight, 240) + "px"
  }, [value])

  const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault()
      onSubmit()
    }
  }

  return (
    <div className="console-composer">
      <div className="flex flex-col gap-3 px-4 pt-3 pb-3">
        <div className="flex items-start gap-2">
          <span
            aria-hidden
            className="console-mono select-none pt-[2px] text-[14px] font-semibold leading-[1.55] text-[var(--console-amber)]"
          >
            ›_
          </span>
          <textarea
            ref={taRef}
            value={value}
            onChange={(e) => onChange(e.target.value)}
            onKeyDown={onKey}
            placeholder={placeholder}
            rows={2}
            data-action="ai-composer-input"
            className="min-h-[3.5rem] w-full resize-none bg-transparent outline-none"
          />
        </div>
        <div className="flex items-center justify-between gap-2">
          <div className="flex items-center gap-1">
            <button
              type="button"
              data-action="ai-attach"
              aria-label="Attach"
              className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
            >
              <Plus className="size-5" />
            </button>
            <CommandsMenu
              onRegenerate={onRegenerate}
              onContinue={onContinue}
              onCompact={onCompact}
              onRestoreCompact={onRestoreCompact}
              onCopyMarkdown={onCopyMarkdown}
              onExportMarkdown={onExportMarkdown}
              onSaveToLibrary={onSaveToLibrary}
              onShowPrompt={onShowPrompt}
              onRetryProbe={onRetryProbe}
              onClear={onClear}
              isStreaming={isStreaming}
              isCompacting={isCompacting}
              hasMessages={hasMessages}
              hasUserMessage={hasUserMessage}
              hasCompactSnapshot={hasCompactSnapshot}
              isMock={isMock}
            />
          </div>
          <div className="flex items-center gap-1">
            <AgentChip
              agents={agents}
              activeAgent={activeAgent}
              onAgentChange={onAgentChange}
            />
            <ModelSelector
              models={models}
              model={model}
              onModelChange={onModelChange}
            />
            <ReasoningChip value={reasoning} onCycle={onCycleReasoning} />
            <VoiceInputButton
              onTranscript={(t) => onChange((value ? value + " " : "") + t)}
            />
            {isStreaming ? (
              <Button
                type="button"
                size="sm"
                variant="ghost"
                onClick={onAbort}
                aria-label="Stop"
                data-action="ai-stop"
                className="rounded-full"
              >
                <Square className="size-4" />
              </Button>
            ) : null}
          </div>
        </div>
      </div>
    </div>
  )
}

function ModelSelector({
  models,
  model,
  onModelChange,
}: {
  models: string[]
  model: string
  onModelChange: (m: string) => void
}) {
  const label = prettyModelName(model)
  return (
    <DropdownMenu>
      {/* base-ui's Menu.Trigger renders its own <button>, so we don't wrap
       * a nested <button> here (which Radix's asChild pattern would require).
       * Styles + data-action go straight on the Trigger. */}
      <DropdownMenuTrigger
        data-action="ai-model"
        className="inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
      >
        <span>{label}</span>
        <ChevronDown className="size-4 opacity-70" />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {models.map((m) => (
          <DropdownMenuItem
            key={m}
            onClick={() => onModelChange(m)}
            className={m === model ? "font-medium" : ""}
          >
            {prettyModelName(m)}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

/**
 * Reasoning-effort chip for the composer. Click cycles off → low → medium →
 * high → max → off. When non-off, the next send includes
 * `reasoning_effort: <level>` which the proxy passes to OpenAI/DeepSeek
 * natively and translates to Anthropic's thinking block server-side.
 *
 * Visually: hidden when off (no chrome clutter for the common case),
 * surfaces as a sodium-amber pill when set.
 */
function ReasoningChip({
  value,
  onCycle,
}: {
  value: ReasoningEffort
  onCycle: () => void
}) {
  const active = value !== "off"
  return (
    <button
      type="button"
      onClick={onCycle}
      data-action="ai-reasoning"
      title={
        active
          ? `Reasoning: ${value}. Click to cycle.`
          : "Reasoning: off. Click to enable thinking mode."
      }
      className={[
        "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-mono uppercase tracking-[0.12em] transition-colors",
        active
          ? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 dark:text-amber-300"
          : "text-muted-foreground hover:bg-accent hover:text-foreground",
      ].join(" ")}
    >
      <Sparkles className="size-3" />
      <span className="select-none">
        think
        {active ? <span className="ml-1 font-semibold">{value}</span> : null}
      </span>
    </button>
  )
}

function AgentChip({
  agents,
  activeAgent,
  onAgentChange,
}: {
  agents: Agent[]
  activeAgent: Agent | undefined
  onAgentChange: (id: string) => void
}) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        data-action="ai-agent"
        className="inline-flex items-center gap-1.5 rounded-full py-1 pl-1 pr-2.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        title={
          activeAgent
            ? `${activeAgent.name} — ${activeAgent.role}`
            : "Pick a persona"
        }
      >
        <Avatar className="size-5">
          <AvatarFallback
            style={{
              background: agentTint(activeAgent?.id ?? ""),
              color: "var(--primary-foreground)",
            }}
            className="text-[10px] font-semibold"
          >
            {agentInitials(activeAgent?.name)}
          </AvatarFallback>
        </Avatar>
        <span className="font-medium">
          {activeAgent?.name ?? "Agent"}
        </span>
        <ChevronDown className="size-4 opacity-70" />
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="start"
        sideOffset={6}
        className="max-h-80 w-72 overflow-y-auto"
      >
        <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
          Switch persona
        </div>
        {agents.map((a) => (
          <DropdownMenuItem
            key={a.id}
            onClick={() => onAgentChange(a.id)}
            data-state={activeAgent?.id === a.id ? "checked" : undefined}
            data-action={`ai-agent-${a.id}`}
            className="flex items-center gap-2.5"
          >
            <Avatar className="size-7">
              <AvatarFallback
                style={{
                  background: agentTint(a.id),
                  color: "var(--primary-foreground)",
                }}
                className="text-[11px] font-semibold"
              >
                {agentInitials(a.name)}
              </AvatarFallback>
            </Avatar>
            <span className="flex min-w-0 flex-col">
              <span className="truncate font-medium">{a.name}</span>
              <span className="truncate text-xs text-muted-foreground">
                {a.role}
              </span>
            </span>
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

function agentInitials(name: string | undefined): string {
  if (!name) return "?"
  const words = name.trim().split(/\s+/).filter(Boolean)
  if (words.length === 0) return "?"
  if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
  return (words[0][0] + words[words.length - 1][0]).toUpperCase()
}

function agentTint(id: string): string {
  let hash = 0
  for (let i = 0; i < id.length; i++) {
    hash = (hash * 31 + id.charCodeAt(i)) | 0
  }
  const hue = ((hash % 360) + 360) % 360
  return `oklch(0.55 0.18 ${hue})`
}

function CommandsMenu({
  onRegenerate,
  onContinue,
  onCompact,
  onRestoreCompact,
  onCopyMarkdown,
  onExportMarkdown,
  onSaveToLibrary,
  onShowPrompt,
  onRetryProbe,
  onClear,
  isStreaming,
  isCompacting,
  hasMessages,
  hasUserMessage,
  hasCompactSnapshot,
  isMock,
}: {
  onRegenerate: () => void
  onContinue: () => void
  onCompact: () => void
  onRestoreCompact: () => void
  onCopyMarkdown: () => void
  onExportMarkdown: () => void
  onSaveToLibrary: () => void
  onShowPrompt: () => void
  onRetryProbe: () => void
  onClear: () => void
  isStreaming: boolean
  isCompacting: boolean
  hasMessages: boolean
  hasUserMessage: boolean
  hasCompactSnapshot: boolean
  isMock: boolean
}) {
  const [open, setOpen] = useState(false)
  // Close the popover after a tile is clicked, so the menu acknowledges the
  // action visually even when the action itself produces no obvious change
  // (Copy MD, Export MD, etc — those also fire a toast at the call site).
  const close = useCallback(
    (fn: () => void) => () => {
      fn()
      setOpen(false)
    },
    [],
  )
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        render={
          <button
            type="button"
            data-action="ai-commands"
            aria-label="Commands"
            title="Commands"
            className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            <CommandIcon className="size-5" />
          </button>
        }
      />
      <PopoverContent align="start" side="top" className="w-80 p-2">
        <SectionLabel>Conversation</SectionLabel>
        <div className="grid grid-cols-2 gap-1">
          <ToolTile
            data-action="ai-cmd-regenerate"
            onClick={close(onRegenerate)}
            disabled={isStreaming || !hasUserMessage}
            icon={<RotateCcw className="size-4" />}
            label="Regenerate"
            title="Re-run the most recent prompt"
          />
          <ToolTile
            data-action="ai-cmd-continue"
            onClick={close(onContinue)}
            disabled={isStreaming || !hasMessages}
            icon={<ArrowRight className="size-4" />}
            label="Continue"
            title="Ask the model to keep going"
          />
          <ToolTile
            data-action="ai-cmd-compact"
            onClick={close(onCompact)}
            disabled={isCompacting || isStreaming || !hasMessages}
            icon={
              isCompacting ? (
                <Loader2 className="size-4 animate-spin" />
              ) : (
                <Archive className="size-4" />
              )
            }
            label="Compact"
            title="Summarize older turns to free context"
          />
          <ToolTile
            data-action="ai-cmd-restore-compact"
            onClick={close(onRestoreCompact)}
            disabled={!hasCompactSnapshot}
            icon={<Undo2 className="size-4" />}
            label="Restore"
            title={
              hasCompactSnapshot
                ? "Undo the most recent compact"
                : "No snapshot available"
            }
          />
        </div>

        <SectionLabel>Share</SectionLabel>
        <div className="grid grid-cols-2 gap-1">
          <ToolTile
            data-action="ai-cmd-copy-md"
            onClick={close(onCopyMarkdown)}
            disabled={!hasMessages}
            icon={<Copy className="size-4" />}
            label="Copy MD"
            title="Copy conversation as Markdown"
          />
          <ToolTile
            data-action="ai-cmd-export-md"
            onClick={close(onExportMarkdown)}
            disabled={!hasMessages}
            icon={<Download className="size-4" />}
            label="Export MD"
            title="Download a .md file"
          />
          <ToolTile
            data-action="ai-cmd-save-library"
            onClick={close(onSaveToLibrary)}
            disabled={!hasMessages}
            icon={<BookmarkPlus className="size-4" />}
            label="Save to Library"
            title="Snapshot this conversation"
          />
          <ToolTile
            data-action="ai-cmd-show-prompt"
            onClick={close(onShowPrompt)}
            icon={<FileText className="size-4" />}
            label="Show prompt"
            title="Preview the system prompt"
          />
        </div>

        {isMock && (
          <>
            <SectionLabel>Connection</SectionLabel>
            <div className="grid grid-cols-2 gap-1">
              <ToolTile
                data-action="ai-cmd-retry-probe"
                onClick={close(onRetryProbe)}
                icon={<RefreshCw className="size-4" />}
                label="Reconnect"
                title="Probe the LLM endpoint again"
              />
            </div>
          </>
        )}

        <div className="my-2 h-px bg-border" />
        <ToolTile
          data-action="ai-cmd-clear"
          onClick={close(onClear)}
          disabled={!hasMessages}
          icon={<Trash2 className="size-4" />}
          label="Clear conversation"
          title="Wipe history and start fresh"
          destructive
          fullWidth
        />
      </PopoverContent>
    </Popover>
  )
}

function SystemPromptDialog({
  prompt,
  onClose,
}: {
  prompt: string
  onClose: () => void
}) {
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(prompt)
    } catch {}
  }
  if (typeof document === "undefined") return null
  return createPortal(
    <div
      role="dialog"
      aria-modal="true"
      onClick={onClose}
      className="fixed inset-0 z-50 flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm"
    >
      <div
        onClick={(e) => e.stopPropagation()}
        className="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border bg-card shadow-lg"
      >
        <div className="flex items-center gap-2 border-b px-4 py-3">
          <FileText className="size-4 text-muted-foreground" />
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">System prompt</span>
            <span className="text-xs text-muted-foreground">
              Base prompt + active persona
            </span>
          </div>
          <Button variant="ghost" size="sm" onClick={copy}>
            <Copy className="size-3.5" /> Copy
          </Button>
          <Button
            variant="ghost"
            size="icon-sm"
            onClick={onClose}
            aria-label="Close"
          >
            <X className="size-4" />
          </Button>
        </div>
        <pre className="flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed">
          {prompt}
        </pre>
      </div>
    </div>,
    document.body,
  )
}

function SectionLabel({ children }: { children: React.ReactNode }) {
  return (
    <div className="mt-2 mb-1 px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
      {children}
    </div>
  )
}

function ToolTile({
  icon,
  label,
  title,
  onClick,
  disabled,
  destructive,
  fullWidth,
  ...rest
}: {
  icon: React.ReactNode
  label: string
  title: string
  onClick: () => void
  disabled?: boolean
  destructive?: boolean
  fullWidth?: boolean
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "title">) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={
        "flex items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 " +
        (fullWidth ? "w-full " : "") +
        (destructive
          ? "text-destructive hover:bg-destructive/10"
          : "hover:bg-accent hover:text-accent-foreground")
      }
      {...rest}
    >
      <span className="shrink-0">{icon}</span>
      <span className="truncate font-medium">{label}</span>
    </button>
  )
}

function prettyModelName(id: string): string {
  if (!id) return "model"
  // Trim known prefixes / paths so the chip stays compact.
  const last = id.split(/[\\/]/).pop() ?? id
  return last.length > 28 ? last.slice(0, 26) + "…" : last
}

type SpeechRecognitionLike = {
  lang: string
  interimResults: boolean
  continuous: boolean
  onresult: (e: { results: { [k: number]: { [k: number]: { transcript: string } } } }) => void
  onerror: () => void
  onend: () => void
  start: () => void
  stop: () => void
}

function getSpeechRecognition(): (new () => SpeechRecognitionLike) | null {
  if (typeof window === "undefined") return null
  const w = window as unknown as {
    SpeechRecognition?: new () => SpeechRecognitionLike
    webkitSpeechRecognition?: new () => SpeechRecognitionLike
  }
  return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
}

function VoiceInputButton({
  onTranscript,
}: {
  onTranscript: (text: string) => void
}) {
  const Ctor = getSpeechRecognition()
  const [listening, setListening] = useState(false)
  const recRef = useRef<SpeechRecognitionLike | null>(null)

  if (!Ctor) return null

  const start = () => {
    try {
      const rec = new Ctor()
      rec.lang = navigator.language || "en-US"
      rec.interimResults = false
      rec.continuous = false
      rec.onresult = (e) => {
        const text = e.results[0]?.[0]?.transcript ?? ""
        if (text.trim()) onTranscript(text.trim())
      }
      rec.onerror = () => setListening(false)
      rec.onend = () => setListening(false)
      recRef.current = rec
      rec.start()
      setListening(true)
    } catch {
      setListening(false)
    }
  }
  const stop = () => {
    recRef.current?.stop()
    setListening(false)
  }

  return (
    <button
      type="button"
      data-action="ai-voice"
      onClick={() => (listening ? stop() : start())}
      aria-label={listening ? "Stop listening" : "Voice input"}
      className={
        "inline-flex size-9 items-center justify-center rounded-full transition-colors " +
        (listening
          ? "bg-destructive/10 text-destructive animate-pulse"
          : "text-muted-foreground hover:bg-accent hover:text-foreground")
      }
    >
      {listening ? <MicOff className="size-5" /> : <Mic className="size-5" />}
    </button>
  )
}
