import type { RpcRequest, RpcResponse } from 'ox'

import type * as Store from './Store.js'

/** Messenger interface for cross-frame communication. */
export type Messenger = {
  /** Tear down all listeners. */
  destroy: () => void
  /** Subscribe to a topic. Returns an unsubscribe function. */
  on: <const topic extends Topic>(
    topic: topic | Topic,
    listener: (payload: Payload<topic>, event: MessageEvent) => void,
    id?: string | undefined,
  ) => () => void
  /** Send a message on a topic. */
  send: <const topic extends Topic>(
    topic: topic | Topic,
    payload: Payload<topic>,
    targetOrigin?: string | undefined,
  ) => Promise<{ id: string; topic: topic; payload: Payload<topic> }>
}

/** Options sent with the `ready` signal from the remote frame. */
export type ReadyOptions = {
  /** CSS `color-scheme` used by the remote embed (e.g. `'dark'`). */
  colorScheme?: string | undefined
  /** Hostnames trusted by the remote embed to render in an iframe. */
  trustedHosts?: readonly string[] | undefined
}

/** Bridge messenger that waits for a `ready` signal from the remote frame. */
export type Bridge = Messenger & {
  /** Signal readiness (called by the remote frame). */
  ready: (options?: ReadyOptions | undefined) => void
  /** Promise that resolves when the remote frame signals ready. */
  waitForReady: () => Promise<ReadyOptions>
}

/** Message schema for cross-frame communication. */
export type Schema = [
  {
    topic: 'ready'
    payload: ReadyOptions
  },
  {
    topic: 'rpc-requests'
    payload: {
      account: { address: string } | undefined
      chainId: number
      requests: readonly Store.QueuedRequest[]
    }
  },
  {
    topic: 'rpc-response'
    payload: RpcResponse.RpcResponse & {
      _request: RpcRequest.RpcRequest
    }
  },
  {
    topic: 'close'
    payload: undefined
  },
  {
    topic: 'switch-mode'
    payload: { mode: 'popup' }
  },
  {
    topic: 'sync'
    payload: { addresses?: readonly string[] | undefined; valid?: boolean | undefined }
  },
  {
    topic: 'theme'
    payload: {
      accent?: string | undefined
      radius?: string | undefined
      scheme?: string | undefined
    }
  },
]

/** Union of all topic strings. */
export type Topic = Schema[number]['topic']

/** Payload for a given topic. */
export type Payload<topic extends Topic> = Extract<Schema[number], { topic: topic }>['payload']

/** Creates a messenger from a custom implementation. */
export function from(messenger: Messenger): Messenger {
  return messenger
}

/**
 * Creates a messenger backed by `window.postMessage` / `addEventListener('message')`.
 * Filters messages by `targetOrigin` when provided.
 */
export function fromWindow(w: Window, options: fromWindow.Options = {}): Messenger {
  const { targetOrigin } = options
  const listeners = new Map<string, (event: MessageEvent) => void>()

  return from({
    destroy() {
      for (const listener of listeners.values()) w.removeEventListener('message', listener)
      listeners.clear()
    },
    on(topic, listener, id) {
      function onMessage(event: MessageEvent) {
        if (event.data.topic !== topic) return
        if (id && event.data.id !== id) return
        if (targetOrigin && event.origin !== targetOrigin) return
        listener(event.data.payload as never, event)
      }
      w.addEventListener('message', onMessage)
      listeners.set(topic, onMessage)
      return () => {
        w.removeEventListener('message', onMessage)
        listeners.delete(topic)
      }
    },
    async send(topic, payload, target) {
      const id = crypto.randomUUID()
      w.postMessage(normalizeValue({ id, payload, topic }), target ?? targetOrigin ?? '*')
      return { id, payload, topic } as never
    },
  })
}

export declare namespace fromWindow {
  type Options = {
    /** Only accept messages from this origin. Also used as the `targetOrigin` for `postMessage`. */
    targetOrigin?: string | undefined
  }
}

/**
 * Bridges two window messengers. The bridge waits for a `ready` signal
 * before sending messages when `waitForReady` is `true`.
 */
export function bridge(parameters: bridge.Parameters): Bridge {
  const { from: from_, to, waitForReady = false } = parameters

  let pending = false

  const ready = withResolvers<ReadyOptions>()
  from_.on('ready', (payload) => ready.resolve(payload ?? {}))

  const messenger = from({
    destroy() {
      from_.destroy()
      to.destroy()
      if (pending) ready.reject()
    },
    on(topic, listener, id) {
      return from_.on(topic, listener, id)
    },
    async send(topic, payload) {
      pending = true
      if (waitForReady) await ready.promise.finally(() => (pending = false))
      return to.send(topic, payload)
    },
  })

  return {
    ...messenger,
    ready(options) {
      void messenger.send('ready', options ?? {})
    },
    waitForReady() {
      return ready.promise
    },
  }
}

export declare namespace bridge {
  type Parameters = {
    /** Listens on this messenger. */
    from: Messenger
    /** Sends to this messenger. */
    to: Messenger
    /** Buffer sends until `ready` is received. */
    waitForReady?: boolean | undefined
  }
}

/** Returns a no-op bridge for SSR environments. */
export function noop(): Bridge {
  return {
    destroy() {},
    on() {
      return () => {}
    },
    send() {
      return Promise.resolve(undefined as never)
    },
    ready() {},
    waitForReady() {
      return Promise.resolve({})
    },
  }
}

function withResolvers<type>() {
  let resolve: (value: type | PromiseLike<type>) => void = () => undefined
  let reject: (reason?: unknown) => void = () => undefined
  const promise = new Promise<type>((resolve_, reject_) => {
    resolve = resolve_
    reject = reject_
  })
  return { promise, reject, resolve }
}

/**
 * Normalizes a value into a structured-clone compatible format.
 *
 * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone
 */
function normalizeValue<type>(value: type): type {
  if (Array.isArray(value)) return value.map(normalizeValue) as never
  if (typeof value === 'function') return undefined as never
  if (typeof value !== 'object' || value === null) return value
  if (Object.getPrototypeOf(value) !== Object.prototype)
    try {
      return structuredClone(value)
    } catch {
      return undefined as never
    }

  const normalized: Record<string, unknown> = {}
  for (const [k, v] of Object.entries(value)) normalized[k] = normalizeValue(v)
  return normalized as never
}
