<script lang="ts">
  // Pure prop-driven transcript + input — no store imports. Both the main
  // Chat page (singleton "current session" stores) and a floating task
  // window (its own per-session store bundle from chat.ts's chatFor) render
  // through this, so the message-bubble/markdown styling lives in one place
  // instead of being copy-pasted between the two.
  import { Pane, Splitpanes } from 'svelte-splitpanes'
  import { activityLog, type ActivityEntry } from '$lib/stores/activity'
  import type { Readable } from 'svelte/store'
  import { Button } from '$lib/components/ui/button'
  import { Textarea } from '$lib/components/ui/textarea'
  import AgentTrace from './AgentTrace.svelte'
  import OperatorQuestion from './OperatorQuestion.svelte'
  import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
  import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
  import SquareIcon from '@lucide/svelte/icons/square'
  import { marked } from 'marked'
  import DOMPurify from 'dompurify'
  import type { ChatMessage } from '$lib/stores/chat'
  import type { ToolCallResult } from '$lib/types'
  import type { SessionQuestion } from '$lib/api'

  let {
    messages,
    streaming,
    connectionState,
    error = null,
    chatErrors = [],
    onSend,
    onCancel,
    onReconnect,
    onDismissError,
    suggestions = [],
    activityLog: activityLogProp = activityLog,
    sessionId = null,
    question = null,
    initialDraft = ''
  }: {
    messages: ChatMessage[]
    streaming: boolean
    connectionState: 'connected' | 'disconnected' | 'reconnecting'
    error?: string | null
    chatErrors?: { id: string; message: string; action?: string }[]
    onSend: (text: string) => void
    onCancel: () => void
    onReconnect: () => void
    onDismissError: (id: string) => void
    suggestions?: string[]
    activityLog?: Readable<ActivityEntry[]>
    /** Session this thread's pending question (below) should post its answer against — see OperatorQuestion.svelte. */
    sessionId?: string | null
    /** The session's open operator question, if any — rendered as an inline card at the end of the thread (the newest thing, blocking the agent until answered). */
    question?: SessionQuestion | null
    /** Pre-fills the composer. Used by "Ask Nomos" in the entity window so an
     *  investigation starts from what the operator was just looking at, rather
     *  than making them retype it. Left editable on purpose — it is a starting
     *  point, not a command. */
    initialDraft?: string
  } = $props()

  let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
  let messagesEnd = $state<HTMLDivElement | null>(null)
  let scrolledUp = $state(false)
  let container = $state<HTMLDivElement | null>(null)

  let indicatorDone = $state(false)
  let wasStreaming = $state(false)

  $effect(() => {
    if (streaming) {
      indicatorDone = false
      wasStreaming = true
    }
    if (!streaming && wasStreaming) {
      indicatorDone = true
      const t = setTimeout(() => {
        indicatorDone = false
        wasStreaming = false
      }, 3000)
      return () => clearTimeout(t)
    }
  })

  const indicatorLabel = $derived.by(() => {
    if (error) return error
    if (!streaming && indicatorDone) return 'Done'
    // Prefer the running PLAN STEP as the headline — it's stable across the
    // step's many tool calls, so the line stops rewriting itself on every
    // command (the "thinking overwrites itself" complaint, F6). Falls back to
    // the current tool only when there's no active step (a plan-less Q&A or
    // between steps), and to a plain "thinking…" otherwise.
    const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
    if (runningStep) return runningStep.description
    const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
    if (runningTool) return runningTool.description
    return 'Agent is thinking…'
  })

  // Resizable input area — drag the splitter above it to grow the textarea,
  // capped so it can't swallow the whole thread. Both the minimum and the
  // default are exactly one line: measured from the textarea's own
  // line-height/padding/border rather than hardcoded, so it stays correct if
  // that styling ever changes.
  let threadHeight = $state(0)
  let textareaRef = $state<HTMLTextAreaElement | null>(null)
  let inputWrapperRef = $state<HTMLDivElement | null>(null)
  let oneLinePx = $state(64)
  $effect(() => {
    if (!textareaRef || !inputWrapperRef) return
    const taCs = getComputedStyle(textareaRef)
    const lineHeight = parseFloat(taCs.lineHeight)
    if (!Number.isFinite(lineHeight)) return
    const taBoxY =
      parseFloat(taCs.paddingTop) +
      parseFloat(taCs.paddingBottom) +
      parseFloat(taCs.borderTopWidth) +
      parseFloat(taCs.borderBottomWidth)
    // The wrapper's own padding/border (space around the textarea, not part
    // of it) also has to fit inside the minimum, or the textarea gets
    // squeezed below one line once the pane is dragged down to it.
    const wrapperCs = getComputedStyle(inputWrapperRef)
    const wrapperBoxY =
      parseFloat(wrapperCs.paddingTop) +
      parseFloat(wrapperCs.paddingBottom) +
      parseFloat(wrapperCs.borderTopWidth) +
      parseFloat(wrapperCs.borderBottomWidth)
    oneLinePx = lineHeight + taBoxY + wrapperBoxY
  })
  const inputMinSize = $derived(threadHeight > 0 ? (oneLinePx / threadHeight) * 100 : 12)

  // Keep the input pinned to inputMinSize (one line) until the user actually
  // drags the splitter — not just on the first measurement. A floating
  // window's threadHeight is 0/wrong for a frame or two while it animates
  // open, and locking the percentage to that first reading left the input
  // several lines tall once the window reached full size (fixed 2026-07-21).
  let inputSize = $state(12)
  let userResizedInput = false
  $effect(() => {
    if (!userResizedInput) inputSize = inputMinSize
  })

  function isNearBottom(): boolean {
    if (!container) return true
    const { scrollTop, scrollHeight, clientHeight } = container
    return scrollHeight - scrollTop - clientHeight < 80
  }

  function onScroll() {
    scrolledUp = !isNearBottom()
  }

  // Auto-scroll to bottom on new messages (or a freshly-raised question) —
  // unless user scrolled up to read.
  $effect(() => {
    void messages
    void question
    if (streaming || !scrolledUp) {
      setTimeout(() => messagesEnd?.scrollIntoView({ behavior: 'smooth' }), 50)
    }
  })

  function render(text: string): string {
    const renderer = new marked.Renderer()
    renderer.code = function ({ text, lang }) {
      const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      return `<div class="code-block-wrapper relative group"><pre><code class="language-${lang || 'plaintext'}">${escaped}</code></pre><button class="code-copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre code').textContent)" title="Copy" aria-label="Copy code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>`
    }
    renderer.table = function (token) {
      const header = token.header.map((c: { text: string }) => `<th>${c.text}</th>`).join('')
      const body = token.rows
        .map((r: { text: string }[]) => `<tr>${r.map((c) => `<td>${c.text}</td>`).join('')}</tr>`)
        .join('')
      return `<div class="table-wrapper"><table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table></div>`
    }
    return DOMPurify.sanitize(marked.parse(text, { async: false, renderer }) as string)
  }

  function formatTime(iso: string): string {
    try {
      const d = new Date(iso)
      return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
    } catch {
      return ''
    }
  }

  function submit() {
    const text = input.trim()
    if (!text || streaming) return
    input = ''
    scrolledUp = false
    onSend(text)
  }

  function handleKeydown(e: KeyboardEvent) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      submit()
    }
  }

  function ask(q: string) {
    if (streaming) return
    onSend(q)
  }

  // Merge live streaming output (from the activity log's `run` entry) onto the
  // in-flight turn's tool calls so the inline tool card shows command output as
  // it arrives — the place the operator naturally "checks the tool". Only the
  // last assistant message can be streaming, so only it gets enriched; history
  // is untouched (and has no live output anyway). (F4)
  function toolsWithLive(
    tools: ToolCallResult[],
    entries: ActivityEntry[],
    isLiveTurn: boolean
  ): ToolCallResult[] {
    if (!isLiveTurn) return tools
    const liveById = new Map<string, string>()
    for (const e of entries) {
      if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
    }
    if (liveById.size === 0) return tools
    return tools.map((t) =>
      t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
    )
  }
