import WebSocketFactory, { WebSocketLike } from './lib/websocket-factory'

import {
  CHANNEL_EVENTS,
  CONNECTION_STATE,
  DEFAULT_VERSION,
  DEFAULT_TIMEOUT,
  SOCKET_STATES,
  TRANSPORTS,
  DEFAULT_VSN,
  VSN_1_0_0,
  VSN_2_0_0,
  WS_CLOSE_NORMAL,
} from './lib/constants'

import Serializer from './lib/serializer'
import Timer from './lib/timer'

import { httpEndpointURL } from './lib/transformers'
import RealtimeChannel from './RealtimeChannel'
import type { RealtimeChannelOptions } from './RealtimeChannel'

type Fetch = typeof fetch

export type Channel = {
  name: string
  inserted_at: string
  updated_at: string
  id: number
}
export type LogLevel = 'info' | 'warn' | 'error'

export type RealtimeMessage = {
  topic: string
  event: string
  payload: any
  ref: string
  join_ref?: string
}

export type RealtimeRemoveChannelResponse = 'ok' | 'timed out' | 'error'
export type HeartbeatStatus = 'sent' | 'ok' | 'error' | 'timeout' | 'disconnected'

const noop = () => {}

type RealtimeClientState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'

// Connection-related constants
const CONNECTION_TIMEOUTS = {
  HEARTBEAT_INTERVAL: 25000,
  RECONNECT_DELAY: 10,
  HEARTBEAT_TIMEOUT_FALLBACK: 100,
} as const

const RECONNECT_INTERVALS = [1000, 2000, 5000, 10000] as const
const DEFAULT_RECONNECT_FALLBACK = 10000

/**
 * Minimal WebSocket constructor interface that RealtimeClient can work with.
 * Supply a compatible implementation (native WebSocket, `ws`, etc) when running outside the browser.
 */
export interface WebSocketLikeConstructor {
  new (address: string | URL, subprotocols?: string | string[] | undefined): WebSocketLike
  // Allow additional properties that may exist on WebSocket constructors
  [key: string]: any
}

export interface WebSocketLikeError {
  error: any
  message: string
  type: string
}

export type RealtimeClientOptions = {
  transport?: WebSocketLikeConstructor
  timeout?: number
  heartbeatIntervalMs?: number
  heartbeatCallback?: (status: HeartbeatStatus, latency?: number) => void
  vsn?: string
  logger?: Function
  encode?: Function
  decode?: Function
  reconnectAfterMs?: Function
  headers?: { [key: string]: string }
  params?: { [key: string]: any }
  //Deprecated: Use it in favour of correct casing `logLevel`
  log_level?: LogLevel
  logLevel?: LogLevel
  fetch?: Fetch
  worker?: boolean
  workerUrl?: string
  accessToken?: () => Promise<string | null>
}

const WORKER_SCRIPT = `
  addEventListener("message", (e) => {
    if (e.data.event === "start") {
      setInterval(() => postMessage({ event: "keepAlive" }), e.data.interval);
    }
  });`

export default class RealtimeClient {
  accessTokenValue: string | null = null
  apiKey: string | null = null
  private _manuallySetToken: boolean = false
  channels: RealtimeChannel[] = new Array()
  endPoint: string = ''
  httpEndpoint: string = ''
  /** @deprecated headers cannot be set on websocket connections */
  headers?: { [key: string]: string } = {}
  params?: { [key: string]: string } = {}
  timeout: number = DEFAULT_TIMEOUT
  transport: WebSocketLikeConstructor | null = null
  heartbeatIntervalMs: number = CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
  heartbeatTimer: ReturnType<typeof setInterval> | undefined = undefined
  pendingHeartbeatRef: string | null = null
  heartbeatCallback: (status: HeartbeatStatus, latency?: number) => void = noop
  ref: number = 0
  reconnectTimer: Timer | null = null
  vsn: string = DEFAULT_VSN
  logger: Function = noop
  logLevel?: LogLevel
  encode!: Function
  decode!: Function
  reconnectAfterMs!: Function
  conn: WebSocketLike | null = null
  sendBuffer: Function[] = []
  serializer: Serializer = new Serializer()
  stateChangeCallbacks: {
    open: Function[]
    close: Function[]
    error: Function[]
    message: Function[]
  } = {
    open: [],
    close: [],
    error: [],
    message: [],
  }
  fetch: Fetch
  accessToken: (() => Promise<string | null>) | null = null
  worker?: boolean
  workerUrl?: string
  workerRef?: Worker
  private _connectionState: RealtimeClientState = 'disconnected'
  private _wasManualDisconnect: boolean = false
  private _authPromise: Promise<void> | null = null
  private _heartbeatSentAt: number | null = null

