import {
  ActionBarPrimitive,
  AuiIf,
  BranchPickerPrimitive,
  ErrorPrimitive,
  MessagePrimitive,
  ThreadPrimitive,
  type ToolCallMessagePartProps,
  useAuiState
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import {
  CheckIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  CopyIcon,
  GitBranchIcon,
  Loader2Icon,
  MoreHorizontalIcon,
  RefreshCwIcon,
  Volume2Icon,
  VolumeXIcon
} from 'lucide-react'
import {
  type FC,
  type ReactNode,
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState
} from 'react'

import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
import { DirectiveText } from '@/components/assistant-ui/directive-text'
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Loader } from '@/components/ui/loader'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { setThreadScrolledUp } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'

const THINKING_FACES = [
  '(｡•́︿•̀｡)',
  '(◔_◔)',
  '(¬‿¬)',
  '( •_•)>⌐■-■',
  '(⌐■_■)',
  '(´･_･`)',
  '◉_◉',
  '(°ロ°)',
  '( ˘⌣˘)♡',
  'ヽ(>∀<☆)☆',
  '٩(๑❛ᴗ❛๑)۶',
  '(⊙_⊙)',
  '(¬_¬)',
  '( ͡° ͜ʖ ͡°)',
  'ಠ_ಠ'
]

const THINKING_VERBS = [
  'pondering',
  'contemplating',
  'musing',
  'cogitating',
  'ruminating',
  'deliberating',
  'mulling',
  'reflecting',
  'processing',
  'reasoning',
  'analyzing',
  'computing',
  'synthesizing',
  'formulating',
  'brainstorming'
]

type ThreadLoadingState = 'response' | 'session' | 'working'

interface MessageActionProps {
  messageId: string
  messageText: string
  onBranchInNewChat?: (messageId: string) => void
}

const BOTTOM_DISTANCE_PX = 24
let readAloudAudio: HTMLAudioElement | null = null

function isNearBottom(el: HTMLElement): boolean {
  return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
}

function partText(part: unknown): string {
  if (typeof part === 'string') {
    return part
  }

  if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
    return part.text
  }

  return ''
}

function messageContentText(content: unknown): string {
  if (typeof content === 'string') {
    return content.trim()
  }

  return Array.isArray(content) ? content.map(partText).join('').trim() : ''
}

