/**
 * Consumes the go-chaintracks (Arcade) reorg SSE stream (`GET /v2/reorg/stream`)
 * and normalizes each `ReorgEvent` into the overlay engine's `handleReorg` input.
 *
 * The stream emits `data: <JSON>\n\n` frames (no `event:`/`id:` lines) with
 * `: keepalive` comment frames in between. Because there are no event ids, a
 * reconnect cannot replay events missed while disconnected — so every (re)connect
 * triggers an `onConnect` catch-up (the engine's revalidation sweep).
 */

/** Input shape accepted by `Engine.handleReorg`. */
export interface ReorgHandlerInput {
  orphanedBlockHashes: string[]
  rebuildFromHeight: number
  newTipHeight: number
}

/**
 * Parses a single SSE data frame (a go-chaintracks `ReorgEvent` JSON document)
 * into `handleReorg` input. Returns `null` for malformed frames.
 *
 * `ReorgEvent` = { orphanedHashes: string[], commonAncestor: BlockHeader|null,
 * newTip: BlockHeader, depth: number }. Block hashes are go-sdk `chainhash.Hash`
 * values marshaled as reversed (display) hex; they are lower-cased here to match
 * the overlay's stored block hashes.
 */
export function parseReorgEvent (frame: string): ReorgHandlerInput | null {
  let event: any
  try {
    event = JSON.parse(frame)
  } catch {
    return null
  }

  const newTipHeight = event?.newTip?.height
  if (typeof newTipHeight !== 'number') {
    return null
  }

  const orphanedBlockHashes: string[] = Array.isArray(event.orphanedHashes)
    ? event.orphanedHashes
      .filter((hash: unknown): hash is string => typeof hash === 'string')
      .map((hash: string) => hash.toLowerCase())
    : []

  const ancestorHeight: unknown = event?.commonAncestor?.height
  const depth: unknown = event?.depth
  let rebuildFromHeight: number
  if (typeof ancestorHeight === 'number') {
    rebuildFromHeight = ancestorHeight + 1
  } else if (typeof depth === 'number') {
    // No common ancestor (e.g. reorg deeper than retained history): fall back to
    // the reported depth from the new tip.
    rebuildFromHeight = Math.max(0, newTipHeight - depth + 1)
  } else {
    return null
  }

  return { orphanedBlockHashes, rebuildFromHeight, newTipHeight }
}

/**
 * Splits an SSE byte buffer into complete event payloads. Frames are delimited by
 * a blank line (`\n\n`); within a frame, `data:` lines are concatenated and
 * comment lines (starting with `:`) are ignored. Any trailing partial frame is
 * returned in `rest` for the next read.
 */
export function extractSseFrames (buffer: string): { events: string[], rest: string } {
  const events: string[] = []
  let working = buffer
  let boundary = working.indexOf('\n\n')
  while (boundary !== -1) {
    const block = working.slice(0, boundary)
    working = working.slice(boundary + 2)
    const dataLines: string[] = []
    for (const line of block.split('\n')) {
      if (line.startsWith(':')) {
        continue
      }
      if (line.startsWith('data:')) {
        dataLines.push(line.slice(5).replace(/^ /, ''))
      }
    }
    if (dataLines.length > 0) {
      events.push(dataLines.join(''))
    }
    boundary = working.indexOf('\n\n')
  }
  return { events, rest: working }
}

export interface ReorgSseAdapterOptions {
  /** Reorg SSE URL, e.g. `https://arcade.example/v2/reorg/stream`. */
  url: string
  /** Invoked for each parsed reorg event. */
  onReorg: (input: ReorgHandlerInput) => Promise<void>
  /** Invoked on every (re)connect for catch-up, since the stream has no replay. */
  onConnect?: () => Promise<void>
  logger?: Pick<typeof console, 'log' | 'warn' | 'error'>
  /** Delay before reconnecting after the stream ends or errors. */
  reconnectDelayMs?: number
  /** Injectable fetch (defaults to global fetch); aids testing. */
  fetchImpl?: typeof fetch
}

/**
 * Long-lived SSE client that keeps the overlay's BASM anchors reconciled with
 * chain reorgs. Auto-reconnects with a fixed delay and runs a catch-up on connect.
 */
export class ReorgSseAdapter {
  private readonly url: string
  private readonly onReorg: (input: ReorgHandlerInput) => Promise<void>
  private readonly onConnect?: () => Promise<void>
  private readonly logger: Pick<typeof console, 'log' | 'warn' | 'error'>
  private readonly reconnectDelayMs: number
  private readonly fetchImpl: typeof fetch
  private controller?: AbortController
  private stopped = false

  constructor (options: ReorgSseAdapterOptions) {
    this.url = options.url
    this.onReorg = options.onReorg
    this.onConnect = options.onConnect
    this.logger = options.logger ?? console
    this.reconnectDelayMs = options.reconnectDelayMs ?? 5000
    this.fetchImpl = options.fetchImpl ?? fetch
  }

  /** Begins consuming the stream in the background. */
  start (): void {
    if (this.stopped) {
      return
    }
    void this.runLoop()
  }

  /** Stops consuming and aborts any in-flight connection. */
  stop (): void {
    this.stopped = true
    this.controller?.abort()
  }

  private async runLoop (): Promise<void> {
    while (!this.stopped) {
      try {
        await this.connectOnce()
      } catch (error) {
        if (!this.stopped) {
          this.logger.warn(`[BASM] reorg stream error: ${error instanceof Error ? error.message : String(error)}`)
        }
      }
      if (this.stopped) {
        break
      }
      await this.delay(this.reconnectDelayMs)
    }
  }

  private async connectOnce (): Promise<void> {
    this.controller = new AbortController()
    const response = await this.fetchImpl(this.url, {
      headers: { Accept: 'text/event-stream' },
      signal: this.controller.signal
    })
    if (!response.ok || response.body == null) {
      throw new Error(`reorg stream responded ${response.status}`)
    }

    // No event-id replay on this stream: catch up on (re)connect.
    await this.runCatchUp()
    await this.pumpEvents(response.body.getReader())
  }

  private async runCatchUp (): Promise<void> {
    if (this.onConnect === undefined) {
      return
    }
    try {
      await this.onConnect()
    } catch (error) {
      this.logger.warn(`[BASM] reorg catch-up failed: ${error instanceof Error ? error.message : String(error)}`)
    }
  }

  private async pumpEvents (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
    const decoder = new TextDecoder()
    let buffer = ''
    while (!this.stopped) {
      const { value, done } = await reader.read()
      if (done) {
        break
      }
      buffer += decoder.decode(value, { stream: true })
      const { events, rest } = extractSseFrames(buffer)
      buffer = rest
      for (const frame of events) {
        await this.processReorgFrame(frame)
      }
    }
  }

  private async processReorgFrame (frame: string): Promise<void> {
    const input = parseReorgEvent(frame)
    if (input === null) {
      this.logger.warn('[BASM] skipping malformed reorg frame')
      return
    }
    try {
      await this.onReorg(input)
    } catch (error) {
      this.logger.error(`[BASM] handleReorg failed: ${error instanceof Error ? error.message : String(error)}`)
    }
  }

  private async delay (ms: number): Promise<void> {
    await new Promise<void>(resolve => {
      const timer = setTimeout(resolve, ms)
      timer.unref?.()
    })
  }
}