  /**
   * Initializes the Socket.
   *
   * @param endPoint The string WebSocket endpoint, ie, "ws://example.com/socket", "wss://example.com", "/socket" (inherited host & protocol)
   * @param httpEndpoint The string HTTP endpoint, ie, "https://example.com", "/" (inherited host & protocol)
   * @param options.transport The Websocket Transport, for example WebSocket. This can be a custom implementation
   * @param options.timeout The default timeout in milliseconds to trigger push timeouts.
   * @param options.params The optional params to pass when connecting.
   * @param options.headers Deprecated: headers cannot be set on websocket connections and this option will be removed in the future.
   * @param options.heartbeatIntervalMs The millisec interval to send a heartbeat message.
   * @param options.heartbeatCallback The optional function to handle heartbeat status and latency.
   * @param options.logger The optional function for specialized logging, ie: logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }
   * @param options.logLevel Sets the log level for Realtime
   * @param options.encode The function to encode outgoing messages. Defaults to JSON: (payload, callback) => callback(JSON.stringify(payload))
   * @param options.decode The function to decode incoming messages. Defaults to Serializer's decode.
   * @param options.reconnectAfterMs he optional function that returns the millsec reconnect interval. Defaults to stepped backoff off.
   * @param options.worker Use Web Worker to set a side flow. Defaults to false.
   * @param options.workerUrl The URL of the worker script. Defaults to https://realtime.supabase.com/worker.js that includes a heartbeat event call to keep the connection alive.
   * @param options.vsn The protocol version to use when connecting. Supported versions are "1.0.0" and "2.0.0". Defaults to "2.0.0".
   * @example
   * ```ts
   * import RealtimeClient from '@supabase/realtime-js'
   *
   * const client = new RealtimeClient('https://xyzcompany.supabase.co/realtime/v1', {
   *   params: { apikey: 'public-anon-key' },
   * })
   * client.connect()
   * ```
   */
  constructor(endPoint: string, options?: RealtimeClientOptions) {
    // Validate required parameters
    if (!options?.params?.apikey) {
      throw new Error('API key is required to connect to Realtime')
    }
    this.apiKey = options.params.apikey

    // Initialize endpoint URLs
    this.endPoint = `${endPoint}/${TRANSPORTS.websocket}`
    this.httpEndpoint = httpEndpointURL(endPoint)

    this._initializeOptions(options)
    this._setupReconnectionTimer()
    this.fetch = this._resolveFetch(options?.fetch)
  }

  /**
   * Connects the socket, unless already connected.
   */
  connect(): void {
    // Skip if already connecting, disconnecting, or connected
    if (
      this.isConnecting() ||
      this.isDisconnecting() ||
      (this.conn !== null && this.isConnected())
    ) {
      return
    }

    this._setConnectionState('connecting')

    // Trigger auth if needed and not already in progress
    // This ensures auth is called for standalone RealtimeClient usage
    // while avoiding race conditions with SupabaseClient's immediate setAuth call
    if (this.accessToken && !this._authPromise) {
      this._setAuthSafely('connect')
    }

    // Establish WebSocket connection
    if (this.transport) {
      // Use custom transport if provided
      this.conn = new this.transport(this.endpointURL()) as WebSocketLike
    } else {
      // Try to use native WebSocket
      try {
        this.conn = WebSocketFactory.createWebSocket(this.endpointURL())
      } catch (error) {
        this._setConnectionState('disconnected')
        const errorMessage = (error as Error).message

        // Provide helpful error message based on environment
        if (errorMessage.includes('Node.js')) {
          throw new Error(
            `${errorMessage}\n\n` +
              'To use Realtime in Node.js, you need to provide a WebSocket implementation:\n\n' +
              'Option 1: Use Node.js 22+ which has native WebSocket support\n' +
              'Option 2: Install and provide the "ws" package:\n\n' +
              '  npm install ws\n\n' +
              '  import ws from "ws"\n' +
              '  const client = new RealtimeClient(url, {\n' +
              '    ...options,\n' +
              '    transport: ws\n' +
              '  })'
          )
        }
        throw new Error(`WebSocket not available: ${errorMessage}`)
      }
    }
    this._setupConnectionHandlers()
  }

