/**
 * WebSocket bridge.
 *
 * Connects to `<base>/v1/bridge/ws?…` (where `base` uses the http(s):// URL
 * — the bridge swaps to ws(s):// internally) and consumes JSON-encoded
 * `BridgeWireMessage` frames pushed by the server.
 *
 * Auth uses the same signed query-string scheme as SSE/long-poll. The server
 * runs the verification at upgrade time and rejects upgrades with a 4xx;
 * once the WebSocket is open, frames are unauthenticated at the WS layer
 * (the address-owner relationship is bound at upgrade and the connection is
 * dedicated to that address).
 */

import type { BridgeConnectOptions, BridgeTransport, BridgeWireMessage } from './types.js';
import { decodeWireMessage } from './types.js';
import type { BridgeAuthInput } from './auth.js';
import { signBridgeQuery } from './auth.js';
import { BridgeError } from './errors.js';

export interface WsBridgeOptions {
  baseUrl: string;
  auth: Omit<BridgeAuthInput, 'kind' | 'since'>;
  /** WebSocket constructor override (browsers vs Node vs Bun). */
  WebSocketCtor?: typeof WebSocket;
  /** Connect timeout (ms). Default 5_000. */
  connectTimeoutMs?: number;
  /** Initial cursor. */
  startCursor?: number;
  /** Disable auto-reconnect on drop. Default false. */
  disableAutoReconnect?: boolean;
  /** Initial reconnect backoff (ms). Default 250. */
  initialBackoffMs?: number;
  /** Max reconnect backoff (ms). Default 10_000. */
  maxBackoffMs?: number;
}

const DEFAULT_CONNECT_TIMEOUT = 5_000;
const DEFAULT_INITIAL_BACKOFF = 250;
const DEFAULT_MAX_BACKOFF = 10_000;

export class WsBridge implements BridgeTransport {
  readonly kind = 'ws';
  private readonly Ctor: typeof WebSocket;
  private cursor: number;
  private connectStarted = false;
  private disposed = false;
  private current: WebSocket | null = null;
  private onMessage: BridgeConnectOptions['onMessage'] | null = null;
  private onError: NonNullable<BridgeConnectOptions['onError']> = (err) =>
    console.warn('[shade-bridge:ws]', err.message);
  private reconnectPromise: Promise<void> | null = null;

  constructor(private readonly options: WsBridgeOptions) {
    const ctor =
      options.WebSocketCtor ??
      ((globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket);
    if (ctor === undefined) {
      throw new BridgeError('WebSocket constructor not available; pass options.WebSocketCtor');
    }
    this.Ctor = ctor;
    this.cursor = options.startCursor ?? 0;
  }

  async connect(opts: BridgeConnectOptions): Promise<void> {
    if (this.connectStarted) throw new BridgeError('WsBridge.connect already called');
    this.connectStarted = true;
    this.onMessage = opts.onMessage;
    if (opts.onError) this.onError = opts.onError;
    await this.openOnce();
  }

  async disconnect(): Promise<void> {
    this.disposed = true;
    if (this.current) {
      try {
        this.current.close(1000, 'client disconnect');
      } catch {
        /* ignore */
      }
      this.current = null;
    }
    if (this.reconnectPromise) {
      try {
        await this.reconnectPromise;
      } catch {
        /* swallow */
      }
    }
  }

  getCursor(): number {
    return this.cursor;
  }

  private async openOnce(): Promise<void> {
    const qs = await signBridgeQuery({
      crypto: this.options.auth.crypto,
      signingPrivateKey: this.options.auth.signingPrivateKey,
      address: this.options.auth.address,
      kind: 'ws',
      since: this.cursor,
    });
    const url = `${toWsUrl(this.options.baseUrl)}/v1/bridge/ws?${qs.toString()}`;
    const ws = new this.Ctor(url);
    ws.binaryType = 'arraybuffer';
    const connectTimeoutMs = this.options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => {
        try {
          ws.close();
        } catch {
          /* ignore */
        }
        reject(new BridgeError(`WS connect timeout to ${url}`));
      }, connectTimeoutMs);
      ws.addEventListener('open', () => {
        clearTimeout(timer);
        resolve();
      });
      ws.addEventListener('error', () => {
        clearTimeout(timer);
        reject(new BridgeError(`WS connect error to ${url}`));
      });
    });
    this.current = ws;
    this.attachListeners(ws);
  }

  private attachListeners(ws: WebSocket): void {
    ws.addEventListener('message', (ev) => {
      const data = ev.data;
      let text: string;
      if (typeof data === 'string') {
        text = data;
      } else if (data instanceof ArrayBuffer) {
        text = new TextDecoder().decode(data);
      } else {
        this.onError(new BridgeError('WS frame neither text nor ArrayBuffer'));
        return;
      }
      let wire: BridgeWireMessage;
      try {
        wire = JSON.parse(text);
      } catch (err) {
        this.onError(new BridgeError(`malformed WS frame: ${(err as Error).message}`));
        return;
      }
      if (typeof wire.ciphertext !== 'string' || typeof wire.receivedAt !== 'number') {
        this.onError(new BridgeError('WS frame missing required fields'));
        return;
      }
      if (wire.receivedAt > this.cursor) this.cursor = wire.receivedAt;
      const msg = decodeWireMessage(wire);
      if (this.onMessage) {
        Promise.resolve(this.onMessage(msg)).catch((err) => this.onError(err as Error));
      }
    });
    const onCloseOrError = (): void => {
      if (this.disposed) return;
      this.current = null;
      if (this.options.disableAutoReconnect) return;
      this.reconnectPromise = this.reconnectLoop();
    };
    ws.addEventListener('close', onCloseOrError);
    ws.addEventListener('error', () => {
      // Treat error as a close — the platform fires 'close' too in most
      // implementations, but Node's `ws` library does not always.
      if (this.current === ws) {
        try {
          ws.close();
        } catch {
          /* ignore */
        }
      }
    });
  }

  private async reconnectLoop(): Promise<void> {
    let backoff = this.options.initialBackoffMs ?? DEFAULT_INITIAL_BACKOFF;
    const maxBackoff = this.options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF;
    while (!this.disposed) {
      await sleep(backoff);
      if (this.disposed) return;
      try {
        await this.openOnce();
        return;
      } catch (err) {
        this.onError(err as Error);
        backoff = Math.min(backoff * 2, maxBackoff);
      }
    }
  }
}

function toWsUrl(httpUrl: string): string {
  const trimmed = httpUrl.endsWith('/') ? httpUrl.slice(0, -1) : httpUrl;
  if (trimmed.startsWith('https://')) return 'wss://' + trimmed.slice('https://'.length);
  if (trimmed.startsWith('http://')) return 'ws://' + trimmed.slice('http://'.length);
  return trimmed; // already ws(s)://
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