</script>

<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
  <Splitpanes
    horizontal
    theme="oikos-theme"
    dblClickSplitter={false}
    class="min-h-0 flex-1"
    on:resize={() => (userResizedInput = true)}
  >
    <Pane class="flex flex-col">
      <div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
        <div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
          {#if messages.length === 0}
            <div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
              <div>
                <h2 class="text-xl font-semibold">Nomos</h2>
                <p class="mt-1 text-sm text-muted-foreground">
                  Your resident operator. Ask about the fleet, or tell it to act.
                </p>
              </div>
              {#if suggestions.length}
                <div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
                  {#each suggestions as q}
                    <Button
                      variant="outline"
                      size="sm"
                      class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
                      onclick={() => ask(q)}
                    >
                      {q}
                    </Button>
                  {/each}
                </div>
              {/if}
            </div>
          {/if}

          {#each messages as msg, idx (msg.id)}
            <div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
              {#if msg.role === 'user'}
                <div class="flex items-baseline gap-2 px-1">
                  <span class="text-[10px] font-medium text-muted-foreground/70">You</span>
                  {#if msg.created_at}
                    <span class="text-[9px] text-muted-foreground/50"
                      >{formatTime(msg.created_at)}</span
                    >
                  {/if}
                </div>
                <div
                  class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg"
                >
                  {msg.text}
                </div>
              {:else}
                {@const isLast = idx === messages.length - 1}
                {@const traceStatus = !isLast
                  ? 'idle'
                  : error
                    ? 'error'
                    : streaming
                      ? 'running'
                      : indicatorDone
                        ? 'done'
                        : 'idle'}
                <div class="flex w-full flex-col gap-2">
                  <div class="flex items-baseline gap-2 px-1">
                    <span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
                    {#if msg.created_at}
                      <span class="text-[9px] text-muted-foreground/50"
                        >{formatTime(msg.created_at)}</span
                      >
                    {/if}
                  </div>
                  <!-- The working trace sits above the answer: it's what happened
                   first, and collapsed it keeps a long tool run from burying
                   the text below it. -->
                  {#if msg.tools.length > 0 || traceStatus !== 'idle'}
                    <AgentTrace
                      tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
                      status={traceStatus}
                      label={traceStatus === 'idle' ? null : indicatorLabel}
                    />
                  {/if}
                  {#if msg.text}
                    <div
                      class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
                    >
                      <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
                      {@html render(msg.text)}
                      {#if isLast && streaming}
                        <span class="stream-cursor" aria-hidden="true"></span>
                      {/if}
                    </div>
                  {/if}
                </div>
              {/if}
            </div>
          {/each}
          {#if question}
            <OperatorQuestion {sessionId} {question} />
          {/if}
          <div bind:this={messagesEnd}></div>
        </div>
      </div>

      {#if connectionState === 'disconnected'}
        <div class="mx-auto w-full max-w-3xl px-4">
          <div
            class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
          >
            <RefreshCwIcon class="size-3 shrink-0 animate-spin text-warning" aria-hidden="true" />
            <span class="text-warning-foreground flex-1"
              >Connection dropped — the task is still running and will catch up here automatically.
              Reconnect to refresh now.</span
            >
            <Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
              >Reconnect</Button
            >
          </div>
        </div>
      {:else if connectionState === 'reconnecting'}
        <div class="mx-auto w-full max-w-3xl px-4">
          <div class="mb-2 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-xs">
            <RefreshCwIcon
              class="size-3 shrink-0 animate-spin text-muted-foreground"
              aria-hidden="true"
            />
            <span class="text-muted-foreground flex-1">Reconnecting to agent…</span>
          </div>
        </div>
      {/if}

      {#if error}
        <div class="mx-auto w-full max-w-3xl px-4">
          <div
            class="mb-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive"
          >
            {error}
          </div>
        </div>
      {/if}

      {#each chatErrors as err (err.id)}
        <div class="mx-auto w-full max-w-3xl px-4">
          <div
            class="mb-2 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
          >
            <span class="flex-1">{err.message}</span>
            {#if err.action}
              <Button
                size="xs"
                variant="ghost"
                class="h-6 text-[11px]"
                onclick={() => onDismissError(err.id)}>{err.action}</Button
              >
            {/if}
            <button
              class="ml-1 text-muted-foreground hover:text-foreground"
              onclick={() => onDismissError(err.id)}
              aria-label="Dismiss">×</button
            >
          </div>
        </div>
      {/each}
    </Pane>

    <Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
      <div
        class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
        bind:this={inputWrapperRef}
      >
        <form
          class="relative mx-auto flex h-full w-full max-w-3xl"
          onsubmit={(e) => {
            e.preventDefault()
            submit()
          }}
        >
          <Textarea
            bind:ref={textareaRef}
            bind:value={input}
            onkeydown={handleKeydown}
            placeholder="Ask Nomos anything…"
            class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
            disabled={streaming}
          />
          {#if streaming}
            <Button
              type="button"
              size="icon-sm"
              variant="secondary"
              class="absolute right-2 bottom-2 rounded-lg"
              onclick={onCancel}
              aria-label="Stop"
            >
              <SquareIcon class="size-3.5" />
            </Button>
          {:else}
            <Button
              type="submit"
              size="icon-sm"
              variant="secondary"
              class="absolute right-2 bottom-2 rounded-lg"
              disabled={!input.trim()}
              aria-label="Send"
            >
              <CornerDownLeftIcon class="size-3.5" />
            </Button>
          {/if}
        </form>
      </div>
    </Pane>
  </Splitpanes>
</div>

<style>
  /* ── Art Nouveau chat styling ── */

  /* Assistant message wrapper */
  .assistant-msg {
    position: relative;
  }

  /* User message — soft terracotta bubble, gentle lift */
  .user-msg {
    box-shadow: 0 1px 8px -4px var(--primary);
    overflow-wrap: break-word;
  }

  /* Prose overrides — deltas on top of the shared .markdown-body base
     (app.css) only. The template applies both classes together
     (class="markdown-body prose-chat ..."); everything below either adds a
     look .markdown-body doesn't have (li::marker, the pre/blockquote
     ::before ornaments, hr, strong, the table-wrapper, the code-copy
     button) or overrides a .markdown-body value that this "Art Nouveau"
     chat treatment wants different (code/pre padding, heading size, th/td
     padding, blockquote border color, link underline style). Anywhere a
     value is actually overridden, the selector is
     `.markdown-body.prose-chat` rather than `.prose-chat` alone —
     :global() selectors from two different <style> blocks land in the same
     stylesheet with no scoping to arbitrate between them, so equal
     specificity would leave the winner to injection order (unreliable
     across dev/build). The two-class selector's higher specificity wins
     deterministically regardless. */
  .prose-chat :global(li) {
    padding-left: 0.25rem;
  }
  .prose-chat :global(li::marker) {
    color: var(--primary);
  }

  .markdown-body.prose-chat :global(code) {
    border: 1px solid var(--border);
    padding: 0.15em 0.4em;
    color: var(--primary);
  }

  .markdown-body.prose-chat :global(pre) {
    padding: 0.75rem 0.875rem;
    position: relative;
  }
  .prose-chat :global(pre)::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 1px;
    background: linear-gradient(to right, transparent, var(--primary), transparent);
    opacity: 0.4;
  }
  .prose-chat :global(pre code) {
    color: inherit;
    border: none;
  }

  /* Section headings — serif (Inknut) with a short accent rule. Extra top
     margin separates sections; the first heading in a message doesn't. */
  .markdown-body.prose-chat :global(h1) {
    font-size: 1.15em;
    margin: 1.15rem 0 0.4rem;
    letter-spacing: 0.01em;
    position: relative;
    display: inline-block;
  }
  .markdown-body.prose-chat :global(h2) {
    font-size: 1.08em;
    margin: 1.15rem 0 0.4rem;
    letter-spacing: 0.01em;
    position: relative;
    display: inline-block;
  }
  .markdown-body.prose-chat :global(h3) {
    font-size: 1.02em;
    margin: 1.15rem 0 0.4rem;
    letter-spacing: 0.01em;
    position: relative;
    display: inline-block;
  }
  .prose-chat :global(> h1:first-child),
  .prose-chat :global(> h2:first-child),
  .prose-chat :global(> h3:first-child) {
    margin-top: 0;
  }
  .prose-chat :global(h1)::after,
  .prose-chat :global(h2)::after,
  .prose-chat :global(h3)::after {
    content: '';
    display: block;
    width: 2.5rem;
    height: 2px;
    margin-top: 4px;
    border-radius: 1px;
    background: linear-gradient(to right, var(--primary), transparent);
    opacity: 0.55;
  }

  .prose-chat :global(.table-wrapper) {
    overflow-x: auto;
    margin: 0 0 0.5rem;
  }
  .prose-chat :global(.table-wrapper table) {
    margin: 0;
  }
  .prose-chat :global(th) {
    background: var(--muted);
    font-weight: 600;
  }
  .markdown-body.prose-chat :global(th),
  .markdown-body.prose-chat :global(td) {
    padding: 0.3rem 0.6rem;
  }

  .markdown-body.prose-chat :global(blockquote) {
    border-left: 3px solid var(--primary);
    font-style: italic;
    position: relative;
  }
  .prose-chat :global(blockquote)::before {
    content: '“';
    position: absolute;
    left: -0.15rem;
    top: -0.35rem;
    font-size: 1.5rem;
    color: var(--primary);
    opacity: 0.6;
    font-style: normal;
    line-height: 1;
  }

  .prose-chat :global(hr) {
    border: none;
    height: 1px;
    margin: 0.75rem 0;
    background: linear-gradient(
      to right,
      transparent,
      var(--border) 20%,
      var(--border) 80%,
      transparent
    );
  }

  /* Bold is emphasis, not color — dark weight reads cleanly and lets the
     terracotta accent stay meaningful (code, headings, links). */
  .prose-chat :global(strong) {
    color: var(--foreground);
    font-weight: 600;
  }

  .markdown-body.prose-chat :global(a) {
    text-decoration: underline;
    text-decoration-style: dotted;
    text-underline-offset: 2px;
  }

  /* Input area ornament */
  .input-ornament::before {
    content: '';
    position: absolute;
    top: 0;
    left: 2rem;
    right: 2rem;
    height: 1px;
    background: linear-gradient(to right, transparent, var(--primary), transparent);
    opacity: 0.3;
  }

  /* Code copy button — global: injected via render() into {html} blocks */
  .prose-chat :global(.code-block-wrapper) {
    position: relative;
  }
  .prose-chat :global(.code-copy-btn) {
    position: absolute;
    top: 0.375rem;
    right: 0.375rem;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: 0.375rem;
    color: var(--muted-foreground);
    opacity: 0;
    transition:
      opacity 0.15s,
      color 0.15s;
    cursor: pointer;
    border: none;
    background: transparent;
  }
  .prose-chat :global(.code-block-wrapper:hover .code-copy-btn) {
    opacity: 1;
  }
  .prose-chat :global(.code-copy-btn:hover) {
    color: var(--foreground);
    background: var(--muted);
  }

  /* Streaming cursor — blinking block appended after streaming text */
  .stream-cursor {
    display: inline-block;
    width: 0.55em;
    height: 1.1em;
    background: var(--primary);
    opacity: 0.75;
    border-radius: 1px;
    margin-left: 1px;
    vertical-align: text-bottom;
    animation: cursor-blink 0.9s ease-in-out infinite;
  }

  @keyframes cursor-blink {
    0%,
    100% {
      opacity: 0.75;
    }
    50% {
      opacity: 0;
    }
  }
</style>