  /**
   * Returns the URL of the websocket.
   * @returns string The URL of the websocket.
   */
  endpointURL(): string {
    return this._appendParams(this.endPoint, Object.assign({}, this.params, { vsn: this.vsn }))
  }

  /**
   * Disconnects the socket.
   *
   * @param code A numeric status code to send on disconnect.
   * @param reason A custom reason for the disconnect.
   */
  disconnect(code?: number, reason?: string): void {
    if (this.isDisconnecting()) {
      return
    }

    this._setConnectionState('disconnecting', true)

    if (this.conn) {
      // Setup fallback timer to prevent hanging in disconnecting state
      const fallbackTimer = setTimeout(() => {
        this._setConnectionState('disconnected')
      }, 100)

      this.conn.onclose = () => {
        clearTimeout(fallbackTimer)
        this._setConnectionState('disconnected')
      }

      // Close the WebSocket connection if close method exists
      if (typeof this.conn.close === 'function') {
        if (code) {
          this.conn.close(code, reason ?? '')
        } else {
          this.conn.close()
        }
      }

      this._teardownConnection()
    } else {
      this._setConnectionState('disconnected')
    }
  }

  /**
   * Returns all created channels
   */
  getChannels(): RealtimeChannel[] {
    return this.channels
  }

  /**
   * Unsubscribes and removes a single channel
   * @param channel A RealtimeChannel instance
   */
  async removeChannel(channel: RealtimeChannel): Promise<RealtimeRemoveChannelResponse> {
    const status = await channel.unsubscribe()

    if (this.channels.length === 0) {
      this.disconnect()
    }

    return status
  }

  /**
   * Unsubscribes and removes all channels
   */
  async removeAllChannels(): Promise<RealtimeRemoveChannelResponse[]> {
    const values_1 = await Promise.all(this.channels.map((channel) => channel.unsubscribe()))
    this.channels = []
    this.disconnect()
    return values_1
  }

  /**
   * Logs the message.
   *
   * For customized logging, `this.logger` can be overridden.
   */
  log(kind: string, msg: string, data?: any) {
    this.logger(kind, msg, data)
  }

  /**
   * Returns the current state of the socket.
   */
  connectionState(): CONNECTION_STATE {
    switch (this.conn && this.conn.readyState) {
      case SOCKET_STATES.connecting:
        return CONNECTION_STATE.Connecting
      case SOCKET_STATES.open:
        return CONNECTION_STATE.Open
      case SOCKET_STATES.closing:
        return CONNECTION_STATE.Closing
      default:
        return CONNECTION_STATE.Closed
    }
  }

  /**
   * Returns `true` is the connection is open.
   */
  isConnected(): boolean {
    return this.connectionState() === CONNECTION_STATE.Open
  }

  /**
   * Returns `true` if the connection is currently connecting.
   */
  isConnecting(): boolean {
    return this._connectionState === 'connecting'
  }

  /**
   * Returns `true` if the connection is currently disconnecting.
   */
  isDisconnecting(): boolean {
    return this._connectionState === 'disconnecting'
  }

  /**
   * Creates (or reuses) a {@link RealtimeChannel} for the provided topic.
   *
   * Topics are automatically prefixed with `realtime:` to match the Realtime service.
   * If a channel with the same topic already exists it will be returned instead of creating
   * a duplicate connection.
   */
  channel(topic: string, params: RealtimeChannelOptions = { config: {} }): RealtimeChannel {
    const realtimeTopic = `realtime:${topic}`
    const exists = this.getChannels().find((c: RealtimeChannel) => c.topic === realtimeTopic)

    if (!exists) {
      const chan = new RealtimeChannel(`realtime:${topic}`, params, this)
      this.channels.push(chan)

      return chan
    } else {
      return exists
    }
  }

  /**
   * Push out a message if the socket is connected.
   *
   * If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established.
   */
  push(data: RealtimeMessage): void {
    const { topic, event, payload, ref } = data
    const callback = () => {
      this.encode(data, (result: any) => {
        this.conn?.send(result)
      })
    }
    this.log('push', `${topic} ${event} (${ref})`, payload)
    if (this.isConnected()) {
      callback()
    } else {
      this.sendBuffer.push(callback)
    }
  }