export const Thread: FC<{
  intro?: IntroProps
  loading?: ThreadLoadingState
  onBranchInNewChat?: (messageId: string) => void
  sessionKey?: string | null
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
  const viewportRef = useRef<HTMLDivElement | null>(null)
  const contentRef = useRef<HTMLDivElement | null>(null)
  const messageCount = useAuiState(s => s.thread.messages.length)
  const isRunning = useAuiState(s => s.thread.isRunning)
  const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
  const shouldStickToBottomRef = useRef(true)
  const scrollFrameRef = useRef<number | null>(null)
  const sessionKeyRef = useRef<string | null>(sessionKey ?? null)

  const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
    const nearBottom = isNearBottom(event.currentTarget)
    shouldStickToBottomRef.current = nearBottom
    setThreadScrolledUp(!nearBottom)
  }, [])

  const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
    if (event.deltaY < 0) {
      shouldStickToBottomRef.current = false
      setThreadScrolledUp(true)
    }
  }, [])

  const scrollToBottom = useCallback(() => {
    const viewport = viewportRef.current

    if (!viewport) {
      return
    }

    viewport.scrollTop = viewport.scrollHeight
    shouldStickToBottomRef.current = true
    setThreadScrolledUp(false)
  }, [])

  const scheduleScrollToBottom = useCallback(() => {
    if (scrollFrameRef.current !== null) {
      window.cancelAnimationFrame(scrollFrameRef.current)
    }

    scrollFrameRef.current = window.requestAnimationFrame(() => {
      scrollFrameRef.current = null
      scrollToBottom()
    })
  }, [scrollToBottom])

  useEffect(() => {
    return () => {
      if (scrollFrameRef.current !== null) {
        window.cancelAnimationFrame(scrollFrameRef.current)
      }

      setThreadScrolledUp(false)
    }
  }, [])

  useLayoutEffect(() => {
    const viewport = viewportRef.current

    if (!viewport) {
      return
    }

    const nextSessionKey = sessionKey ?? null
    const sessionChanged = sessionKeyRef.current !== nextSessionKey
    sessionKeyRef.current = nextSessionKey
    const force = loading === 'session' || sessionChanged

    if (!force && !shouldStickToBottomRef.current) {
      return
    }

    scheduleScrollToBottom()
  }, [isRunning, lastMessageId, loading, messageCount, scheduleScrollToBottom, sessionKey])

  useLayoutEffect(() => {
    const content = contentRef.current
    const viewport = viewportRef.current

    if (!content || !viewport) {
      return
    }

    let previousHeight = content.getBoundingClientRect().height

    const observer = new ResizeObserver(entries => {
      const height = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height

      if (height === previousHeight) {
        return
      }

      previousHeight = height

      if (!shouldStickToBottomRef.current && !isNearBottom(viewport)) {
        return
      }

      scheduleScrollToBottom()
    })

    observer.observe(content)

    return () => observer.disconnect()
  }, [scheduleScrollToBottom])

  return (
    <GeneratedImageProvider>
      <ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
        <AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>

        <ThreadPrimitive.Viewport
          autoScroll={false}
          className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)]"
          data-slot="aui_thread-viewport"
          onScroll={handleScroll}
          onWheel={handleWheel}
          ref={viewportRef}
          scrollToBottomOnInitialize
          scrollToBottomOnRunStart
          scrollToBottomOnThreadSwitch
        >
          <div className="flex w-full flex-col gap-3" ref={contentRef}>
            <ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
            {loading === 'response' && <ResponseLoadingIndicator />}
            {loading === 'working' && <WorkingIndicator />}
          </div>
          <ThreadPrimitive.ViewportFooter className="h-[220px] shrink-0" />
        </ThreadPrimitive.Viewport>
        {loading === 'session' && <CenteredThreadSpinner />}
      </ThreadPrimitive.Root>
    </GeneratedImageProvider>
  )
}

const CenteredThreadSpinner: FC = () => (
  <div
    aria-label="Loading session"
    className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
    role="status"
  >
    <Loader
      aria-hidden="true"
      className="size-12 text-primary/70"
      pathSteps={220}
      role="presentation"
      strokeScale={0.72}
      type="rose-curve"
    />
  </div>
)

const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
  const role = useAuiState(s => s.message.role)
  const isEditing = useAuiState(s => s.message.composer.isEditing)

  // The runtime synthesizes an empty assistant placeholder while isRunning is true
  // (last message is user). Rendering the full `MessagePrimitive.Root` for it adds
  // ~36px of invisible chrome (gap-2 + min-h-7 footer) which can push the
  // loading affordance too far below the user message. Skip it —
  // `ResponseLoadingIndicator` in the viewport handles the loading affordance directly.
  const isPlaceholder = useAuiState(
    s => s.message.role === 'assistant' && s.message.status?.type === 'running' && s.message.content.length === 0
  )

  if (isEditing) {
    return <EditComposer />
  }

  if (role === 'user') {
    return <UserMessage />
  }

  if (isPlaceholder) {
    return null
  }

  return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
}

const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
  const messageId = useAuiState(s => s.message.id)
  const content = useAuiState(s => s.message.content)
  const messageText = messageContentText(content)

  return (
    <MessagePrimitive.Root
      className="group flex w-full flex-col gap-2 self-start"
      data-role="assistant"
      data-slot="aui_assistant-message-root"
    >
      <div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
        <MessagePrimitive.Parts
          components={{
            Text: MarkdownText,
            Reasoning: ReasoningPart,
            tools: { Fallback: ChainToolFallback }
          }}
        />
        <MessagePrimitive.Error>
          <ErrorPrimitive.Root
            className="mt-2 rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"
            role="alert"
          >
            <ErrorPrimitive.Message />
          </ErrorPrimitive.Root>
        </MessagePrimitive.Error>
      </div>
      <div className="min-h-6">
        <AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
      </div>
    </MessagePrimitive.Root>
  )
}

