/**
 * T8 (v0.10) — browser-side E2E transport. Implements the SAME `TerminalTransport` (T4) so
 * `terminal-view.ts` is unchanged (the drop-in swap). Consumes the FROZEN §4.4 surface
 * (`E2ESession`/`DirectionalKeys`/`sealFrame`/`openFrame`) — cited via injected `relay-e2e` impls,
 * NEVER re-implemented here, and NOT a single sessionKey via raw SubtleCrypto AES-GCM.
 *
 * Trust root (closes the first-connect TOFU gap): `expectedFpr` is the caller's
 * `api.getHost(hostId).enrollFpr` — the P5-authenticated HTTPS control-plane record, out-of-band of
 * the relay. On ANY disagreement between the relay-forwarded host fingerprint and `expectedFpr` —
 * INCLUDING THE VERY FIRST CONNECTION — `onPinMismatch(...,'api')` fires (default `'abort'`), the
 * handshake never completes, and NO session key material is derived. `HostPinStore` is only a
 * cache/audit trail of that authenticated value.
 *
 * Any `open`/`openFrame` throw (AEAD-tag / seq / direction-confusion failure, INV13) TEARS THE
 * SOCKET DOWN — a bad frame is never rendered or swallowed.
 *
 * INTEGRATION POINT: the concrete §4.4 handshake message framing over the relay (client_hello /
 * host_hello wire) and the crypto impls are injected via {@link E2ETransportDeps.runHandshake}
 * (wired to P4 `relay-e2e` + P2's agent-side counterpart in production). This module owns the WS
 * lifecycle, capability-token attachment (§4.3), the fingerprint-pin gate, and the seal/open
 * teardown — all independently testable with a loopback relay-e2e session pair.
 */
import { APP_SUBPROTOCOL, encodeTokenSubprotocol, type AeadAlg } from 'relay-contracts'
import type { AuthorizedDeviceContext, E2ESession } from 'relay-contracts'
import type { RelayWebConfig } from './config'
import type { HostPinStore } from './host-pin-store'
import type { TerminalTransport, WebSocketCtor, WebSocketLike } from './ws-transport'

export type PinMismatchSource = 'api' | 'cache'
export type FprDecision = 'trust' | 'abort'

/** Raw byte channel the handshake driver uses to exchange client_hello / host_hello opaquely. */
export interface E2EHandshakeIo {
  sendRaw(bytes: Uint8Array): void
  onRaw(cb: (bytes: Uint8Array) => void): void
}

export interface RunHandshakeArgs {
  readonly hostId: string
  readonly aeadOffer: readonly AeadAlg[]
  readonly context: AuthorizedDeviceContext
  /** API-sourced host signing pubkey (P3 registry, out-of-band of the relay). */
  readonly agentPubkey: Uint8Array
  /**
   * Gate called with the host's OFFERED fingerprint BEFORE any key material is derived. Returning
   * `'abort'` MUST make `runHandshake` reject without deriving keys (no MITM key material ever).
   */
  verifyOfferedFpr(offeredFpr: string): Promise<FprDecision>
}

export interface E2ETransportDeps {
  /**
   * Drives the §4.4 handshake through `io` and returns an established `E2ESession`, or rejects if
   * `verifyOfferedFpr` returned `'abort'`. Wired to relay-e2e (`buildClientHandshake` →
   * `ClientHandshake.start`/`.onHostHello` → `createE2ESession('client', result)`) in production.
   */
  runHandshake(io: E2EHandshakeIo, args: RunHandshakeArgs): Promise<E2ESession>
  readonly context: AuthorizedDeviceContext
  readonly agentPubkey: Uint8Array
  /** AEAD offer; lists `aes-256-gcm` FIRST (fast native-adjacent path); host makes the final choice. */
  readonly aeadOffer?: readonly AeadAlg[]
  readonly wsCtor?: WebSocketCtor
}

const DEFAULT_AEAD_OFFER: readonly AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']

function toBytes(data: unknown): Uint8Array {
  if (data instanceof Uint8Array) return data
  if (data instanceof ArrayBuffer) return new Uint8Array(data)
  if (ArrayBuffer.isView(data)) {
    const v = data as ArrayBufferView
    return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
  }
  if (typeof data === 'string') return new TextEncoder().encode(data)
  return new Uint8Array(0)
}