  /**
   * Sets the JWT access token used for channel subscription authorization and Realtime RLS.
   *
   * If param is null it will use the `accessToken` callback function or the token set on the client.
   *
   * On callback used, it will set the value of the token internal to the client.
   *
   * When a token is explicitly provided, it will be preserved across channel operations
   * (including removeChannel and resubscribe). The `accessToken` callback will not be
   * invoked until `setAuth()` is called without arguments.
   *
   * @param token A JWT string to override the token set on the client.
   *
   * @example
   * // Use a manual token (preserved across resubscribes, ignores accessToken callback)
   * client.realtime.setAuth('my-custom-jwt')
   *
   * // Switch back to using the accessToken callback
   * client.realtime.setAuth()
   */
  async setAuth(token: string | null = null): Promise<void> {
    this._authPromise = this._performAuth(token)
    try {
      await this._authPromise
    } finally {
      this._authPromise = null
    }
  }

  /**
   * Returns true if the current access token was explicitly set via setAuth(token),
   * false if it was obtained via the accessToken callback.
   * @internal
   */
  _isManualToken(): boolean {
    return this._manuallySetToken
  }

  /**
   * Sends a heartbeat message if the socket is connected.
   */
  async sendHeartbeat() {
    if (!this.isConnected()) {
      try {
        this.heartbeatCallback('disconnected')
      } catch (e) {
        this.log('error', 'error in heartbeat callback', e)
      }
      return
    }

    // Handle heartbeat timeout and force reconnection if needed
    if (this.pendingHeartbeatRef) {
      this.pendingHeartbeatRef = null
      this._heartbeatSentAt = null
      this.log('transport', 'heartbeat timeout. Attempting to re-establish connection')
      try {
        this.heartbeatCallback('timeout')
      } catch (e) {
        this.log('error', 'error in heartbeat callback', e)
      }

      // Force reconnection after heartbeat timeout
      this._wasManualDisconnect = false
      this.conn?.close(WS_CLOSE_NORMAL, 'heartbeat timeout')

      setTimeout(() => {
        if (!this.isConnected()) {
          this.reconnectTimer?.scheduleTimeout()
        }
      }, CONNECTION_TIMEOUTS.HEARTBEAT_TIMEOUT_FALLBACK)
      return
    }

    // Send heartbeat message to server
    this._heartbeatSentAt = Date.now()
    this.pendingHeartbeatRef = this._makeRef()
    this.push({
      topic: 'phoenix',
      event: 'heartbeat',
      payload: {},
      ref: this.pendingHeartbeatRef,
    })
    try {
      this.heartbeatCallback('sent')
    } catch (e) {
      this.log('error', 'error in heartbeat callback', e)
    }

    this._setAuthSafely('heartbeat')
  }

  /**
   * Sets a callback that receives lifecycle events for internal heartbeat messages.
   * Useful for instrumenting connection health (e.g. sent/ok/timeout/disconnected).
   */
  onHeartbeat(callback: (status: HeartbeatStatus, latency?: number) => void): void {
    this.heartbeatCallback = callback
  }
  /**
   * Flushes send buffer
   */
  flushSendBuffer() {
    if (this.isConnected() && this.sendBuffer.length > 0) {
      this.sendBuffer.forEach((callback) => callback())
      this.sendBuffer = []
    }
  }

  /**
   * Use either custom fetch, if provided, or default fetch to make HTTP requests
   *
   * @internal
   */
  _resolveFetch = (customFetch?: Fetch): Fetch => {
    if (customFetch) {
      return (...args) => customFetch(...args)
    }
    return (...args) => fetch(...args)
  }

  /**
   * Return the next message ref, accounting for overflows
   *
   * @internal
   */
  _makeRef(): string {
    let newRef = this.ref + 1
    if (newRef === this.ref) {
      this.ref = 0
    } else {
      this.ref = newRef
    }

    return this.ref.toString()
  }

  /**
   * Unsubscribe from channels with the specified topic.
   *
   * @internal
   */
  _leaveOpenTopic(topic: string): void {
    let dupChannel = this.channels.find(
      (c) => c.topic === topic && (c._isJoined() || c._isJoining())
    )
    if (dupChannel) {
      this.log('transport', `leaving duplicate topic "${topic}"`)
      dupChannel.unsubscribe()
    }
  }

  /**
   * Removes a subscription from the socket.
   *
   * @param channel An open subscription.
   *
   * @internal
   */
  _remove(channel: RealtimeChannel) {
    this.channels = this.channels.filter((c) => c.topic !== channel.topic)
  }

