import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'

import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'

import {
  cloneAttachments,
  DRAFT_PERSIST_DEBOUNCE_MS,
  isPendingDraftPersistCurrent,
  type QueueEditState
} from '../composer-utils'
import {
  type ComposerInsertMode,
  focusComposerInput,
  markActiveComposer,
  onComposerFocusRequest,
  onComposerInsertRefsRequest,
  onComposerInsertRequest,
  releaseActiveComposer
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import {
  composerPlainText,
  normalizeComposerEditorDom,
  placeCaretEnd,
  REF_RE,
  renderComposerContents
} from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'

interface UseComposerDraftArgs {
  activeQueueSessionKey: string | null
  focusKey: ChatBarProps['focusKey']
  inputDisabled: boolean
  queueEditRef: RefObject<QueueEditState | null>
  sessionId: string | null | undefined
}

/**
 * The composer's draft engine — the detached source-of-truth spine. The live
 * text lives in the contentEditable DOM + `draftRef`; React only sees coarse
 * edge selectors, so typing never re-renders the chrome. Owns the imperative
 * composer-runtime subscription (draftRef mirror + external repaint + debounced
 * per-session stash), the edit primitives (append/insert/inline-refs), focus,
 * and per-session load/clear/stash/restore. The contentEditable *event*
 * handlers stay in ChatBar (they bridge into the trigger engine) and drive the
 * primitives exposed here.
 */
export function useComposerDraft({
  activeQueueSessionKey,
  focusKey,
  inputDisabled,
  queueEditRef,
  sessionId
}: UseComposerDraftArgs) {
  const aui = useAui()
  const composerRuntime = useComposerRuntime()
  // Which composer this is on the focus bus + which attachment set it owns.
  const { attachments: attachmentScope, target } = useComposerScope()

  // Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
  // steerable-vs-slash), so typing within a line costs no render.
  const hasText = useAuiState(s => s.composer.text.trim().length > 0)
  const isHelpHint = useAuiState(s => s.composer.text === '?')

  const isSteerableText = useAuiState(s => {
    const trimmed = s.composer.text.trim()

    return trimmed.length > 0 && !SLASH_COMMAND_RE.test(trimmed)
  })

  // assistant-ui's composer mutators throw when the core isn't bound yet (a
  // startup/thread-swap window); the DOM + draftRef hold the text and the
  // subscription reconciles once it binds, so swallow the premature write.
  const setComposerText = useCallback(
    (value: string) => {
      try {
        aui.composer().setText(value)
      } catch {
        // Composer core not bound yet — DOM/draftRef carry the text.
      }
    },
    [aui]
  )

  const editorRef = useRef<HTMLDivElement | null>(null)
  const draftRef = useRef('')
  const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
  const draftPersistTimerRef = useRef<number | undefined>(undefined)
  const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
  activeQueueSessionKeyRef.current = activeQueueSessionKey
  // Owned only by the swap effect below — unlike activeQueueSessionKeyRef this
  // does NOT update on every render, so it always reflects the session whose
  // text is actually loaded in the editor. Async work (debounce timers,
  // pagehide flush) must persist against this, not the render-time ref, or a
  // session switch mid-flight files one session's draft under another's key
  // (#54527).
  const draftScopeRef = useRef(activeQueueSessionKey)
  const sessionIdRef = useRef(sessionId)
  sessionIdRef.current = sessionId
  const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current)
  queueEditStateRef.current = queueEditRef.current

  const [focusRequestId, setFocusRequestId] = useState(0)

  const focusInput = useCallback(() => {
    focusComposerInput(editorRef.current)
    markActiveComposer(target)
  }, [target])

  const requestMainFocus = useCallback(() => {
    setFocusRequestId(id => id + 1)
  }, [])

  // The single write path for programmatic draft mutations: mirror → AUI state →
  // repaint the editor (caret to end). Repaints even while focused — inserts /
  // restores run mid-focus, and the runtime sync only repaints an unfocused
  // editor — so the visible text never lags the store.
  const paintDraft = useCallback(
    (next: string, focus = true) => {
      draftRef.current = next
      setComposerText(next)

      const editor = editorRef.current

      if (editor) {
        renderComposerContents(editor, next, { trailingCommitted: true })
        placeCaretEnd(editor)
      }

      if (focus) {
        requestMainFocus()
      }
    },
    [requestMainFocus, setComposerText]
  )

  const appendExternalText = useCallback(
    (text: string, mode: ComposerInsertMode) => {
      const value = text.trim()

      if (!value) {
        return
      }

      const base = mode === 'inline' ? draftRef.current.trimEnd() : draftRef.current
      const sep = mode === 'inline' ? (base ? ' ' : '') : base && !base.endsWith('\n') ? '\n\n' : ''

      paintDraft(`${base}${sep}${value}`)
    },
    [paintDraft]
  )

  useEffect(() => {
    if (!inputDisabled) {
      focusInput()
    }
  }, [focusInput, focusKey, focusRequestId, inputDisabled])

  // The mirror of the `markActiveComposer` above: give the key back when this
  // composer goes away (a session tile closing, a pane unmounting). Covers both
  // claim sites for this composer — `focusInput` here and ChatBar's `onFocus` —
  // since they mark the same scope target. Without it `'active'` keeps
  // resolving to a dead tile and every routed focus/insert request is dropped.
  // (Heal-to-visible in focus.ts covers the keep-alive-tab case where the pane
  // stays mounted behind the front tab; this covers true unmounts.)
  useEffect(() => () => releaseActiveComposer(target), [target])

  useEffect(() => {
    if (inputDisabled) {
      return undefined
    }

    const offFocus = onComposerFocusRequest(({ target: requested, typeChar }) => {
      if (requested !== target) {
        return
      }

      // Type-to-focus appends at end; bare Enter just focuses.
      if (typeChar) {
        paintDraft(`${draftRef.current}${typeChar}`, true)

        return
      }

      setFocusRequestId(id => id + 1)
    })

    const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
      if (requested === target) {
        appendExternalText(text, mode)
      }
    })

    return () => {
      offFocus()
      offInsert()
    }
  }, [appendExternalText, inputDisabled, paintDraft, target])

  const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
    stashSessionDraft(scope, text, attachments)

  const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
    // Diagnostic breadcrumb for #59305-class reports: identifies WHAT kind of
    // state got restored into the composer (session switch, queue-edit
    // restore, history browse) without logging any raw content. REF_RE has the
    // global flag — testing against a throwaway clone avoids mutating the
    // shared instance's lastIndex, which would otherwise corrupt this check on
    // the next call.
    if (attachments.length > 0 || new RegExp(REF_RE.source, REF_RE.flags).test(text)) {
      console.debug('[composer-rehydrate]', {
        attachmentCount: attachments.length,
        attachmentKinds: attachments.map(a => a.kind),
        hasTextRefs: new RegExp(REF_RE.source, REF_RE.flags).test(text),
        scope: activeQueueSessionKeyRef.current
      })
    }

    attachmentScope.$attachments.set(cloneAttachments(attachments))
    paintDraft(text, false)
  }

  const clearDraft = useCallback(() => {
    setComposerText('')
    draftRef.current = ''

    if (editorRef.current) {
      editorRef.current.replaceChildren()
    }
  }, [setComposerText])

  // Read the editor's current plain text into draftRef + composer state. This
  // closes the "queued rAF flush hasn't run yet" window so scope-swap/pagehide
  // persistence captures the latest keystrokes.
  const syncDraftFromEditor = useCallback(() => {
    const editor = editorRef.current

    if (!editor) {
      return draftRef.current
    }

    // Same normalize-then-sanitize the rAF flush does. An emptied editor still
    // holds the placeholder <br> that keeps the contenteditable from collapsing
    // to a sliver, and that serializes as "\n" — so an editor the user just
    // cleared would otherwise stash a one-newline draft and come back non-empty.
    normalizeComposerEditorDom(editor)

    const text = sanitizeComposerInput(composerPlainText(editor))

    if (text !== draftRef.current) {
      draftRef.current = text
      setComposerText(text)
    }

    return text
  }, [setComposerText])

  // Imperative draft sync — the spine of the "work only when work is to be
  // performed" model. Subscribing to the composer runtime directly (not
  // `useAuiState(text)` + a `[draft]` effect) keeps per-keystroke text out of
  // React, so typing never re-renders the chrome. On each change we (1) mirror
  // text into draftRef, (2) repaint the editor only when the change came from
  // OUTSIDE it (programmatic clear/restore/insert; the focused editor is the
  // source otherwise), and (3) schedule the debounced per-session stash.
  // Browsing history / editing a queued prompt suppress the stash so recalled
  // text never clobbers the draft.
  // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
  useEffect(() => {
    const sync = () => {
      const text = composerRuntime.getState().text
      draftRef.current = text

      const editor = editorRef.current

      if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
        renderComposerContents(editor, text, { trailingCommitted: true })
      }

      if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
        return
      }

      const scope = draftScopeRef.current
      const entry = { scope, text }
      pendingDraftPersistRef.current = entry
      window.clearTimeout(draftPersistTimerRef.current)
      draftPersistTimerRef.current = window.setTimeout(() => {
        // Integrity guard (defense-in-depth, #54527): only commit if this is
        // still the pending write on file. A session swap or a newer
        // keystroke clears/replaces it before firing in the normal case; this
        // catches any future call site that skips that bookkeeping instead of
        // silently filing text under the wrong session.
        if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) {
          return
        }

        pendingDraftPersistRef.current = null
        stashAt(scope, text)
      }, DRAFT_PERSIST_DEBOUNCE_MS)
    }

    const unsubscribe = composerRuntime.subscribe(sync)

    return () => {
      unsubscribe()
      window.clearTimeout(draftPersistTimerRef.current)
    }
  }, [composerRuntime, queueEditRef])

  const insertText = (text: string) => {
    const base = draftRef.current
    const sep = base && !base.endsWith('\n') ? '\n' : ''

    paintDraft(`${base}${sep}${text}`)
  }

  // insertInlineRefs mutates the editor in place (chips), so it can't go through
  // paintDraft's re-render — it mirrors the resulting plain text and refocuses.
  const insertInlineRefs = (refs: InlineRefInput[]) => {
    const editor = editorRef.current

    if (!editor) {
      return false
    }

    const nextDraft = insertInlineRefsIntoEditor(editor, refs)

    if (nextDraft === null) {
      return false
    }

    draftRef.current = nextDraft
    setComposerText(nextDraft)
    requestMainFocus()

    return true
  }

  // Latest-closure ref so the once-only subscription always calls the current
  // insertInlineRefs without re-subscribing every render.
  const insertInlineRefsRef = useRef(insertInlineRefs)
  insertInlineRefsRef.current = insertInlineRefs

  useEffect(() => {
    return onComposerInsertRefsRequest(({ refs, target: requested }) => {
      if (requested === target) {
        insertInlineRefsRef.current(refs)
      }
    })
  }, [target])

  // Per-thread draft swap — the composer's only session coupling. Lifecycle
  // never clears composer state; this effect alone stashes on leave, restores
  // on enter. Keyed writes are idempotent, so no skip-sentinel.
  //
  // MUST be a layout effect, not a passive one: it swaps attachmentScope's
  // module-level $attachments atom, and a passive effect fires only after the
  // browser paints the new session's view — leaving a window where the DOM
  // already shows session B while $attachments (and therefore ChatBar's
  // `attachments` prop) still holds session A's chips. A submit fired in that
  // window (e.g. a fast session switch immediately followed by Enter) would
  // ship A's attachments into B's turn (#59305). useLayoutEffect closes the
  // window by running before paint.

  useLayoutEffect(() => {
    // A pending debounce timer from the outgoing session is now stale — its
    // scope was correct when scheduled, but the authoritative stash below
    // (and the cleanup on the way out) already covers that text. Letting it
    // fire later would just clobber with an older snapshot.
    window.clearTimeout(draftPersistTimerRef.current)
    pendingDraftPersistRef.current = null
    draftScopeRef.current = activeQueueSessionKey

    const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
    loadIntoComposer(text, attachments)

    return () => {
      const latestText = syncDraftFromEditor()
      const editing = queueEditStateRef.current

      if (editing?.sessionKey === activeQueueSessionKey) {
        stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
      } else if (!isBrowsingHistory(sessionId)) {
        stashAt(activeQueueSessionKey, latestText)
      }
    }
  }, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps

  // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
  // inside the debounce/rAF window would drop trailing keystrokes without this.
  // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
  useEffect(() => {
    const flushPendingDraftPersist = () => {
      const scope = draftScopeRef.current
      const editing = queueEditStateRef.current

      if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) {
        return
      }

      const latestText = syncDraftFromEditor()
      pendingDraftPersistRef.current = null
      stashAt(scope, latestText)
    }

    window.addEventListener('pagehide', flushPendingDraftPersist)

    return () => {
      window.removeEventListener('pagehide', flushPendingDraftPersist)
      flushPendingDraftPersist()
    }
  }, [syncDraftFromEditor])

  return {
    activeQueueSessionKeyRef,
    clearDraft,
    draftRef,
    editorRef,
    focusInput,
    hasText,
    insertInlineRefs,
    insertText,
    isHelpHint,
    isSteerableText,
    loadIntoComposer,
    requestMainFocus,
    sessionIdRef,
    setComposerText,
    stashAt,
    syncDraftFromEditor
  }
}
