/**
 * One peer connection between two Shade endpoints.
 *
 * Wraps an `IPeerConnection` plus the single bidirectional Shade transfer
 * `IDataChannel`. Drives offer/answer/ICE through a {@link WebRtcSignalingChannel}
 * and exposes lifecycle hooks (`open`, `close`, `failure`).
 *
 * The class is symmetric in API but asymmetric in state machine: the
 * caller-side instance generates `sessionId` + creates the offer; the
 * callee-side instance is created when an inbound `offer` arrives. Glare
 * is resolved at the manager layer — see `manager.ts`.
 */

import {
  WebRtcConnectError,
  WebRtcDataChannelError,
  WebRtcTimeoutError,
} from './errors.js';
import {
  decodeFrame,
  encodeChunkAckFrame,
  encodeErrorFrame,
  encodePongFrame,
  encodeResumeStateFrame,
  randomRequestId,
  WIRE_CHUNK,
  WIRE_CHUNK_ACK,
  WIRE_ERROR,
  WIRE_PING,
  WIRE_PONG,
  WIRE_RESUME_QUERY,
  WIRE_RESUME_STATE,
  type AnyFrame,
  type ChunkFrame,
  type ResumeQueryFrame,
} from './wire.js';
import type { WebRtcSignalingChannel } from './signaling.js';
import type {
  IDataChannel,
  IPeerConnection,
  IRtcFactory,
  ShadeIceCandidate,
  ShadeRtcConfig,
  ShadeRtcConnectionState,
  ShadeSessionDescription,
  WebRtcAnswerMessage,
  WebRtcByeMessage,
  WebRtcIceMessage,
  WebRtcOfferMessage,
} from './types.js';

/** Label used for the single Shade data channel. */
export const SHADE_DATACHANNEL_LABEL = 'shade-transfer/v1';

export interface WebRtcConnectionDeps {
  factory: IRtcFactory;
  config: ShadeRtcConfig;
  signaling: WebRtcSignalingChannel;
  /** Default 30s — applies to the entire connection-establishment handshake. */
  connectTimeoutMs?: number;
  /**
   * Optional receiver-side hooks. When provided, the connection registers
   * a chunk + resume-state handler on the DataChannel and replies with
   * the appropriate ack frames.
   */
  receiver?: WebRtcReceiverHooks;
}

export interface WebRtcReceiverHooks {
  /** Called when a peer pushes a chunk over the DataChannel. */
  onChunk(
    fromPeer: string,
    streamId: string,
    laneId: number,
    seq: bigint,
    envelope: Uint8Array,
  ): Promise<{ lastSeq: number; bytesReceived?: number }>;
  /** Called when a peer queries resume state. Return `null` for unknown. */
  onResumeQuery(
    fromPeer: string,
    streamId: string,
  ): Promise<{ streamId: string; lanes: Array<{ laneId: number; lastSeqAcked: number }> } | null>;
}

type Role = 'caller' | 'callee';

interface PendingRequest {
  resolve(frame: AnyFrame): void;
  reject(err: unknown): void;
  cleanupTimer: () => void;
}

export class WebRtcConnection {
  /** State exposed to the manager layer. */
  state: ShadeRtcConnectionState = 'new';
  /** Peer this connection talks to. */
  readonly peerAddress: string;
  /** Caller-generated sessionId; both peers tag every signaling message with this. */
  readonly sessionId: string;
  /** 'caller' produced the SDP offer; 'callee' answered. */
  readonly role: Role;

  private pc!: IPeerConnection;
  private dc: IDataChannel | null = null;
  private readonly deps: WebRtcConnectionDeps;
  private opened = false;
  private closed = false;

  private readonly openWaiters: Array<{
    resolve(): void;
    reject(err: unknown): void;
  }> = [];

  private readonly closeWaiters: Array<() => void> = [];

  /** requestId → pending response. */
  private readonly pending = new Map<string, PendingRequest>();
  /** Trickled ICE candidates that arrived before setRemoteDescription. */
  private readonly pendingRemoteCandidates: Array<ShadeIceCandidate | null> = [];
  /** True after we've called setRemoteDescription so further ICE applies directly. */
  private remoteDescriptionSet = false;

  /** External listeners (manager wires these). */
  readonly onClose = new Set<() => void>();
  readonly onFailure = new Set<(err: unknown) => void>();

  /**
   * Construct WITHOUT starting the handshake. Call `start()` for caller
   * role, or `acceptOffer(offer)` for callee role.
   */
  constructor(args: {
    deps: WebRtcConnectionDeps;
    peerAddress: string;
    sessionId: string;
    role: Role;
  }) {
    this.deps = args.deps;
    this.peerAddress = args.peerAddress;
    this.sessionId = args.sessionId;
    this.role = args.role;
  }