  /** @internal */
  private _onConnMessage(rawMessage: { data: any }) {
    this.decode(rawMessage.data, (msg: RealtimeMessage) => {
      // Handle heartbeat responses
      if (
        msg.topic === 'phoenix' &&
        msg.event === 'phx_reply' &&
        msg.ref &&
        msg.ref === this.pendingHeartbeatRef
      ) {
        const latency = this._heartbeatSentAt ? Date.now() - this._heartbeatSentAt : undefined
        try {
          this.heartbeatCallback(msg.payload.status === 'ok' ? 'ok' : 'error', latency)
        } catch (e) {
          this.log('error', 'error in heartbeat callback', e)
        }
        this._heartbeatSentAt = null
        this.pendingHeartbeatRef = null
      }

      // Log incoming message
      const { topic, event, payload, ref } = msg
      const refString = ref ? `(${ref})` : ''
      const status = payload.status || ''
      this.log('receive', `${status} ${topic} ${event} ${refString}`.trim(), payload)

      // Route message to appropriate channels
      this.channels
        .filter((channel: RealtimeChannel) => channel._isMember(topic))
        .forEach((channel: RealtimeChannel) => channel._trigger(event, payload, ref))

      this._triggerStateCallbacks('message', msg)
    })
  }

  /**
   * Clear specific timer
   * @internal
   */
  private _clearTimer(timer: 'heartbeat' | 'reconnect'): void {
    if (timer === 'heartbeat' && this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = undefined
    } else if (timer === 'reconnect') {
      this.reconnectTimer?.reset()
    }
  }

  /**
   * Clear all timers
   * @internal
   */
  private _clearAllTimers(): void {
    this._clearTimer('heartbeat')
    this._clearTimer('reconnect')
  }

  /**
   * Setup connection handlers for WebSocket events
   * @internal
   */
  private _setupConnectionHandlers(): void {
    if (!this.conn) return

    // Set binary type if supported (browsers and most WebSocket implementations)
    if ('binaryType' in this.conn) {
      ;(this.conn as any).binaryType = 'arraybuffer'
    }

    this.conn.onopen = () => this._onConnOpen()
    this.conn.onerror = (error: Event) => this._onConnError(error)
    this.conn.onmessage = (event: any) => this._onConnMessage(event)
    this.conn.onclose = (event: any) => this._onConnClose(event)

    if (this.conn.readyState === SOCKET_STATES.open) {
      this._onConnOpen()
    }
  }

  /**
   * Teardown connection and cleanup resources
   * @internal
   */
  private _teardownConnection(): void {
    if (this.conn) {
      if (
        this.conn.readyState === SOCKET_STATES.open ||
        this.conn.readyState === SOCKET_STATES.connecting
      ) {
        try {
          this.conn.close()
        } catch (e) {
          this.log('error', 'Error closing connection', e)
        }
      }

      this.conn.onopen = null
      this.conn.onerror = null
      this.conn.onmessage = null
      this.conn.onclose = null
      this.conn = null
    }
    this._clearAllTimers()
    this._terminateWorker()
    this.channels.forEach((channel) => channel.teardown())
  }

