import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import MessageBubble from './MessageBubble'

/**
 * Right panel: scrollable thread of messages with send form.
 */
export default function ConversationThread({
  conversationId,
  conversation,
  realtimeEnabled = false,
  currentUserId,
  currentUsername,
  apiFetch,
  onBack,
  onMarkRead,
  onConversationUpdated,
}) {
  const [messages, setMessages]     = useState([])
  const [loading, setLoading]       = useState(true)
  const [sending, setSending]       = useState(false)
  const [body, setBody]             = useState('')
  const [nextCursor, setNextCursor] = useState(null)
  const [loadingMore, setLoadingMore] = useState(false)
  const [error, setError]           = useState(null)
  const [attachments, setAttachments] = useState([])
  const [uploadProgress, setUploadProgress] = useState(null)
  const [typingUsers, setTypingUsers] = useState([])
  const [threadSearch, setThreadSearch] = useState('')
  const [threadSearchResults, setThreadSearchResults] = useState([])
  const [lightboxImage, setLightboxImage] = useState(null)
  const fileInputRef                = useRef(null)
  const bottomRef                   = useRef(null)
  const threadRef                   = useRef(null)
  const pollRef                     = useRef(null)
  const typingPollRef               = useRef(null)
  const typingStopTimerRef          = useRef(null)
  const latestIdRef                 = useRef(null)
  const shouldAutoScrollRef         = useRef(true)
  const draftKey                    = `nova_draft_${conversationId}`
  const previewAttachments = useMemo(() => {
    return attachments.map(file => ({
      file,
      previewUrl: isImageLike(file) ? URL.createObjectURL(file) : null,
    }))
  }, [attachments])

  useEffect(() => {
    return () => {
      for (const item of previewAttachments) {
        if (item.previewUrl) {
          URL.revokeObjectURL(item.previewUrl)
        }
      }
    }
  }, [previewAttachments])

  // ── Initial load ─────────────────────────────────────────────────────────
  const loadMessages = useCallback(async () => {
    try {
      const data = await apiFetch(`/api/messages/${conversationId}`)
      const msgs = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
      setMessages(msgs)
      setNextCursor(data.next_cursor ?? null)
      setLoading(false)
      if (msgs.length) latestIdRef.current = msgs[msgs.length - 1].id
      shouldAutoScrollRef.current = true
    } catch (e) {
      setError(e.message)
      setLoading(false)
    }
  }, [conversationId, currentUserId, apiFetch])

  useEffect(() => {
    setLoading(true)
    setMessages([])
    const storedDraft = window.localStorage.getItem(draftKey)
    setBody(storedDraft ?? '')
    loadMessages()

    if (!realtimeEnabled) {
      pollRef.current = setInterval(async () => {
        try {
          const data = await apiFetch(`/api/messages/${conversationId}`)
          const latestChunk = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
          if (latestChunk.length && latestChunk[latestChunk.length - 1].id !== latestIdRef.current) {
            shouldAutoScrollRef.current = true
            setMessages(prev => mergeMessageLists(prev, latestChunk))
            latestIdRef.current = latestChunk[latestChunk.length - 1].id
            onConversationUpdated()
          }
        } catch (_) {}
      }, 10_000)
    }

    return () => {
      if (pollRef.current) clearInterval(pollRef.current)
    }
  }, [conversationId, draftKey, realtimeEnabled, currentUserId, apiFetch, loadMessages, onConversationUpdated])

  useEffect(() => {
    if (!realtimeEnabled) {
      typingPollRef.current = setInterval(async () => {
        try {
          const data = await apiFetch(`/api/messages/${conversationId}/typing`)
          setTypingUsers(data.typing ?? [])
        } catch (_) {}
      }, 2_000)
    }

    return () => {
      if (typingPollRef.current) clearInterval(typingPollRef.current)
      clearTimeout(typingStopTimerRef.current)
      apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
    }
  }, [conversationId, apiFetch, realtimeEnabled])

  useEffect(() => {
    const content = body.trim()
    if (!content) {
      clearTimeout(typingStopTimerRef.current)
      apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
      return
    }

    apiFetch(`/api/messages/${conversationId}/typing`, { method: 'POST' }).catch(() => {})
    clearTimeout(typingStopTimerRef.current)
    typingStopTimerRef.current = setTimeout(() => {
      apiFetch(`/api/messages/${conversationId}/typing/stop`, { method: 'POST' }).catch(() => {})
    }, 2500)
  }, [body, conversationId, apiFetch])

  useEffect(() => {
    if (body.trim()) {
      window.localStorage.setItem(draftKey, body)
      return
    }

    window.localStorage.removeItem(draftKey)
  }, [body, draftKey])

  // ── Scroll to bottom on first load and new messages ───────────────────────
  useEffect(() => {
    if (!loading && shouldAutoScrollRef.current) {
      bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
      shouldAutoScrollRef.current = false
    }
  }, [loading, messages.length])

  // ── Mark as read when thread is viewed ────────────────────────────────────
  useEffect(() => {
    if (!loading) {
      apiFetch(`/api/messages/${conversationId}/read`, { method: 'POST' })
        .then(() => onMarkRead(conversationId))
        .catch(() => {})
    }
  }, [loading, conversationId])

  // ── Load older messages ───────────────────────────────────────────────────
  const loadMore = useCallback(async () => {
    if (!nextCursor || loadingMore) return
    setLoadingMore(true)
    const container = threadRef.current
    const prevHeight = container?.scrollHeight ?? 0

    try {
      const data = await apiFetch(`/api/messages/${conversationId}?cursor=${nextCursor}`)
      const older = [...(data.data ?? [])].map(m => tagReactions(m, currentUserId))
      shouldAutoScrollRef.current = false
      setMessages(prev => [...older, ...prev])
      setNextCursor(data.next_cursor ?? null)

      requestAnimationFrame(() => {
        if (!container) return
        const newHeight = container.scrollHeight
        container.scrollTop = Math.max(0, newHeight - prevHeight + container.scrollTop)
      })
    } catch (_) {}
    setLoadingMore(false)
  }, [nextCursor, loadingMore, apiFetch, conversationId, currentUserId])

  const handleThreadScroll = useCallback((e) => {
    if (e.currentTarget.scrollTop < 120) {
      loadMore()
    }
  }, [loadMore])

  // ── Send message ──────────────────────────────────────────────────────────
  const handleSend = useCallback(async (e) => {
    e.preventDefault()
    const text = body.trim()
    if ((!text && attachments.length === 0) || sending) return

    setSending(true)
    const optimistic = {
      id: `opt-${Date.now()}`,
      sender_id: currentUserId,
      sender: { id: currentUserId, username: currentUsername },
      body: text,
      created_at: new Date().toISOString(),
      _optimistic: true,
      attachments: attachments.map((file, index) => ({
        id: `tmp-${Date.now()}-${index}`,
        type: file.type.startsWith('image/') ? 'image' : 'file',
        original_name: file.name,
      })),
    }
    setMessages(prev => [...prev, optimistic])
    setBody('')
    window.localStorage.removeItem(draftKey)
    shouldAutoScrollRef.current = true
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })

    try {
      const formData = new FormData()
      formData.append('body', text)
      attachments.forEach(file => formData.append('attachments[]', file))
      setUploadProgress(0)

      const msg = await sendMessageWithProgress(`/api/messages/${conversationId}`, formData, (progress) => {
        setUploadProgress(progress)
      })
      setMessages(prev => prev.map(m => m.id === optimistic.id ? msg : m))
      latestIdRef.current = msg.id
      onConversationUpdated()
      setAttachments([])
    } catch (e) {
      setMessages(prev => prev.filter(m => m.id !== optimistic.id))
      setError(e.message)
    } finally {
      setUploadProgress(null)
      setSending(false)
    }
  }, [body, attachments, sending, conversationId, currentUserId, currentUsername, apiFetch, onConversationUpdated, draftKey])

  const handleKeyDown = useCallback((e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSend(e)
    }
  }, [handleSend])

  // ── Reaction ──────────────────────────────────────────────────────────────
  const handleReact = useCallback(async (messageId, emoji) => {
    try {
      await apiFetch(`/api/messages/${messageId}/reactions`, {
        method: 'POST',
        body: JSON.stringify({ reaction: emoji }),
      })
      // Optimistically add reaction with _iMine flag
      setMessages(prev => prev.map(m => {
        if (m.id !== messageId) return m
        const existing = (m.reactions ?? []).some(r => r._iMine && r.reaction === emoji)
        if (existing) return m
        return { ...m, reactions: [...(m.reactions ?? []), { reaction: emoji, user_id: currentUserId, _iMine: true }] }
      }))
    } catch (_) {}
  }, [currentUserId, apiFetch])

  const handleUnreact = useCallback(async (messageId, emoji) => {
    try {
      await apiFetch(`/api/messages/${messageId}/reactions`, {
        method: 'DELETE',
        body: JSON.stringify({ reaction: emoji }),
      })
      // Optimistically remove reaction
      setMessages(prev => prev.map(m => {
        if (m.id !== messageId) return m
        return { ...m, reactions: (m.reactions ?? []).filter(r => !(r._iMine && r.reaction === emoji)) }
      }))
    } catch (_) {}
  }, [apiFetch])

  const handleEdit = useCallback(async (messageId, newBody) => {
    const updated = await apiFetch(`/api/messages/message/${messageId}`, {
      method: 'PATCH',
      body: JSON.stringify({ body: newBody }),
    })
    setMessages(prev => prev.map(m => m.id === messageId ? { ...m, body: updated.body, edited_at: updated.edited_at } : m))
  }, [apiFetch])

  const handleReportMessage = useCallback(async (messageId) => {
    try {
      await apiFetch('/api/reports', {
        method: 'POST',
        body: JSON.stringify({
          target_type: 'message',
          target_id: messageId,
          reason: 'inappropriate',
          details: '',
        }),
      })
    } catch (e) {
      setError(e.message)
    }
  }, [apiFetch])

  const handlePickAttachments = useCallback((e) => {
    const next = Array.from(e.target.files ?? [])
    if (!next.length) return
    setAttachments(prev => [...prev, ...next].slice(0, 5))
    e.target.value = ''
  }, [])

  const removeAttachment = useCallback((index) => {
    setAttachments(prev => prev.filter((_, i) => i !== index))
  }, [])

  const togglePin = useCallback(async () => {
    const me = conversation?.all_participants?.find(p => p.user_id === currentUserId)
    const isPinned = !!me?.is_pinned
    const endpoint = isPinned ? 'unpin' : 'pin'
    try {
      await apiFetch(`/api/messages/${conversationId}/${endpoint}`, { method: 'POST' })
      onConversationUpdated()
    } catch (e) {
      setError(e.message)
    }
  }, [conversation, currentUserId, apiFetch, conversationId, onConversationUpdated])

  const toggleMute = useCallback(async () => {
    try {
      await apiFetch(`/api/messages/${conversationId}/mute`, { method: 'POST' })
      onConversationUpdated()
    } catch (e) {
      setError(e.message)
    }
  }, [apiFetch, conversationId, onConversationUpdated])

  const toggleArchive = useCallback(async () => {
    try {
      await apiFetch(`/api/messages/${conversationId}/archive`, { method: 'POST' })
      onConversationUpdated()
    } catch (e) {
      setError(e.message)
    }
  }, [apiFetch, conversationId, onConversationUpdated])

  useEffect(() => {
    let cancelled = false
    const q = threadSearch.trim()
    if (q.length < 2) {
      setThreadSearchResults([])
      return
    }

    const timer = setTimeout(async () => {
      try {
        const data = await apiFetch(`/api/messages/search?q=${encodeURIComponent(q)}&conversation_id=${conversationId}`)
        if (!cancelled) {
          setThreadSearchResults(data.data ?? [])
        }
      } catch (_) {
        if (!cancelled) {
          setThreadSearchResults([])
        }
      }
    }, 250)

    return () => {
      cancelled = true
      clearTimeout(timer)
    }
  }, [threadSearch, conversationId, apiFetch])

  const jumpToMessage = useCallback((messageId) => {
    const target = document.getElementById(`message-${messageId}`)
    if (target) {
      target.scrollIntoView({ behavior: 'smooth', block: 'center' })
    }
  }, [])

  // ── Thread header label ───────────────────────────────────────────────────
  const threadLabel = conversation?.type === 'group'
    ? (conversation?.title ?? 'Group conversation')
    : (conversation?.all_participants?.find(p => p.user_id !== currentUserId)?.user?.username ?? 'Direct message')
  const myParticipant = conversation?.my_participant ?? conversation?.all_participants?.find(p => p.user_id === currentUserId)
  const otherParticipant = conversation?.all_participants?.find(p => p.user_id !== currentUserId)
  const otherLastReadAt = otherParticipant?.last_read_at ?? null
  const lastMessageId = messages[messages.length - 1]?.id ?? null

  // ── Group date separators from messages ──────────────────────────────────
  const grouped = groupByDate(messages)

  return (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
        <button
          onClick={onBack}
          className="sm:hidden p-1 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
        >
          <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
            <path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
          </svg>
        </button>
        <div>
          <p className="font-medium text-sm text-gray-900 dark:text-gray-100">{threadLabel}</p>
          {conversation?.type === 'group' && (
            <p className="text-xs text-gray-400">
              {conversation.all_participants?.filter(p => !p.left_at).length ?? 0} members
            </p>
          )}
          {typingUsers.length > 0 && (
            <p className="text-xs text-blue-400">{typingUsers.map(u => u.username).join(', ')} typing…</p>
          )}
        </div>
        <button
          type="button"
          onClick={togglePin}
          className="ml-auto text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
        >
          {myParticipant?.is_pinned ? 'Unpin' : 'Pin'}
        </button>
        <button
          type="button"
          onClick={toggleMute}
          className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
        >
          {myParticipant?.is_muted ? 'Unmute' : 'Mute'}
        </button>
        <button
          type="button"
          onClick={toggleArchive}
          className="text-xs px-2 py-1 rounded border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
        >
          {myParticipant?.is_archived ? 'Unarchive' : 'Archive'}
        </button>
      </div>

      <div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
        <input
          type="search"
          value={threadSearch}
          onChange={e => setThreadSearch(e.target.value)}
          placeholder="Search in this conversation…"
          className="w-full rounded-lg bg-gray-100 dark:bg-gray-800 px-3 py-1.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500"
        />
        {threadSearch.trim().length >= 2 && (
          <div className="mt-2 max-h-28 overflow-y-auto rounded border border-gray-200 dark:border-gray-700">
            {threadSearchResults.length === 0 && (
              <p className="px-2 py-1 text-xs text-gray-400">No matches</p>
            )}
            {threadSearchResults.map(item => (
              <button
                key={`thread-search-${item.id}`}
                onClick={() => jumpToMessage(item.id)}
                className="w-full text-left px-2 py-1 text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
              >
                <span className="text-gray-500">@{item.sender?.username ?? 'unknown'}: </span>
                <span className="text-gray-800 dark:text-gray-200">{item.body || '(attachment)'}</span>
              </button>
            ))}
          </div>
        )}
      </div>

      {/* Messages */}
      <div ref={threadRef} onScroll={handleThreadScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
        {loadingMore && (
          <div className="text-center py-2 text-xs text-gray-400">Loading older messages…</div>
        )}

        {loading && (
          <div className="text-center text-sm text-gray-400 py-12">Loading messages…</div>
        )}

        {!loading && messages.length === 0 && (
          <div className="text-center text-sm text-gray-400 py-12">
            No messages yet. Say hello!
          </div>
        )}

        {grouped.map(({ date, messages: dayMessages }) => (
          <React.Fragment key={date}>
            <div className="flex items-center gap-3 my-4">
              <hr className="flex-1 border-gray-200 dark:border-gray-700" />
              <span className="text-xs text-gray-400 flex-shrink-0">{date}</span>
              <hr className="flex-1 border-gray-200 dark:border-gray-700" />
            </div>
            {dayMessages.map((msg, idx) => (
              <div key={msg.id} id={`message-${msg.id}`}>
                <MessageBubble
                  message={msg}
                  isMine={msg.sender_id === currentUserId}
                  showAvatar={idx === 0 || dayMessages[idx - 1]?.sender_id !== msg.sender_id}
                  onReact={handleReact}
                  onUnreact={handleUnreact}
                  onEdit={handleEdit}
                  onReport={handleReportMessage}
                  onOpenImage={setLightboxImage}
                  seenText={buildSeenText({
                    message: msg,
                    isMine: msg.sender_id === currentUserId,
                    isDirect: conversation?.type === 'direct',
                    isLastMessage: msg.id === lastMessageId,
                    otherLastReadAt,
                  })}
                />
              </div>
            ))}
          </React.Fragment>
        ))}

        <div ref={bottomRef} />
      </div>

      {/* Error */}
      {error && (
        <div className="mx-4 mb-2 px-3 py-2 text-xs text-red-600 bg-red-50 dark:bg-red-900/30 rounded-lg">
          {error}
        </div>
      )}

      {/* Compose */}
      <form onSubmit={handleSend} className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
        <input
          ref={fileInputRef}
          type="file"
          multiple
          className="hidden"
          onChange={handlePickAttachments}
        />
        <button
          type="button"
          onClick={() => fileInputRef.current?.click()}
          className="flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 px-3 py-2 text-sm text-gray-600 dark:text-gray-300"
          title="Attach files"
        >
          📎
        </button>
        <textarea
          value={body}
          onChange={e => setBody(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Write a message… (Enter to send, Shift+Enter for new line)"
          rows={1}
          maxLength={5000}
          className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto"
          style={{ minHeight: '2.5rem' }}
        />
        <button
          type="submit"
          disabled={(!body.trim() && attachments.length === 0) || sending}
          className="flex-shrink-0 rounded-xl bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white px-4 py-2 text-sm font-medium transition-colors"
        >
          Send
        </button>
      </form>

      {attachments.length > 0 && (
        <div className="px-4 pb-3 flex flex-wrap gap-2">
          {previewAttachments.map(({ file, previewUrl }, idx) => (
            <div key={`${file.name}-${idx}`} className="inline-flex items-center gap-2 rounded-lg bg-gray-100 dark:bg-gray-800 px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
              {previewUrl && (
                <img
                  src={previewUrl}
                  alt={file.name}
                  className="h-10 w-10 object-cover rounded"
                />
              )}
              <span className="truncate max-w-[220px]">{file.name}</span>
              <button type="button" onClick={() => removeAttachment(idx)} className="text-red-500">✕</button>
            </div>
          ))}
        </div>
      )}

      {sending && uploadProgress !== null && (
        <div className="px-4 pb-3">
          <div className="h-2 rounded bg-gray-200 dark:bg-gray-700 overflow-hidden">
            <div className="h-full bg-blue-500" style={{ width: `${uploadProgress}%` }} />
          </div>
          <p className="mt-1 text-[11px] text-gray-500">Uploading {uploadProgress}%</p>
        </div>
      )}

      {lightboxImage && (
        <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-6" onClick={() => setLightboxImage(null)}>
          <img
            src={lightboxImage.url}
            alt={lightboxImage.original_name || 'Attachment'}
            className="max-h-full max-w-full rounded-lg"
            onClick={(e) => e.stopPropagation()}
          />
        </div>
      )}
    </div>
  )
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function tagReactions(msg, currentUserId) {
  if (!msg.reactions?.length) return msg
  return {
    ...msg,
    reactions: msg.reactions.map(r => ({ ...r, _iMine: r.user_id === currentUserId })),
  }
}

function groupByDate(messages) {
  const map = new Map()
  for (const msg of messages) {
    const date = formatDate(msg.created_at)
    if (!map.has(date)) map.set(date, [])
    map.get(date).push(msg)
  }
  return Array.from(map.entries()).map(([date, messages]) => ({ date, messages }))
}

function mergeMessageLists(existing, incoming) {
  const byId = new Map()

  for (const msg of existing) {
    byId.set(String(msg.id), msg)
  }

  for (const msg of incoming) {
    byId.set(String(msg.id), msg)
  }

  return Array.from(byId.values()).sort((a, b) => {
    const at = new Date(a.created_at).getTime()
    const bt = new Date(b.created_at).getTime()
    if (at !== bt) return at - bt
    const aid = Number(a.id)
    const bid = Number(b.id)
    if (!Number.isNaN(aid) && !Number.isNaN(bid)) {
      return aid - bid
    }
    return String(a.id).localeCompare(String(b.id))
  })
}

function buildSeenText({ message, isMine, isDirect, isLastMessage, otherLastReadAt }) {
  if (!isDirect || !isMine || !isLastMessage || !otherLastReadAt || !message?.created_at) {
    return null
  }

  const seenAt = new Date(otherLastReadAt)
  const sentAt = new Date(message.created_at)

  if (Number.isNaN(seenAt.getTime()) || Number.isNaN(sentAt.getTime()) || seenAt < sentAt) {
    return null
  }

  return `Seen ${relativeTimeFromNow(otherLastReadAt)} ago`
}

function relativeTimeFromNow(iso) {
  const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
  if (seconds < 60) return `${seconds}s`
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
  return `${Math.floor(seconds / 86400)}d`
}

function formatDate(iso) {
  const d = new Date(iso)
  const today = new Date()
  const yesterday = new Date(today)
  yesterday.setDate(today.getDate() - 1)

  if (isSameDay(d, today)) return 'Today'
  if (isSameDay(d, yesterday)) return 'Yesterday'
  return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}

function isSameDay(a, b) {
  return a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
}

function isImageLike(file) {
  return file?.type?.startsWith('image/')
}

function getCsrf() {
  return document.querySelector('meta[name="csrf-token"]')?.content ?? ''
}

function sendMessageWithProgress(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('POST', url)
    xhr.setRequestHeader('X-CSRF-TOKEN', getCsrf())
    xhr.setRequestHeader('Accept', 'application/json')

    xhr.upload.onprogress = (event) => {
      if (!event.lengthComputable) return
      const progress = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))
      onProgress(progress)
    }

    xhr.onload = () => {
      try {
        const json = JSON.parse(xhr.responseText || '{}')
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve(json)
          return
        }
        reject(new Error(json.message || `HTTP ${xhr.status}`))
      } catch (_) {
        reject(new Error(`HTTP ${xhr.status}`))
      }
    }

    xhr.onerror = () => reject(new Error('Network error'))
    xhr.send(formData)
  })
}
