import type { SaeeolChatEventMap, SaeeolChatEventName } from "./types"
import {
  WS_SUBPROTOCOL,
  HANDSHAKE_TIMEOUT_MS,
  PING_INTERVAL_MS,
  WebSocketAuthError,
  WebSocketConnectError,
  HandshakeTimeoutError,
  isAuthCloseCode,
  fetchTicket,
  parseMessage,
} from "./event-service-client-core"
import type { EventServiceConfig, EventHandler } from "./event-service-client-core"

export class EventServiceClient {
  private readonly url: string
  private readonly getToken: () => Promise<string>
  private readonly onUnauthorized: (() => void) | undefined
  private ws: WebSocket | null = null
  private connected = false
  private destroyed = false
  private reconnectAttempts = 0
  private hasConnectedBefore = false
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null
  private pingTimer: ReturnType<typeof setInterval> | null = null
  private handshakeTimer: ReturnType<typeof setTimeout> | null = null
  private abortHandshake: ((err: Error) => void) | null = null
  private eventHandlers = new Map<string, Set<EventHandler>>()
  private activeContexts = new Set<string>()
  private reconnectHandlers = new Set<() => void>()

  constructor(config: EventServiceConfig) {
    this.url = config.url
    this.getToken = config.getToken
    this.onUnauthorized = config.onUnauthorized
  }

  async connect(): Promise<void> {
    this.destroyed = false
    this.reconnectAttempts = 0
    if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
    try {
      await this.connectOnce()
    } catch (err) {
      if (this.handleAuthFailure(err)) return
      if (!this.destroyed) this.scheduleReconnect()
    }
  }

  disconnect(): void {
    this.destroyed = true
    if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
    this.clearHandshakeTimer()
    this.abortHandshake?.(new Error("disconnected"))
    if (this.ws) { this.ws.close(); this.ws = null }
    this.stopPing()
    this.connected = false
  }

  isConnected(): boolean {
    return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN
  }

  subscribe(contexts: string[]): void {
    for (const ctx of contexts) this.activeContexts.add(ctx)
    if (this.isConnected()) this.sendJson({ type: "context.subscribe", contexts })
  }

  unsubscribe(contexts: string[]): void {
    for (const ctx of contexts) this.activeContexts.delete(ctx)
    if (this.isConnected()) this.sendJson({ type: "context.unsubscribe", contexts })
  }

  on<N extends SaeeolChatEventName>(event: N, handler: (ctx: string, payload: SaeeolChatEventMap[N]) => void): () => void {
    const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
    const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as SaeeolChatEventMap[N])
    set.add(wrapped)
    this.eventHandlers.set(event, set)
    return () => { set.delete(wrapped); if (set.size === 0) this.eventHandlers.delete(event) }
  }

  onReconnect(handler: () => void): () => void {
    this.reconnectHandlers.add(handler)
    return () => this.reconnectHandlers.delete(handler)
  }

  private handleAuthFailure(err: unknown): boolean {
    if (!(err instanceof WebSocketAuthError)) return false
    this.destroyed = true
    if (this.reconnectTimer !== null) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
    this.onUnauthorized?.()
    return true
  }

  private async connectOnce(): Promise<void> {
    if (this.ws) { const old = this.ws; this.ws = null; old.close() }
    const token = await this.getToken()
    const ticket = await fetchTicket(this.url, token)

    return new Promise<void>((resolve, reject) => {
      const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
      this.ws = ws
      let settled = false
      const finish = (action: () => void) => {
        if (settled) return
        settled = true
        this.clearHandshakeTimer()
        this.abortHandshake = null
        action()
      }
      this.abortHandshake = (err) => finish(() => reject(err))

      this.handshakeTimer = setTimeout(() => {
        this.handshakeTimer = null
        if (this.ws === ws) ws.close(1000, "handshake-timeout")
        finish(() => reject(new HandshakeTimeoutError()))
      }, HANDSHAKE_TIMEOUT_MS)

      ws.addEventListener("open", () => {
        const isReconnect = this.hasConnectedBefore
        this.connected = true
        this.hasConnectedBefore = true
        this.reconnectAttempts = 0
        this.resubscribeContexts()
        if (isReconnect) for (const h of this.reconnectHandlers) h()
        finish(resolve)
        this.startPing()
      })

      ws.addEventListener("message", (event: MessageEvent) => {
        parseMessage(String(event.data), this.eventHandlers)
      })

      ws.addEventListener("close", (event: CloseEvent) => {
        if (this.ws !== ws) return
        const wasConnected = this.connected
        this.connected = false
        this.stopPing()
        this.clearHandshakeTimer()
        if (!wasConnected) {
          if (isAuthCloseCode(event.code)) {
            finish(() => reject(new WebSocketAuthError()))
          } else {
            finish(() => reject(new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code)))
          }
          return
        }
        if (!this.destroyed) this.scheduleReconnect()
      })

      ws.addEventListener("error", () => {})
    })
  }

  private clearHandshakeTimer() {
    if (this.handshakeTimer !== null) { clearTimeout(this.handshakeTimer); this.handshakeTimer = null }
  }

  private sendJson(msg: unknown) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg))
  }

  private startPing() {
    this.stopPing()
    this.pingTimer = setInterval(() => {
      if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send("ping")
    }, PING_INTERVAL_MS)
  }

  private stopPing() {
    if (this.pingTimer !== null) { clearInterval(this.pingTimer); this.pingTimer = null }
  }

  private resubscribeContexts() {
    if (this.activeContexts.size > 0) {
      this.sendJson({ type: "context.subscribe", contexts: Array.from(this.activeContexts) })
    }
  }

  private scheduleReconnect() {
    if (this.reconnectTimer !== null) return
    const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts)
    const delay = base * (0.5 + Math.random() * 0.5)
    this.reconnectAttempts++
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null
      this.connectOnce().catch((err) => {
        if (this.handleAuthFailure(err)) return
        if (!this.destroyed) this.scheduleReconnect()
      })
    }, delay)
  }
}