  /** @internal */
  private _onConnOpen() {
    this._setConnectionState('connected')
    this.log('transport', `connected to ${this.endpointURL()}`)

    // Wait for any pending auth operations before flushing send buffer
    // This ensures channel join messages include the correct access token
    const authPromise =
      this._authPromise ||
      (this.accessToken && !this.accessTokenValue ? this.setAuth() : Promise.resolve())

    authPromise
      .then(() => {
        // When subscribe() is called before the accessToken callback has
        // resolved (common on React Native / Expo where token storage is
        // async), the phx_join payload captured at subscribe()-time will
        // have no access_token.  By this point auth has settled and
        // this.accessTokenValue holds the real JWT.
        //
        // The stale join messages sitting in sendBuffer captured the old
        // (token-less) payload in a closure, so we cannot simply flush
        // them.  Instead we:
        //   1. Patch each channel's joinPush payload with the real token
        //   2. Drop the stale buffered messages
        //   3. Re-send the join for any channel still in "joining" state
        //
        // On browsers this is a harmless no-op: accessTokenValue was
        // already set synchronously before subscribe() ran, so the join
        // payload already had the correct token.
        if (this.accessTokenValue) {
          this.channels.forEach((channel) => {
            channel.updateJoinPayload({ access_token: this.accessTokenValue })
          })
          this.sendBuffer = []
          this.channels.forEach((channel) => {
            if (channel._isJoining()) {
              channel.joinPush.sent = false
              channel.joinPush.send()
            }
          })
        }
        this.flushSendBuffer()
      })
      .catch((e) => {
        this.log('error', 'error waiting for auth on connect', e)
        // Proceed anyway to avoid hanging connections
        this.flushSendBuffer()
      })

    this._clearTimer('reconnect')

    if (!this.worker) {
      this._startHeartbeat()
    } else {
      if (!this.workerRef) {
        this._startWorkerHeartbeat()
      }
    }

    this._triggerStateCallbacks('open')
  }
  /** @internal */
  private _startHeartbeat() {
    this.heartbeatTimer && clearInterval(this.heartbeatTimer)
    this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatIntervalMs)
  }

  /** @internal */
  private _startWorkerHeartbeat() {
    if (this.workerUrl) {
      this.log('worker', `starting worker for from ${this.workerUrl}`)
    } else {
      this.log('worker', `starting default worker`)
    }
    const objectUrl = this._workerObjectUrl(this.workerUrl!)
    this.workerRef = new Worker(objectUrl)
    this.workerRef.onerror = (error) => {
      this.log('worker', 'worker error', (error as ErrorEvent).message)
      this._terminateWorker()
    }
    this.workerRef.onmessage = (event) => {
      if (event.data.event === 'keepAlive') {
        this.sendHeartbeat()
      }
    }
    this.workerRef.postMessage({
      event: 'start',
      interval: this.heartbeatIntervalMs,
    })
  }

  /**
   * Terminate the Web Worker and clear the reference
   * @internal
   */
  private _terminateWorker(): void {
    if (this.workerRef) {
      this.log('worker', 'terminating worker')
      this.workerRef.terminate()
      this.workerRef = undefined
    }
  }
  /** @internal */
  private _onConnClose(event: any) {
    this._setConnectionState('disconnected')
    this.log('transport', 'close', event)
    this._triggerChanError()
    this._clearTimer('heartbeat')

    // Only schedule reconnection if it wasn't a manual disconnect
    if (!this._wasManualDisconnect) {
      this.reconnectTimer?.scheduleTimeout()
    }

    this._triggerStateCallbacks('close', event)
  }

  /** @internal */
  private _onConnError(error: Event) {
    this._setConnectionState('disconnected')
    this.log('transport', `${error}`)
    this._triggerChanError()
    this._triggerStateCallbacks('error', error)
    try {
      this.heartbeatCallback('error')
    } catch (e) {
      this.log('error', 'error in heartbeat callback', e)
    }
  }

  /** @internal */
  private _triggerChanError() {
    this.channels.forEach((channel: RealtimeChannel) => channel._trigger(CHANNEL_EVENTS.error))
  }

  /** @internal */
  private _appendParams(url: string, params: { [key: string]: string }): string {
    if (Object.keys(params).length === 0) {
      return url
    }
    const prefix = url.match(/\?/) ? '&' : '?'
    const query = new URLSearchParams(params)
    return `${url}${prefix}${query}`
  }

  private _workerObjectUrl(url: string | undefined): string {
    let result_url: string
    if (url) {
      result_url = url
    } else {
      const blob = new Blob([WORKER_SCRIPT], { type: 'application/javascript' })
      result_url = URL.createObjectURL(blob)
    }
    return result_url
  }

  /**
   * Set connection state with proper state management
   * @internal
   */
  private _setConnectionState(state: RealtimeClientState, manual = false): void {
    this._connectionState = state

    if (state === 'connecting') {
      this._wasManualDisconnect = false
    } else if (state === 'disconnecting') {
      this._wasManualDisconnect = manual
    }
  }

  /**
   * Perform the actual auth operation
   * @internal
   */
  private async _performAuth(token: string | null = null): Promise<void> {
    let tokenToSend: string | null
    let isManualToken = false

    if (token) {
      tokenToSend = token
      // Track if this is a manually-provided token
      isManualToken = true
    } else if (this.accessToken) {
      // Call the accessToken callback to get fresh token
      try {
        tokenToSend = await this.accessToken()
      } catch (e) {
        this.log('error', 'Error fetching access token from callback', e)
        // Fall back to cached value if callback fails
        tokenToSend = this.accessTokenValue
      }
    } else {
      tokenToSend = this.accessTokenValue
    }

    // Track whether this token was manually set or fetched via callback
    if (isManualToken) {
      this._manuallySetToken = true
    } else if (this.accessToken) {
      // If we used the callback, clear the manual flag
      this._manuallySetToken = false
    }

    if (this.accessTokenValue != tokenToSend) {
      this.accessTokenValue = tokenToSend
      this.channels.forEach((channel) => {
        const payload = {
          access_token: tokenToSend,
          version: DEFAULT_VERSION,
        }

        tokenToSend && channel.updateJoinPayload(payload)

        if (channel.joinedOnce && channel._isJoined()) {
          channel._push(CHANNEL_EVENTS.access_token, {
            access_token: tokenToSend,
          })
        }
      })
    }
  }

  /**
   * Wait for any in-flight auth operations to complete
   * @internal
   */
  private async _waitForAuthIfNeeded(): Promise<void> {
    if (this._authPromise) {
      await this._authPromise
    }
  }

  /**
   * Safely call setAuth with standardized error handling
   * @internal
   */
  private _setAuthSafely(context = 'general'): void {
    // Only refresh auth if using callback-based tokens
    if (!this._isManualToken()) {
      this.setAuth().catch((e) => {
        this.log('error', `Error setting auth in ${context}`, e)
      })
    }
  }

  /**
   * Trigger state change callbacks with proper error handling
   * @internal
   */
  private _triggerStateCallbacks(event: keyof typeof this.stateChangeCallbacks, data?: any): void {
    try {
      this.stateChangeCallbacks[event].forEach((callback) => {
        try {
          callback(data)
        } catch (e) {
          this.log('error', `error in ${event} callback`, e)
        }
      })
    } catch (e) {
      this.log('error', `error triggering ${event} callbacks`, e)
    }
  }

  /**
   * Setup reconnection timer with proper configuration
   * @internal
   */
  private _setupReconnectionTimer(): void {
    this.reconnectTimer = new Timer(async () => {
      setTimeout(async () => {
        await this._waitForAuthIfNeeded()
        if (!this.isConnected()) {
          this.connect()
        }
      }, CONNECTION_TIMEOUTS.RECONNECT_DELAY)
    }, this.reconnectAfterMs)
  }

  /**
   * Initialize client options with defaults
   * @internal
   */
  private _initializeOptions(options?: RealtimeClientOptions): void {
    // Set defaults
    this.transport = options?.transport ?? null
    this.timeout = options?.timeout ?? DEFAULT_TIMEOUT
    this.heartbeatIntervalMs =
      options?.heartbeatIntervalMs ?? CONNECTION_TIMEOUTS.HEARTBEAT_INTERVAL
    this.worker = options?.worker ?? false
    this.accessToken = options?.accessToken ?? null
    this.heartbeatCallback = options?.heartbeatCallback ?? noop
    this.vsn = options?.vsn ?? DEFAULT_VSN

    // Handle special cases
    if (options?.params) this.params = options.params
    if (options?.logger) this.logger = options.logger
    if (options?.logLevel || options?.log_level) {
      this.logLevel = options.logLevel || options.log_level
      this.params = { ...this.params, log_level: this.logLevel as string }
    }

    // Set up functions with defaults
    this.reconnectAfterMs =
      options?.reconnectAfterMs ??
      ((tries: number) => {
        return RECONNECT_INTERVALS[tries - 1] || DEFAULT_RECONNECT_FALLBACK
      })

    switch (this.vsn) {
      case VSN_1_0_0:
        this.encode =
          options?.encode ??
          ((payload: JSON, callback: Function) => {
            return callback(JSON.stringify(payload))
          })

        this.decode =
          options?.decode ??
          ((payload: string, callback: Function) => {
            return callback(JSON.parse(payload))
          })
        break
      case VSN_2_0_0:
        this.encode = options?.encode ?? this.serializer.encode.bind(this.serializer)
        this.decode = options?.decode ?? this.serializer.decode.bind(this.serializer)
        break
      default:
        throw new Error(`Unsupported serializer version: ${this.vsn}`)
    }

    // Handle worker setup
    if (this.worker) {
      if (typeof window !== 'undefined' && !window.Worker) {
        throw new Error('Web Worker is not supported')
      }
      this.workerUrl = options?.workerUrl
    }
  }
}
