/**
 * Server-Sent Events manager with reconnection
 * @countermeasure/web-components/services/sse
 */

export type SSEEventHandler = (data: unknown) => void

export interface SSEConfig {
  url: string
  /**
   * Legacy query-token source. Prefer cookie-backed SSE auth with withCredentials.
   * This token is only appended to the URL when allowQueryTokenAuth is true.
   */
  getAuthToken?: () => string | null
  /**
   * Explicitly opt into appending getAuthToken() as ?token=... on the EventSource URL.
   * Query-string bearer tokens can leak through logs, browser history, and referrers.
   */
  allowQueryTokenAuth?: boolean
  reconnect?: boolean
  reconnectInterval?: number
  maxRetries?: number
  withCredentials?: boolean
}

export class SSEManager {
  private readonly config: SSEConfig
  private readonly handlers = new Map<string, Set<SSEEventHandler>>()
  private eventSource: EventSource | null = null
  private attachedTypes = new Set<string>()
  private retryCount = 0
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null

  constructor(config: SSEConfig) {
    this.config = config
  }

  on(eventType: string, handler: SSEEventHandler): () => void {
    let handlerSet = this.handlers.get(eventType)
    if (!handlerSet) {
      handlerSet = new Set()
      this.handlers.set(eventType, handlerSet)
    }
    handlerSet.add(handler)

    if (this.eventSource) {
      this.attachEventListener(eventType)
    }

    return () => {
      this.off(eventType, handler)
    }
  }

  off(eventType: string, handler: SSEEventHandler): void {
    const handlerSet = this.handlers.get(eventType)
    if (handlerSet) {
      handlerSet.delete(handler)
      if (handlerSet.size === 0) {
        this.handlers.delete(eventType)
      }
    }
  }

  connect(): void {
    if (this.eventSource) {
      this.eventSource.close()
    }

    this.attachedTypes = new Set()
    const url = this.buildUrl()
    this.eventSource = new EventSource(url, {
      withCredentials: this.config.withCredentials === true,
    })

    this.eventSource.onopen = () => {
      this.retryCount = 0
    }

    this.eventSource.onerror = () => {
      if (this.eventSource) {
        this.eventSource.close()
        this.eventSource = null
      }

      const shouldReconnect = this.config.reconnect !== false
      const maxRetries = this.config.maxRetries ?? 10

      if (shouldReconnect && this.retryCount < maxRetries) {
        this.retryCount += 1
        const baseInterval = this.config.reconnectInterval ?? 1000
        const delay = baseInterval * Math.min(this.retryCount, 10)
        this.reconnectTimer = setTimeout(() => {
          this.reconnectTimer = null
          this.connect()
        }, delay)
      }
    }

    for (const eventType of this.handlers.keys()) {
      this.attachEventListener(eventType)
    }
  }

  disconnect(): void {
    if (this.reconnectTimer !== null) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }

    if (this.eventSource) {
      this.eventSource.close()
      this.eventSource = null
    }

    this.retryCount = 0
  }

  isConnected(): boolean {
    return this.eventSource !== null && this.eventSource.readyState === EventSource.OPEN
  }

  /**
   * Returns `true` when the manager has given up reconnecting — i.e. the
   * EventSource errored and `maxRetries` has been exhausted.  Consumers can
   * use this to show a permanent-failure indicator rather than an optimistic
   * "reconnecting" state.
   */
  isFailed(): boolean {
    if (this.eventSource !== null) return false
    if (this.reconnectTimer !== null) return false
    const maxRetries = this.config.maxRetries ?? 10
    return this.retryCount >= maxRetries
  }

  private buildUrl(): string {
    const base = this.config.url

    if (this.config.allowQueryTokenAuth !== true || !this.config.getAuthToken) {
      return base
    }

    const token = this.config.getAuthToken()
    if (token) {
      const separator = base.includes('?') ? '&' : '?'
      return `${base}${separator}token=${encodeURIComponent(token)}`
    }

    return base
  }

  private attachEventListener(eventType: string): void {
    if (!this.eventSource) {
      return
    }
    if (this.attachedTypes.has(eventType)) {
      return
    }
    this.attachedTypes.add(eventType)

    this.eventSource.addEventListener(eventType, (event: MessageEvent) => {
      const handlerSet = this.handlers.get(eventType)
      if (!handlerSet) {
        return
      }

      let parsed: unknown
      try {
        parsed = JSON.parse(event.data as string)
      } catch {
        parsed = event.data
      }

      for (const handler of handlerSet) {
        handler(parsed)
      }
    })
  }
}

// Re-export the React hook so consumers using the existing
// `services/sse` import path automatically get `useSSE` too.
export { useSSE, type UseSSEOptions, type UseSSEResult } from './sse-react'