const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70'

const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
  <div aria-label={label} aria-live="polite" className={STATUS_ROW_CLASS} role="status">
    {children}
  </div>
)

const ResponseLoadingIndicator: FC = () => {
  const [tick, setTick] = useState(0)
  const elapsed = useElapsedSeconds()

  useEffect(() => {
    const id = window.setInterval(() => setTick(t => t + 1), 900)

    return () => window.clearInterval(id)
  }, [])

  const face = THINKING_FACES[tick % THINKING_FACES.length]
  const verb = THINKING_VERBS[tick % THINKING_VERBS.length]

  return (
    <StatusRow label="Hermes is loading a response">
      <span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
        {face} {verb}…
      </span>
      <ActivityTimerText seconds={elapsed} />
    </StatusRow>
  )
}

const WorkingIndicator: FC = () => {
  const elapsed = useElapsedSeconds()

  return (
    <StatusRow label="Hermes is still working">
      <Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
      <span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working…</span>
      <ActivityTimerText seconds={elapsed} />
    </StatusRow>
  )
}

const ImageGenerateTool: FC<ToolCallMessagePartProps> = ({ result }) => {
  const generatedImage = useGeneratedImageContext()
  const running = result === undefined

  useEffect(() => {
    generatedImage?.setPending(running)
  }, [generatedImage, running])

  if (!running) {
    return null
  }

  return (
    <div className="mt-2">
      <ImageGenerationPlaceholder />
    </div>
  )
}

const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
  if (props.toolName === 'image_generate') {
    return <ImageGenerateTool {...props} />
  }

  return <ToolFallback {...props} />
}

const ThinkingDisclosure: FC<{
  children: ReactNode
  pending?: boolean
}> = ({ children, pending = false }) => {
  const [open, setOpen] = useState(false)
  const elapsed = useElapsedSeconds(pending)

  return (
    <div className="mb-3 text-sm text-muted-foreground">
      <button
        aria-expanded={open}
        className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
        onClick={() => setOpen(value => !value)}
        type="button"
      >
        <ChevronRightIcon
          className={cn('size-3 shrink-0 text-muted-foreground/80 transition-transform', open && 'rotate-90')}
        />
        <span
          className={cn('shrink-0 text-xs font-medium text-foreground/70', pending && 'shimmer text-foreground/55')}
        >
          Thinking
        </span>
        {pending && <ActivityTimerText seconds={elapsed} />}
      </button>
      {open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
    </div>
  )
}

const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => (
  <div className="mb-1 mt-1">
    <ThinkingDisclosure pending={status?.type === 'running'}>
      <div
        className={cn(
          'whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground/85',
          status?.type === 'running' && 'shimmer text-muted-foreground/55'
        )}
      >
        {text}
      </div>
    </ThinkingDisclosure>
  </div>
)

const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })

const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
  day: 'numeric',
  hour: 'numeric',
  minute: '2-digit',
  month: 'short'
})

function startOfDay(d: Date): number {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}

function formatMessageTimestamp(value: Date | string | number | undefined): string {
  if (!value) {
    return ''
  }

  const date = value instanceof Date ? value : new Date(value)

  if (Number.isNaN(date.getTime())) {
    return ''
  }

  const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)

  if (dayDelta === 0) {
    return `Today, ${TIME_FMT.format(date)}`
  }

  if (dayDelta === 1) {
    return `Yesterday, ${TIME_FMT.format(date)}`
  }

  return SHORT_FMT.format(date)
}

const ACTION_BAR_CLASS = cn(
  'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100',
  'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100',
  'focus-within:pointer-events-auto focus-within:opacity-100'
)

