import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
import { Alert } from '@/components/ui/Alert'
import { IconButton } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn'
import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent'
import type { useUndo } from '@/lib/history'
import { lazyPage } from '@/lib/lazyPage'

// Lazy so the markdown renderer loads only when an assistant message renders.
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')

/** Falls back to the raw text if the markdown chunk can't load or throws. */
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
  state = { failed: false }
  static getDerivedStateFromError() {
    return { failed: true }
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children
  }
}

/**
 * Talk to the garden assistant beside the canvas — watching the plan change as
 * it works IS the confirmation. Every turn lands as one undoable step, so each
 * assistant reply that changed something carries its own Undo.
 */
export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType<typeof useUndo>; large?: boolean }) {
  const history = useAgentHistory(gardenId, true)
  const clear = useClearAgentHistory(gardenId)
  const refresh = useAgentRefresh(gardenId)
  const [input, setInput] = useState('')
  const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [warning, setWarning] = useState<string | null>(null)
  const abort = useRef<AbortController | null>(null)
  const bottom = useRef<HTMLDivElement>(null)
  const thread = useRef<HTMLDivElement>(null)
  // Whether the view is pinned to the end of the thread. It follows new
  // content only while it is; a person who scrolled up to read something is
  // left there until they come back down or send the next message.
  const stuck = useRef(true)

  // Deliberately NOT aborted on unmount: selecting a bed switches the rail to
  // the inspector, and that must not kill a turn mid-flight. The request runs
  // on; the exchange is persisted server-side; coming back shows it.
  // Instant, not smooth: Chrome left the thread at the top with
  // `behavior: 'smooth'` — a smooth scrollIntoView into this nested scroller
  // never moved it, so every new reply landed out of view below a long
  // conversation (found live, 2026-08-23). The instant form scrolls. It runs
  // on every step of a turn too, so it is gated on `stuck`: following the
  // stream is right when the person is at the end, and a snap they didn't
  // ask for when they had scrolled up.
  useEffect(() => {
    if (stuck.current) bottom.current?.scrollIntoView({ block: 'end' })
  }, [history.data, pending])
  const onThreadScroll = () => {
    const el = thread.current
    if (el) stuck.current = el.scrollHeight - el.clientHeight - el.scrollTop < 80
  }

  const send = () => {
    const message = input.trim()
    if (!message || pending) return
    setInput('')
    stuck.current = true // sending is a return to the end of the thread
    setError(null)
    setWarning(null)
    setPending({ message, steps: [] })
    const controller = new AbortController()
    abort.current = controller
    void streamChat(
      gardenId,
      message,
      {
        onStep: (step) => {
          setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
          refresh.canvas()
        },
        onDone: (turn: AgentTurn) => {
          setPending(null)
          refresh.everything()
          if (turn.truncated) setError('That turned into more steps than I should take at once — check what changed before continuing.')
        },
        onWarning: setWarning,
        onError: (m) => {
          setPending(null)
          setError(m)
          refresh.everything()
        },
      },
      controller.signal,
    )
  }

  const stop = () => {
    abort.current?.abort()
    setPending(null)
    refresh.everything()
  }

  const messages = history.data ?? []
  const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]'

  return (
    <div className="flex min-h-0 flex-1 flex-col gap-2.5">
      {/* The thread scrolls on its own so the composer stays put: with the whole
          tab scrolling, a long conversation pushed the input off the bottom and
          every new message scrolled it further away. */}
      <div ref={thread} onScroll={onThreadScroll} className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto">
      {!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
      {history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
      {history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
      {history.isSuccess && messages.length === 0 && !pending && (
        <p className="text-[13px] leading-relaxed text-ink-mute">
          Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the
          west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.
        </p>
      )}
      {messages.map((m) =>
        m.role === 'user' ? (
          <div key={m.id} className={cn('self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText, 'max-w-[85%]')}>
            {m.body}
          </div>
        ) : (
          <div key={m.id} className="flex min-w-0 max-w-[90%] flex-col items-start gap-1 self-start">
            {/* min-w-0 / max-w-full: a wide markdown table scrolls inside its own
                wrapper instead of widening the bubble past the panel. */}
            <div className={cn('min-w-0 max-w-full rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
              <MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
                <Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
                  <MarkdownMessage>{m.body}</MarkdownMessage>
                </Suspense>
              </MarkdownBoundary>
            </div>
            {m.changeSetId != null && canEdit && <TurnUndo changeSetId={m.changeSetId} undo={undo} />}
          </div>
        ),
      )}
      {pending && (
        <>
          <div className={cn('max-w-[85%] self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText)}>
            {pending.message}
          </div>
          <div className={cn('max-w-[90%] self-start rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
            {pending.steps.map((s) => (
              <div key={s.index} className="text-xs text-ink-mute">
                {describeStep(s)}…
              </div>
            ))}
            <p className="flex items-center gap-1.5 text-xs text-ink-mute">
              <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
              {pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
            </p>
          </div>
        </>
      )}
      {warning && <Alert tone="info">{warning}</Alert>}
      {error && <Alert>{error}</Alert>}
      <div ref={bottom} />
      </div>
      <div className="flex flex-col gap-1.5 pt-1.5">
        {messages.length > 0 && !pending && (
          <button
            type="button"
            className="btn btn-ghost self-end px-2 py-0.5 text-[11.5px]"
            disabled={clear.isPending}
            onClick={() => clear.mutate(undefined, { onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")) })}
          >
            Start over
          </button>
        )}
        <div className="flex gap-1.5">
          <input
            className={cn('input', large && 'input-lg')}
            placeholder="Ask about your garden…"
            aria-label="Message the assistant"
            value={input}
            disabled={!!pending}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') {
                e.preventDefault()
                send()
              }
            }}
          />
          {pending ? (
            <IconButton label="Stop" icon="square" iconSize={large ? 16 : 15} size={large ? 44 : 36} onClick={stop} />
          ) : (
            <IconButton label="Send" icon="send" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={!input.trim()} onClick={send} />
          )}
        </div>
      </div>
    </div>
  )
}

/** Undo on the turn itself, so the common case never involves the History tab. */
function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType<typeof useUndo> }) {
  const outcome = undo.outcomeFor(changeSetId)
  return (
    <div className="flex flex-col items-start gap-0.5 pl-1">
      <button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" disabled={outcome?.tone === 'pending'} onClick={() => undo.undo({ id: changeSetId })}>
        {outcome?.tone === 'pending' ? 'Undoing…' : 'Undo this'}
      </button>
      {outcome && outcome.tone !== 'pending' && (
        <p role="status" className={cn('text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
          {outcome.message}
        </p>
      )}
    </div>
  )
}