export function createE2ETransport(
  cfg: RelayWebConfig,
  capabilityToken: string,
  hostId: string,
  expectedFpr: string,
  pins: HostPinStore,
  onPinMismatch: (
    expected: string,
    offered: string,
    source: PinMismatchSource,
  ) => Promise<FprDecision>,
  deps: E2ETransportDeps,
): TerminalTransport {
  const Ctor: WebSocketCtor = deps.wsCtor ?? (globalThis.WebSocket as unknown as WebSocketCtor)
  const url = cfg.wsUrl('/term') // SAME-ORIGIN; token rides the subprotocol, NEVER the URL (§4.3)
  const protocols: readonly string[] = [APP_SUBPROTOCOL, encodeTokenSubprotocol(capabilityToken)]

  let ws: WebSocketLike | null = null
  let session: E2ESession | null = null
  let rawCb: ((bytes: Uint8Array) => void) | null = null // handshake-phase sink
  let userMessageCb: ((bytes: Uint8Array) => void) | null = null
  let closeCb: ((reason: string) => void) | null = null
  let torn = false

  function tearDown(reason: string): void {
    if (torn) return
    torn = true
    if (ws) ws.close()
    if (closeCb) closeCb(reason)
  }

  /** The fingerprint-pin gate — the anti-MITM trust decision (API value is authoritative). */
  async function verifyOfferedFpr(offeredFpr: string): Promise<FprDecision> {
    if (offeredFpr !== expectedFpr) {
      // Relay-forwarded fingerprint disagrees with the authenticated API value — first connect too.
      if ((await onPinMismatch(expectedFpr, offeredFpr, 'api')) === 'abort') return 'abort'
    }
    const cached = pins.get(hostId)
    if (cached !== null && cached !== expectedFpr) {
      // Rotation: cache disagrees with a fresh API value. Advisory — API remains authoritative.
      if ((await onPinMismatch(expectedFpr, cached, 'cache')) === 'abort') return 'abort'
    }
    return 'trust'
  }

  function openWs(): Promise<void> {
    return new Promise((resolve, reject) => {
      const socket = new Ctor(url, protocols)
      socket.binaryType = 'arraybuffer'
      ws = socket
      socket.onopen = () => {
        // Echo rule (§4.3): accept ONLY the app subprotocol back (bearer-leak guard).
        if (socket.protocol !== APP_SUBPROTOCOL) {
          socket.close(4400, 'bad-subprotocol')
          reject(new Error(`unexpected echoed subprotocol: '${socket.protocol}'`))
          return
        }
        resolve()
      }
      socket.onmessage = (ev) => {
        const bytes = toBytes(ev.data)
        if (session) {
          // DATA phase: decrypt; ANY openFrame throw tears the socket down (INV13).
          let plain: Uint8Array
          try {
            plain = session.open(bytes)
          } catch {
            tearDown('e2e-verify-failed')
            return
          }
          if (userMessageCb) userMessageCb(plain)
        } else if (rawCb) {
          rawCb(bytes) // handshake phase
        }
      }
      socket.onclose = (ev) => {
        const reason = ev.code === 4403 ? 'forbidden' : ev.reason && ev.reason.length > 0 ? ev.reason : 'closed'
        if (!torn) {
          torn = true
          if (closeCb) closeCb(reason)
        }
      }
      socket.onerror = () => reject(new Error('websocket error'))
    })
  }

  async function open(): Promise<void> {
    await openWs()
    const io: E2EHandshakeIo = {
      sendRaw: (bytes) => ws?.send(bytes),
      onRaw: (cb) => {
        rawCb = cb
      },
    }
    let established: E2ESession
    try {
      established = await deps.runHandshake(io, {
        hostId,
        aeadOffer: deps.aeadOffer ?? DEFAULT_AEAD_OFFER,
        context: deps.context,
        agentPubkey: deps.agentPubkey,
        verifyOfferedFpr,
      })
    } catch (err) {
      // Abort (fingerprint mismatch) or any handshake failure → no session, socket closed.
      tearDown('e2e-handshake-failed')
      throw err
    }
    session = established
    // Cache the API-VERIFIED fingerprint (audit/drift trail) — NOT a handshake-derived value.
    pins.record(hostId, expectedFpr)
  }

  return {
    open,
    send(bytes: Uint8Array): void {
      if (!ws || !session) throw new Error('e2e transport not open')
      ws.send(session.seal(bytes)) // plaintext never leaves un-AEAD'd (INV2)
    },
    onMessage(cb: (bytes: Uint8Array) => void): void {
      userMessageCb = cb
    },
    onClose(cb: (reason: string) => void): void {
      closeCb = cb
    },
    close(): void {
      tearDown('closed')
    },
  }
}