const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
  const [menuOpen, setMenuOpen] = useState(false)

  return (
    <div className="relative h-6 w-20 shrink-0">
      <ActionBarPrimitive.Root
        className={cn(ACTION_BAR_CLASS, menuOpen && 'pointer-events-auto opacity-100')}
        hideWhenRunning
      >
        <CopyMessageButton text={messageText} />
        <ActionBarPrimitive.Reload asChild>
          <TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
            <RefreshCwIcon />
          </TooltipIconButton>
        </ActionBarPrimitive.Reload>
        <DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
          <DropdownMenuTrigger asChild>
            <TooltipIconButton tooltip="More actions">
              <MoreHorizontalIcon />
            </TooltipIconButton>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
            <MessageTimestamp />
            <DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
              <GitBranchIcon />
              Branch in new chat
            </DropdownMenuItem>
            <ReadAloudItem messageId={messageId} text={messageText} />
          </DropdownMenuContent>
        </DropdownMenu>
      </ActionBarPrimitive.Root>
    </div>
  )
}

const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
  const [copied, setCopied] = useState(false)

  const copy = useCallback(async () => {
    if (!text) {
      return
    }

    try {
      await navigator.clipboard.writeText(text)
      triggerHaptic('selection')
      setCopied(true)
      window.setTimeout(() => setCopied(false), 2000)
    } catch (error) {
      notifyError(error, 'Copy failed')
    }
  }, [text])

  return (
    <TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
      {copied ? <CheckIcon /> : <CopyIcon />}
    </TooltipIconButton>
  )
}

const ReadAloudItem: FC<{ messageId: string; text: string }> = ({ messageId, text }) => {
  const voicePlayback = useStore($voicePlayback)

  const readAloudStatus =
    voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'

  const isPreparing = readAloudStatus === 'preparing'
  const isSpeaking = readAloudStatus === 'speaking'
  const anyPlaybackActive = voicePlayback.status !== 'idle'
  const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon

  const read = useCallback(async () => {
    if (!text || $voicePlayback.get().status !== 'idle') {
      return
    }

    try {
      await playSpeechText(text, { messageId, source: 'read-aloud' })
    } catch (error) {
      notifyError(error, 'Read aloud failed')
    }
  }, [messageId, text])

  return (
    <DropdownMenuItem
      disabled={isPreparing || (!isSpeaking && (anyPlaybackActive || !text))}
      onSelect={e => {
        e.preventDefault()
        void (isSpeaking ? stopVoicePlayback() : read())
      }}
    >
      <Icon className={isPreparing ? 'animate-spin' : undefined} />
      {isPreparing ? 'Preparing audio...' : isSpeaking ? 'Stop reading' : 'Read aloud'}
    </DropdownMenuItem>
  )
}

const MessageTimestamp: FC = () => {
  const createdAt = useAuiState(s => s.message.createdAt)
  const label = formatMessageTimestamp(createdAt)

  if (!label) {
    return null
  }

  return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
}

const AssistantFooter: FC<MessageActionProps> = props => (
  <div className="flex min-h-6 flex-col items-start gap-1">
    <BranchPickerPrimitive.Root
      className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
      hideWhenSingleBranch
    >
      <BranchPickerPrimitive.Previous className={branchButtonClass}>
        <ChevronLeftIcon className="size-3.5" />
      </BranchPickerPrimitive.Previous>
      <span className="tabular-nums">
        <BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
      </span>
      <BranchPickerPrimitive.Next className={branchButtonClass}>
        <ChevronRightIcon className="size-3.5" />
      </BranchPickerPrimitive.Next>
    </BranchPickerPrimitive.Root>
    <AssistantActionBar {...props} />
  </div>
)

const branchButtonClass =
  'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'

const UserMessage: FC = () => {
  return (
    <MessagePrimitive.Root
      className="group flex max-w-[min(72%,34rem)] flex-col gap-2 self-end rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2"
      data-role="user"
      data-slot="aui_user-message-root"
    >
      <div className="wrap-anywhere whitespace-pre-line leading-[1.48] text-foreground/95">
        <MessagePrimitive.Parts components={{ Text: DirectiveText }} />
      </div>
    </MessagePrimitive.Root>
  )
}

const EditComposer: FC = () => {
  // Editing requires a real onEdit implementation against Hermes history.
  // Hide the edit composer until that contract is implemented.
  return null
}
