/**
 * T4 — `TerminalTransport` seam + `PassthroughTransport` (v0.8, no E2E).
 *
 * The abstraction that lets browser-side E2E (T8) "drop in" without reshaping the view: both the
 * passthrough and the E2E transport implement `TerminalTransport`, so `terminal-view.ts` never
 * changes. In v0.8 there is NO capability token (INDEX §1) — the same-origin signed cookie (T3)
 * authenticates the upgrade and the base-app Origin/CSWSH check (M6) is retained.
 *
 * Token attachment (v0.9+): the §4.3 capability token rides the `Sec-WebSocket-Protocol`
 * subprotocol list (frozen §4.3 wire format via `encodeTokenSubprotocol`), NEVER the URL/query
 * string (which would leak the bearer credential into proxy/access logs, history, and `Referer`).
 * After open, the client asserts the echoed subprotocol is exactly `APP_SUBPROTOCOL` and tears the
 * socket down otherwise (echo rule).
 */
import { APP_SUBPROTOCOL, encodeTokenSubprotocol } from 'relay-contracts'
import type { RelayWebConfig } from './config'
import { encodeDpopSubprotocol } from './dpop'

/** The seam every transport implements — plaintext in the browser, encoded by the impl. */
export interface TerminalTransport {
  open(): Promise<void>
  send(bytes: Uint8Array): void
  onMessage(cb: (bytes: Uint8Array) => void): void
  onClose(cb: (reason: string) => void): void
  close(): void
}

/** Minimal structural WebSocket surface (so tests can inject a mock, no jsdom WS dependency). */
export interface WebSocketLike {
  binaryType: string
  protocol: string
  send(data: ArrayBufferView | ArrayBufferLike | string): void
  close(code?: number, reason?: string): void
  onopen: ((ev: unknown) => void) | null
  onmessage: ((ev: { data: unknown }) => void) | null
  onclose: ((ev: { code?: number; reason?: string }) => void) | null
  onerror: ((ev: unknown) => void) | null
}

export type WebSocketCtor = new (url: string, protocols?: string | readonly string[]) => WebSocketLike

export interface PassthroughOpts {
  /** v0.9+ populates this; ABSENT in v0.8 (cookie-only auth). */
  readonly capabilityToken?: string
  /**
   * Phase-1 STAGING (B6): a DPoP proof-of-possession JWS bound to the token's `cnf.jkt`. Browsers
   * can't set the `dpop` request header on a native WS upgrade, so — mirroring the §4.3 token — it
   * rides an extra `term.dpop.<b64u>` subprotocol entry (see dpop.ts `encodeDpopSubprotocol`). The
   * current relay ignores it (its `handleProtocols` echoes only `APP_SUBPROTOCOL`), so the handshake
   * is unaffected; true DPoP enforcement needs the relay to read this entry (server-lane, VPS-gated).
   */
  readonly dpopProof?: string
  /** DI seam: inject a mock WebSocket constructor in tests; defaults to global `WebSocket`. */
  readonly wsCtor?: WebSocketCtor
}

/** Map a WS close event to a stable reason string the UI can render (no silent swallow). */
function closeReason(ev: { code?: number; reason?: string }): string {
  if (ev.code === 4403) return 'forbidden' // INV1/INV6 upgrade denial mirror
  if (ev.code === 4401) return 'unauthenticated'
  if (ev.reason && ev.reason.length > 0) return ev.reason
  return 'closed'
}

/** Coerce a WS message payload (ArrayBuffer | ArrayBufferView | string) to bytes. */
function toBytes(data: unknown): Uint8Array {
  if (data instanceof Uint8Array) return data
  if (data instanceof ArrayBuffer) return new Uint8Array(data)
  if (ArrayBuffer.isView(data)) {
    const view = data as ArrayBufferView
    return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
  }
  if (typeof data === 'string') return new TextEncoder().encode(data)
  return new Uint8Array(0)
}

export function createPassthroughTransport(
  cfg: RelayWebConfig,
  opts: PassthroughOpts = {},
): TerminalTransport {
  const Ctor: WebSocketCtor = opts.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
  const token = opts.capabilityToken
  const dpopProof = opts.dpopProof
  const hasBearer = token !== undefined || dpopProof !== undefined
  const url = cfg.wsUrl('/term') // SAME-ORIGIN, scheme-following; NEVER carries a token (T4 §)

  let ws: WebSocketLike | null = null
  let messageCb: ((bytes: Uint8Array) => void) | null = null
  let closeCb: ((reason: string) => void) | null = null

  // App subprotocol FIRST; token entry (v0.9+) second; DPoP proof entry (Phase-1 B6) last — a
  // bearer NEVER touches the URL/query (would leak it to proxy/access logs, history, and Referer).
  const protocols: readonly string[] = [
    APP_SUBPROTOCOL,
    ...(token === undefined ? [] : [encodeTokenSubprotocol(token)]),
    ...(dpopProof === undefined ? [] : [encodeDpopSubprotocol(dpopProof)]),
  ]

  function open(): Promise<void> {
    return new Promise((resolve, reject) => {
      const socket = new Ctor(url, protocols)
      socket.binaryType = 'arraybuffer'
      ws = socket

      socket.onopen = () => {
        // Echo rule (v0.9+): with a bearer attached (token and/or DPoP), accept ONLY the app
        // subprotocol back — a relay echoing a bearer entry, an empty value, or a foreign one is
        // rejected (bearer-leak guard).
        if (hasBearer && socket.protocol !== APP_SUBPROTOCOL) {
          socket.close(4400, 'bad-subprotocol')
          reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
          return
        }
        resolve()
      }
      socket.onmessage = (ev) => {
        if (messageCb) messageCb(toBytes(ev.data))
      }
      socket.onclose = (ev) => {
        if (closeCb) closeCb(closeReason(ev))
      }
      socket.onerror = () => {
        // Surface as a close so callers never hang; open() rejection is handled by onclose too.
        reject(new Error('websocket error'))
      }
    })
  }

  return {
    open,
    send(bytes: Uint8Array): void {
      if (!ws) throw new Error('transport not open')
      ws.send(bytes)
    },
    onMessage(cb: (bytes: Uint8Array) => void): void {
      messageCb = cb
    },
    onClose(cb: (reason: string) => void): void {
      closeCb = cb
    },
    close(): void {
      if (ws) ws.close()
    },
  }
}