  /** Caller side: create offer and ship it to the peer. */
  async start(): Promise<void> {
    if (this.role !== 'caller') {
      throw new WebRtcConnectError(`start() invalid for role=${this.role}`);
    }
    this.pc = this.deps.factory.createPeerConnection(this.deps.config);
    this.installPeerConnectionListeners();

    // The caller creates the DataChannel BEFORE createOffer so the SDP
    // negotiates an `m=application` section.
    this.dc = this.pc.createDataChannel(SHADE_DATACHANNEL_LABEL, { ordered: true });
    this.dc.binaryType = 'arraybuffer';
    this.installDataChannelListeners(this.dc);

    this.state = 'connecting';
    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);
    await this.deps.signaling.sendOffer(this.peerAddress, this.sessionId, offer.sdp);

    this.armConnectTimeout();
  }

  /** Callee side: accept inbound offer, create answer, ship it back. */
  async acceptOffer(offer: ShadeSessionDescription): Promise<void> {
    if (this.role !== 'callee') {
      throw new WebRtcConnectError(`acceptOffer() invalid for role=${this.role}`);
    }
    this.pc = this.deps.factory.createPeerConnection(this.deps.config);
    this.installPeerConnectionListeners();

    // Callee receives the DataChannel via 'datachannel' event.
    this.pc.addEventListener('datachannel', (ev) => {
      if (this.dc !== null) return;
      this.dc = ev.channel;
      this.dc.binaryType = 'arraybuffer';
      this.installDataChannelListeners(this.dc);
    });

    this.state = 'connecting';
    await this.pc.setRemoteDescription(offer);
    this.remoteDescriptionSet = true;
    await this.flushPendingRemoteCandidates();

    const answer = await this.pc.createAnswer();
    await this.pc.setLocalDescription(answer);
    await this.deps.signaling.sendAnswer(this.peerAddress, this.sessionId, answer.sdp);

    this.armConnectTimeout();
  }

  /** Caller side: peer's answer arrived. */
  async acceptAnswer(answer: ShadeSessionDescription): Promise<void> {
    if (this.role !== 'caller') {
      throw new WebRtcConnectError(`acceptAnswer() invalid for role=${this.role}`);
    }
    if (this.remoteDescriptionSet) {
      // Spurious duplicate; ignore.
      return;
    }
    await this.pc.setRemoteDescription(answer);
    this.remoteDescriptionSet = true;
    await this.flushPendingRemoteCandidates();
  }

  /** Trickle-ICE: peer reported a candidate. `null` means end-of-candidates. */
  async addRemoteCandidate(candidate: ShadeIceCandidate | null): Promise<void> {
    if (!this.remoteDescriptionSet) {
      this.pendingRemoteCandidates.push(candidate);
      return;
    }
    try {
      await this.pc.addIceCandidate(candidate);
    } catch (err) {
      // Failed candidates are non-fatal; ICE chooses among many.
      console.warn('[WebRtcConnection] addIceCandidate failed:', err);
    }
  }

  /** Wait until the data channel is open. Rejects on failure or timeout. */
  async waitForOpen(): Promise<void> {
    if (this.opened) return;
    if (this.closed) throw new WebRtcConnectError('connection already closed');
    return new Promise<void>((resolve, reject) => {
      this.openWaiters.push({ resolve, reject });
    });
  }

  /** Send a pre-encoded frame and wait for the reply matched by requestId. */
  async request(frame: Uint8Array, requestId: Uint8Array, timeoutMs = 30_000): Promise<AnyFrame> {
    await this.waitForOpen();
    if (this.dc === null) throw new WebRtcDataChannelError('data channel missing');
    if (this.dc.readyState !== 'open') {
      throw new WebRtcDataChannelError(`data channel not open (${this.dc.readyState})`);
    }
    return new Promise<AnyFrame>((resolve, reject) => {
      const key = bytesToHex(requestId);
      const timer = setTimeout(() => {
        this.pending.delete(key);
        reject(new WebRtcTimeoutError(`request ${key} timed out after ${timeoutMs}ms`));
      }, timeoutMs);
      this.pending.set(key, {
        resolve: (f) => {
          clearTimeout(timer);
          resolve(f);
        },
        reject: (err) => {
          clearTimeout(timer);
          reject(err);
        },
        cleanupTimer: () => clearTimeout(timer),
      });
      try {
        this.dc!.send(frame);
      } catch (err) {
        this.pending.delete(key);
        clearTimeout(timer);
        reject(new WebRtcDataChannelError(`send failed: ${(err as Error).message}`));
      }
    });
  }

  /** Send a frame without expecting a reply. Used for receiver-side ack frames. */
  sendRaw(frame: Uint8Array): void {
    if (this.dc === null || this.dc.readyState !== 'open') {
      throw new WebRtcDataChannelError(
        `data channel not open (state=${this.dc?.readyState ?? 'missing'})`,
      );
    }
    this.dc.send(frame);
  }

  /** Tear down. Idempotent. */
  async close(reason?: string): Promise<void> {
    if (this.closed) return;
    this.closed = true;
    if (!this.opened) {
      const err = new WebRtcConnectError(reason ?? 'connection closed before open');
      for (const w of this.openWaiters.splice(0)) w.reject(err);
    } else {
      this.openWaiters.splice(0);
    }
    for (const p of this.pending.values()) {
      p.cleanupTimer();
      p.reject(new WebRtcConnectError(reason ?? 'connection closed'));
    }
    this.pending.clear();
    try {
      await this.deps.signaling.sendBye(this.peerAddress, this.sessionId, reason);
    } catch {
      /* swallow — we're tearing down anyway */
    }
    try {
      this.dc?.close();
    } catch {
      /* swallow */
    }
    try {
      this.pc?.close();
    } catch {
      /* swallow */
    }
    this.state = 'closed';
    for (const w of this.closeWaiters.splice(0)) w();
    for (const cb of this.onClose) {
      try {
        cb();
      } catch (err) {
        console.warn('[WebRtcConnection] onClose handler threw:', err);
      }
    }
  }

  /** Promise that resolves when the connection has fully closed. */
  closed_promise(): Promise<void> {
    if (this.closed) return Promise.resolve();
    return new Promise<void>((resolve) => this.closeWaiters.push(resolve));
  }

  // ─── Private ───────────────────────────────────────────

  private installPeerConnectionListeners(): void {
    this.pc.addEventListener('icecandidate', async (ev) => {
      try {
        await this.deps.signaling.sendIce(this.peerAddress, this.sessionId, ev.candidate);
      } catch (err) {
        console.warn('[WebRtcConnection] ICE send failed:', err);
      }
    });
    this.pc.addEventListener('connectionstatechange', () => {
      const cs = String(this.pc.connectionState);
      if (cs === 'failed') {
        const err = new WebRtcConnectError('connectionState=failed');
        for (const w of this.openWaiters.splice(0)) w.reject(err);
        for (const cb of this.onFailure) cb(err);
        void this.close('connectionState=failed');
      } else if (cs === 'closed') {
        if (!this.closed) void this.close('peer closed');
      } else if (cs === 'disconnected') {
        this.state = 'disconnected';
      }
    });
    this.pc.addEventListener('iceconnectionstatechange', () => {
      // Some browsers (Firefox/Safari) drive 'connectionstatechange' through
      // 'iceconnectionstatechange'. Mirror failed → tear down.
      const ics = this.pc.iceConnectionState;
      if (ics === 'failed') {
        const err = new WebRtcConnectError('iceConnectionState=failed');
        for (const w of this.openWaiters.splice(0)) w.reject(err);
        for (const cb of this.onFailure) cb(err);
        void this.close('iceConnectionState=failed');
      }
    });
  }

  private installDataChannelListeners(dc: IDataChannel): void {
    dc.addEventListener('open', () => {
      this.opened = true;
      this.state = 'connected';
      const waiters = this.openWaiters.splice(0);
      for (const w of waiters) w.resolve();
    });
    dc.addEventListener('close', () => {
      if (!this.closed) void this.close('data channel closed');
    });
    dc.addEventListener('error', (ev) => {
      const err = new WebRtcDataChannelError(`data channel error: ${stringifyEvent(ev)}`);
      for (const w of this.openWaiters.splice(0)) w.reject(err);
      for (const cb of this.onFailure) cb(err);
      void this.close('data channel error');
    });
    dc.addEventListener('message', (ev) => {
      void this.handleMessage(ev.data);
    });
  }

  private async handleMessage(data: ArrayBuffer): Promise<void> {
    let frame: AnyFrame;
    try {
      frame = decodeFrame(data);
    } catch (err) {
      console.warn('[WebRtcConnection] frame decode failed:', err);
      return;
    }
    const key = bytesToHex(frame.requestId);

    // Server-side response frames: complete a pending caller request.
    if (
      frame.type === WIRE_CHUNK_ACK ||
      frame.type === WIRE_RESUME_STATE ||
      frame.type === WIRE_PONG ||
      frame.type === WIRE_ERROR
    ) {
      const pending = this.pending.get(key);
      if (pending !== undefined) {
        this.pending.delete(key);
        pending.resolve(frame);
        return;
      }
      // Unmatched response — log and drop.
      console.warn(`[WebRtcConnection] no pending request for response ${key}`);
      return;
    }

    // Client-side request frames: dispatch to receiver hooks (if any).
    if (this.deps.receiver === undefined) {
      this.sendRawSafe(
        encodeErrorFrame({
          type: WIRE_ERROR,
          requestId: frame.requestId,
          json: JSON.stringify({ error: 'no receiver registered on this peer' }),
        }),
      );
      return;
    }

    if (frame.type === WIRE_PING) {
      this.sendRawSafe(
        encodePongFrame({ type: WIRE_PONG, requestId: frame.requestId, nonce: frame.nonce }),
      );
      return;
    }

    if (frame.type === WIRE_CHUNK) {
      await this.handleChunkRequest(frame);
      return;
    }
    if (frame.type === WIRE_RESUME_QUERY) {
      await this.handleResumeQuery(frame);
      return;
    }
  }

  private async handleChunkRequest(frame: ChunkFrame): Promise<void> {
    const streamIdString = streamIdBytesToBase64Url(frame.streamId);
    try {
      const ack = await this.deps.receiver!.onChunk(
        this.peerAddress,
        streamIdString,
        frame.laneId,
        frame.seq,
        frame.envelope,
      );
      this.sendRawSafe(
        encodeChunkAckFrame({
          type: WIRE_CHUNK_ACK,
          requestId: frame.requestId,
          lastSeq: ack.lastSeq,
          bytesReceived: ack.bytesReceived ?? 0,
        }),
      );
    } catch (err) {
      this.sendRawSafe(
        encodeErrorFrame({
          type: WIRE_ERROR,
          requestId: frame.requestId,
          json: JSON.stringify({ error: (err as Error).message ?? String(err) }),
        }),
      );
    }
  }

  private async handleResumeQuery(frame: ResumeQueryFrame): Promise<void> {
    const streamIdString = streamIdBytesToBase64Url(frame.streamId);
    try {
      const state = await this.deps.receiver!.onResumeQuery(this.peerAddress, streamIdString);
      if (state === null) {
        this.sendRawSafe(
          encodeErrorFrame({
            type: WIRE_ERROR,
            requestId: frame.requestId,
            json: JSON.stringify({ error: 'not found' }),
          }),
        );
        return;
      }
      this.sendRawSafe(
        encodeResumeStateFrame({
          type: WIRE_RESUME_STATE,
          requestId: frame.requestId,
          json: JSON.stringify(state),
        }),
      );
    } catch (err) {
      this.sendRawSafe(
        encodeErrorFrame({
          type: WIRE_ERROR,
          requestId: frame.requestId,
          json: JSON.stringify({ error: (err as Error).message ?? String(err) }),
        }),
      );
    }
  }

  private sendRawSafe(frame: Uint8Array): void {
    try {
      this.sendRaw(frame);
    } catch (err) {
      console.warn('[WebRtcConnection] sendRaw failed during response:', err);
    }
  }

  private async flushPendingRemoteCandidates(): Promise<void> {
    const queued = this.pendingRemoteCandidates.splice(0);
    for (const c of queued) {
      try {
        await this.pc.addIceCandidate(c);
      } catch (err) {
        console.warn('[WebRtcConnection] queued ICE addIceCandidate failed:', err);
      }
    }
  }

  private armConnectTimeout(): void {
    const timeout = this.deps.connectTimeoutMs ?? 30_000;
    setTimeout(() => {
      if (this.opened || this.closed) return;
      const err = new WebRtcTimeoutError(`connection did not open within ${timeout}ms`);
      for (const w of this.openWaiters.splice(0)) w.reject(err);
      for (const cb of this.onFailure) cb(err);
      void this.close('connect timeout');
    }, timeout);
  }
}

// Top-level helpers — exported as they're used by manager + transport.

export function generateSessionId(): string {
  const bytes = randomRequestId(); // re-use 16-byte randomness
  return streamIdBytesToBase64Url(bytes);
}

function bytesToHex(b: Uint8Array): string {
  let s = '';
  for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
  return s;
}

function streamIdBytesToBase64Url(bytes: Uint8Array): string {
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

function stringifyEvent(ev: { error?: unknown }): string {
  if (ev?.error !== undefined) {
    if (ev.error instanceof Error) return ev.error.message;
    return String(ev.error);
  }
  return 'unknown';
}

// Forward type-only re-exports so consumers don't have to import twice.
export type {
  WebRtcOfferMessage,
  WebRtcAnswerMessage,
  WebRtcIceMessage,
  WebRtcByeMessage,
};
