/**
 * Owns the single staff-chat WebSocket (one per tab). Started once by
 * plugins/04.chat-socket.client.ts for non-limited staff. Pipes inbound frames
 * into global state (presence / unread / threads) and exposes send().
 *
 * Also the source of truth for *activity*: it watches real user interaction and
 * tells the server active/idle so presence reflects "actually at the keyboard",
 * not merely "tab open". Heartbeat ping keeps the socket alive through nginx.
 *
 * Module-level singleton (the socket + timers) so repeated useChatSocket() calls
 * share one connection.
 */

// Build-time flag injected by Vite ONLY in the Capacitor app build (see nuxt.config.ts). Undefined
// on the web build, so the `typeof` guard below keeps same-origin + cookie WS auth there.
declare const __CAP_BUILD__: boolean | undefined

const PING_MS = 25000          // < nginx idle timeout
const IDLE_MS = 5 * 60 * 1000  // no interaction → away
const RECONNECT_MS = 5000

let ws: WebSocket | null = null
let pingTimer: any = null
let idleTimer: any = null
let reconnectTimer: any = null
let activityBound = false
let selfActive = true
// Per-peer auto-clear timers so a lost typing:false frame can't leave "tippt…" stuck.
const typingTimers: Record<number, any> = {}

export const useChatSocket = () => {
  const presence = useChatPresence()
  const unread = useChatUnread()
  const connected = useChatConnected()
  const threads = useChatThreads()
  const open = useChatOpen()
  const activePeer = useChatActivePeer()
  const typing = useChatTyping()
  const soundMuted = useChatSoundMuted()
  const staffUsers = useState<any[]>('chatStaffUsers', () => [])

  // Seed the mute preference from localStorage once (client only).
  if (typeof window !== 'undefined') {
    try { if (localStorage.getItem('logship_chat_sound') === 'muted') soundMuted.value = true } catch {}
  }

  // Captured here (valid Nuxt context) so the socket's message handler — which
  // runs later, outside any active instance — can still raise a toast.
  const toast = useToast()
  const nuxtApp = useNuxtApp()

  const me = Number(useCookie('logship_user_id').value || 0)

  const sendRaw = (frame: any) => {
    if (ws && ws.readyState === WebSocket.OPEN) {
      try { ws.send(JSON.stringify(frame)) } catch {}
    }
  }

  // Re-seed per-peer unread counts from the DB. Runs on every (re)connect so the
  // header badge always reflects messages that arrived while we were away/offline.
  const seedUnread = async () => {
    try {
      const res: any = await $fetch('/api/chat/unread', { headers: useRequestHeaders(['cookie']) })
      if (res?.counts) unread.value = { ...res.counts }
    } catch { /* not ready / gone */ }
  }

  const senderName = (id: number) => {
    const u = (staffUsers.value || []).find((x: any) => Number(x.id) === Number(id))
    return u?.name || 'Neue Nachricht'
  }

  // In-app toast for a live inbound message we're not currently looking at. The
  // toast is ephemeral; the persistent cue is the header badge (unread count).
  const toastInbound = (msg: any) => {
    const sid = Number(msg.senderUserId)
    const openConversation = () => {
      // On mobile/native the teleported drawer doesn't render — route to the full-screen chat page
      // instead (mirrors <ChatBadge>); the /chat route + 04.chat-socket plugin open this conversation.
      if (import.meta.client && window.innerWidth <= 1024) {
        nuxtApp.runWithContext(() => navigateTo('/chat?chat=' + sid))
        return
      }
      open.value = true
      activePeer.value = sid // ChatDrawer watches activePeer → loads history + marks read
    }
    nuxtApp.runWithContext(() => {
      toast.add({
        duration: 8000,
        timeout: 8000, // v2/v3 key compat (see plugins/03.notifications-stream.client.ts)
        title: senderName(sid),
        description: (msg.body || '').toString().slice(0, 140),
        icon: 'i-heroicons-chat-bubble-left-right',
        color: 'primary',
        actions: [{ label: 'Öffnen', click: openConversation, onClick: openConversation }]
      } as any)
    })
  }

  // Short WebAudio blip — no asset, respects the mute preference. The user has
  // necessarily interacted with the app (they're logged in), so autoplay is allowed.
  const playBlip = () => {
    if (soundMuted.value || typeof window === 'undefined') return
    try {
      const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext
      if (!Ctx) return
      const ctx = new Ctx()
      const osc = ctx.createOscillator()
      const gain = ctx.createGain()
      osc.type = 'sine'
      osc.frequency.value = 660
      gain.gain.setValueAtTime(0.0001, ctx.currentTime)
      gain.gain.exponentialRampToValueAtTime(0.14, ctx.currentTime + 0.01)
      gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.25)
      osc.connect(gain); gain.connect(ctx.destination)
      osc.start()
      osc.stop(ctx.currentTime + 0.26)
      osc.onended = () => { try { ctx.close() } catch {} }
    } catch { /* audio not available */ }
  }

  // Desktop notification when the tab is in the background. Permission is obtained
  // by the push plugin — we never prompt here, only use it if already granted.
  const notifyInbound = (msg: any) => {
    try {
      if (typeof document === 'undefined' || !document.hidden) return
      if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return
      const sid = Number(msg.senderUserId)
      const n = new Notification(senderName(sid), {
        body: (msg.body || '').toString().slice(0, 140),
        tag: 'chat-' + sid,
        icon: '/favicon.ico'
      })
      n.onclick = () => {
        try { window.focus() } catch {}
        open.value = true
        activePeer.value = sid
        try { n.close() } catch {}
      }
    } catch { /* notifications not available */ }
  }

  const applyInbound = (msg: any) => {
    if (!msg) return
    const peerKey = Number(msg.senderUserId) === me ? Number(msg.recipientUserId) : Number(msg.senderUserId)
    const list = threads.value[peerKey] ? [...threads.value[peerKey]] : []
    // Reconcile an optimistic message (same clientMsgId) or de-dupe by id.
    const idx = list.findIndex((m: any) =>
      (msg.clientMsgId && m.clientMsgId && m.clientMsgId === msg.clientMsgId) ||
      (msg.id && m.id && m.id === msg.id))
    if (idx >= 0) list[idx] = { ...list[idx], ...msg, pending: false }
    else list.push({ ...msg, pending: false })
    threads.value = { ...threads.value, [peerKey]: list }

    // Inbound (not our own echo): bump unread + toast unless we're looking at it now.
    if (Number(msg.senderUserId) !== me) {
      const viewing = open.value &&
        Number(activePeer.value) === Number(msg.senderUserId) &&
        typeof document !== 'undefined' && !document.hidden
      if (!viewing) {
        const sid = Number(msg.senderUserId)
        unread.value = { ...unread.value, [sid]: (unread.value[sid] || 0) + 1 }
        toastInbound(msg)
        playBlip()
        notifyInbound(msg)
      }
    }
  }

  const handle = (frame: any) => {
    switch (frame?.type) {
      case 'connected':
        connected.value = true
        seedUnread() // refresh the badge for anything that landed while we were away
        break
      case 'presence-snapshot': {
        const map: Record<number, 'online' | 'away' | 'offline'> = {}
        ;(frame.online || []).forEach((id: number) => { map[id] = 'online' })
        ;(frame.away || []).forEach((id: number) => { map[id] = 'away' })
        presence.value = map
        break
      }
      case 'presence':
        presence.value = { ...presence.value, [frame.userId]: frame.status }
        break
      case 'message':
        applyInbound(frame.message)
        break
      case 'message-update': {
        // A message changed server-side (e.g. a voice transcript finished). Merge
        // it into the right thread by id/clientMsgId — never bumps unread/toast.
        const msg = frame.message
        if (!msg) break
        const peerKey = Number(msg.senderUserId) === me ? Number(msg.recipientUserId) : Number(msg.senderUserId)
        const list = threads.value[peerKey]
        if (!list) break
        const idx = list.findIndex((m: any) =>
          (msg.id && m.id === msg.id) || (msg.clientMsgId && m.clientMsgId && m.clientMsgId === msg.clientMsgId))
        if (idx < 0) break
        const next = [...list]
        next[idx] = { ...next[idx], ...msg, meta: { ...(next[idx].meta || {}), ...(msg.meta || {}) }, pending: false }
        threads.value = { ...threads.value, [peerKey]: next }
        break
      }
      case 'reaction': {
        const messageId = Number(frame.messageId)
        const peerKey = Number(frame.senderUserId) === me ? Number(frame.recipientUserId) : Number(frame.senderUserId)
        const list = threads.value[peerKey]
        if (!list) break
        const idx = list.findIndex((m: any) => Number(m.id) === messageId)
        if (idx < 0) break
        const msg = list[idx]
        const reactions = Array.isArray(msg.reactions) ? [...msg.reactions] : []
        const ri = reactions.findIndex((r: any) => Number(r.userId) === Number(frame.userId) && r.emoji === frame.emoji)
        if (frame.on) { if (ri < 0) reactions.push({ userId: Number(frame.userId), emoji: frame.emoji }) }
        else if (ri >= 0) reactions.splice(ri, 1)
        const next = [...list]
        next[idx] = { ...msg, reactions }
        threads.value = { ...threads.value, [peerKey]: next }
        break
      }
      case 'read': {
        const u = { ...unread.value }
        delete u[frame.peer]
        unread.value = u
        break
      }
      case 'read-receipt': {
        // The peer read our messages → stamp readAt on our outgoing ones (✓✓).
        const peerKey = Number(frame.peer)
        const list = threads.value[peerKey]
        if (!list) break
        const now = Date.now()
        let changed = false
        const next = list.map((m: any) => {
          if (Number(m.senderUserId) === me && !m.readAt) { changed = true; return { ...m, readAt: now } }
          return m
        })
        if (changed) threads.value = { ...threads.value, [peerKey]: next }
        break
      }
      case 'typing': {
        const from = Number(frame.from)
        if (!from) break
        if (typingTimers[from]) { clearTimeout(typingTimers[from]); delete typingTimers[from] }
        if (frame.typing) {
          typing.value = { ...typing.value, [from]: true }
          typingTimers[from] = setTimeout(() => {
            const t = { ...typing.value }; delete t[from]; typing.value = t
            delete typingTimers[from]
          }, 6000)
        } else {
          const t = { ...typing.value }; delete t[from]; typing.value = t
        }
        break
      }
      case 'pong':
      default:
        break
    }
  }

  // ── activity / idle ──────────────────────────────────────────────────────
  const sendActivity = (active: boolean) => {
    selfActive = active
    sendRaw({ type: 'activity', active })
  }
  const resetIdle = () => {
    if (idleTimer) clearTimeout(idleTimer)
    idleTimer = setTimeout(() => { if (selfActive) sendActivity(false) }, IDLE_MS)
  }
  const onInteract = () => {
    if (!selfActive) sendActivity(true)
    resetIdle()
  }
  const onVisibility = () => {
    if (document.hidden) {
      if (selfActive) sendActivity(false)
    } else {
      onInteract()
    }
  }
  const bindActivity = () => {
    if (activityBound || typeof window === 'undefined') return
    activityBound = true
    ;['mousedown', 'keydown', 'scroll', 'touchstart', 'click', 'mousemove'].forEach(ev =>
      window.addEventListener(ev, onInteract, { passive: true }))
    document.addEventListener('visibilitychange', onVisibility)
  }

  // ── heartbeat ────────────────────────────────────────────────────────────
  const startPing = () => {
    if (pingTimer) return
    pingTimer = setInterval(() => sendRaw({ type: 'ping' }), PING_MS)
  }
  const stopPing = () => {
    if (pingTimer) { clearInterval(pingTimer); pingTimer = null }
  }

  const scheduleReconnect = () => {
    if (reconnectTimer) return
    reconnectTimer = setTimeout(() => { reconnectTimer = null; connect() }, RECONNECT_MS)
  }

  // Web: same-origin WS + cookie auth. Bundled app (__CAP_BUILD__): the WebView origin is
  // https://localhost and a WS upgrade can't send an Authorization header or app.logship.de cookies,
  // so connect to the API host with the bearer token in the query (re-read on each reconnect → handles
  // token rotation). The server (ws.ts) accepts ?access_token as a fallback to the logship_it cookie.
  const buildWsUrl = (): string => {
    if (typeof __CAP_BUILD__ !== 'undefined') {
      const base = String(useRuntimeConfig().public.apiBase || '').replace(/^http/, 'ws')
      const token = useAuthSession().token.value
      return `${base}/chat/ws${token ? `?access_token=${encodeURIComponent(token)}` : ''}`
    }
    const proto = location.protocol === 'https:' ? 'wss' : 'ws'
    return `${proto}://${location.host}/chat/ws`
  }

  const connect = () => {
    if (typeof window === 'undefined') return
    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
    try {
      const socket = new WebSocket(buildWsUrl())
      ws = socket
      socket.onopen = () => {
        startPing()
        bindActivity()
        selfActive = true
        sendActivity(true)
        resetIdle()
      }
      socket.onmessage = (e) => { try { handle(JSON.parse(e.data)) } catch {} }
      socket.onclose = () => {
        connected.value = false
        presence.value = {}
        stopPing()
        if (ws === socket) ws = null
        scheduleReconnect()
      }
      socket.onerror = () => { try { socket.close() } catch {} }
    } catch (e) {
      scheduleReconnect()
    }
  }

  const disconnect = () => {
    if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null }
    stopPing()
    if (idleTimer) { clearTimeout(idleTimer); idleTimer = null }
    if (ws) { try { ws.close() } catch {}; ws = null }
    connected.value = false
  }

  return { connect, disconnect, send: sendRaw }
}
