/**
 * Per-peer connection pool.
 *
 * The manager owns at most one {@link WebRtcConnection} per peer address.
 * It dispatches inbound signaling messages to the matching connection,
 * resolves glare deterministically (lexicographic address compare), and
 * exposes a single `getOrCreate(peerAddress)` entrypoint to the rest of
 * the package.
 */

import { WebRtcConnectError } from './errors.js';
import {
  generateSessionId,
  WebRtcConnection,
  type WebRtcReceiverHooks,
} from './connection.js';
import type { WebRtcSignalingChannel } from './signaling.js';
import type {
  IRtcFactory,
  ShadeRtcConfig,
  WebRtcSignalingMessage,
} from './types.js';

export interface WebRtcConnectionManagerOptions {
  factory: IRtcFactory;
  signaling: WebRtcSignalingChannel;
  config?: ShadeRtcConfig;
  /** Default 30s; passed through to each connection. */
  connectTimeoutMs?: number;
  /** Optional hooks invoked when a peer pushes chunks / queries resume state. */
  receiver?: WebRtcReceiverHooks;
  /** Default {@link DEFAULT_STUN_SERVERS}. */
  defaultStunServers?: ShadeRtcConfig['iceServers'];
}

export class WebRtcConnectionManager {
  private readonly byPeer = new Map<string, WebRtcConnection>();
  private readonly inflight = new Map<string, Promise<WebRtcConnection>>();
  private readonly unsubscribe: () => void;
  private destroyed = false;

  constructor(private readonly options: WebRtcConnectionManagerOptions) {
    this.unsubscribe = options.signaling.onSignal((from, msg) =>
      this.handleSignaling(from, msg),
    );
  }

  /** This endpoint's own address — used for glare tiebreak. */
  get myAddress(): string {
    return this.options.signaling.myAddress;
  }

  /**
   * Resolve the connection for `peerAddress`. Returns the existing one if
   * it's open or in-flight, otherwise initiates a fresh caller-side
   * negotiation. The returned promise resolves once the data channel is
   * `open` and rejects on connect failure / timeout.
   *
   * Glare semantics: if our caller-role connection got yielded mid-flight
   * (because the peer's address is lexically smaller than ours), the
   * `byPeer` slot is replaced with a callee-role connection. We follow
   * the swap automatically — the user sees a single resolved promise.
   */
  async getOrCreate(peerAddress: string, attempt = 0): Promise<WebRtcConnection> {
    if (this.destroyed) throw new WebRtcConnectError('manager destroyed');
    if (attempt > 4) {
      throw new WebRtcConnectError(
        `getOrCreate(${peerAddress}) gave up after ${attempt} retries`,
      );
    }

    const existing = this.byPeer.get(peerAddress);
    if (existing !== undefined && (existing.state === 'connecting' || existing.state === 'connected')) {
      try {
        await existing.waitForOpen();
        return existing;
      } catch (err) {
        // The conn closed mid-wait. If a fresh slot landed in `byPeer`
        // (glare swap), retry; otherwise propagate.
        const replacement = this.byPeer.get(peerAddress);
        if (replacement !== undefined && replacement !== existing) {
          return this.getOrCreate(peerAddress, attempt + 1);
        }
        throw err;
      }
    }

    const inflight = this.inflight.get(peerAddress);
    if (inflight !== undefined) {
      try {
        return await inflight;
      } catch {
        // The in-flight initiate failed; fall through to a retry which
        // will pick up either an empty slot or a callee that took ours.
        return this.getOrCreate(peerAddress, attempt + 1);
      }
    }

    const promise = this.initiate(peerAddress);
    this.inflight.set(peerAddress, promise);
    try {
      return await promise;
    } catch (err) {
      const replacement = this.byPeer.get(peerAddress);
      if (
        replacement !== undefined &&
        (replacement.state === 'connecting' || replacement.state === 'connected')
      ) {
        return this.getOrCreate(peerAddress, attempt + 1);
      }
      throw err;
    } finally {
      this.inflight.delete(peerAddress);
    }
  }

  /** Force a fresh connection (closes any existing). Used by tests + diagnostics. */
  async reconnect(peerAddress: string): Promise<WebRtcConnection> {
    await this.closePeer(peerAddress, 'reconnect');
    return this.getOrCreate(peerAddress);
  }

  /** True when an open connection exists. */
  isConnected(peerAddress: string): boolean {
    const conn = this.byPeer.get(peerAddress);
    return conn !== undefined && conn.state === 'connected';
  }

  /** Tear down the connection (if any) for a single peer. */
  async closePeer(peerAddress: string, reason?: string): Promise<void> {
    const conn = this.byPeer.get(peerAddress);
    if (conn !== undefined) await conn.close(reason ?? 'manager-closePeer');
  }

  /** Tear down everything. After this the manager rejects further work. */
  destroy(): void {
    this.destroyed = true;
    this.unsubscribe();
    for (const conn of [...this.byPeer.values()]) {
      void conn.close('manager-destroy');
    }
    this.byPeer.clear();
    this.inflight.clear();
  }

  // ─── Internals ───────────────────────────────────────────

  private async initiate(peerAddress: string): Promise<WebRtcConnection> {
    const sessionId = generateSessionId();
    const conn = new WebRtcConnection({
      deps: {
        factory: this.options.factory,
        config: this.resolveConfig(),
        signaling: this.options.signaling,
        ...(this.options.connectTimeoutMs !== undefined
          ? { connectTimeoutMs: this.options.connectTimeoutMs }
          : {}),
        ...(this.options.receiver !== undefined ? { receiver: this.options.receiver } : {}),
      },
      peerAddress,
      sessionId,
      role: 'caller',
    });
    this.installPeerListeners(peerAddress, conn);
    this.byPeer.set(peerAddress, conn);

    try {
      await conn.start();
      await conn.waitForOpen();
      return conn;
    } catch (err) {
      await conn.close('initiate-failed').catch(() => {});
      throw err;
    }
  }

  private installPeerListeners(peerAddress: string, conn: WebRtcConnection): void {
    conn.onClose.add(() => {
      const current = this.byPeer.get(peerAddress);
      if (current === conn) this.byPeer.delete(peerAddress);
    });
  }

  private resolveConfig(): ShadeRtcConfig {
    const supplied = this.options.config ?? {};
    if (supplied.iceServers !== undefined) return supplied;
    if (this.options.defaultStunServers !== undefined) {
      return { ...supplied, iceServers: this.options.defaultStunServers };
    }
    // Fall through to caller-side default in connection (= public STUN).
    return supplied;
  }

  private async handleSignaling(
    from: string,
    msg: WebRtcSignalingMessage,
  ): Promise<void> {
    if (this.destroyed) return;
    switch (msg.kind) {
      case 'shade.webrtc-offer/v1':
        await this.handleOffer(from, msg.sessionId, msg.sdp);
        return;
      case 'shade.webrtc-answer/v1':
        await this.handleAnswer(from, msg.sessionId, msg.sdp);
        return;
      case 'shade.webrtc-ice/v1':
        await this.handleIce(from, msg.sessionId, msg.candidate);
        return;
      case 'shade.webrtc-bye/v1':
        await this.handleBye(from, msg.sessionId, msg.reason);
        return;
    }
  }

  private async handleOffer(from: string, sessionId: string, sdp: string): Promise<void> {
    let existing = this.byPeer.get(from);

    if (existing !== undefined) {
      if (existing.state === 'connected') {
        // Already connected — this is a reconnect attempt by the peer.
        // Tear down ours so the new offer takes the slot.
        await existing.close('peer-reconnect');
        existing = undefined;
      } else if (existing.state === 'connecting') {
        // Glare. Tiebreak on address comparison: the smaller address keeps
        // its caller-role, the larger address yields and accepts the peer's
        // offer.
        const yieldToPeer = this.myAddress > from;
        if (yieldToPeer) {
          await existing.close('glare-yield');
          existing = undefined;
        } else {
          // Ignore the peer's offer; our outbound will win once their side
          // accepts our offer.
          return;
        }
      }
    }

    const conn = new WebRtcConnection({
      deps: {
        factory: this.options.factory,
        config: this.resolveConfig(),
        signaling: this.options.signaling,
        ...(this.options.connectTimeoutMs !== undefined
          ? { connectTimeoutMs: this.options.connectTimeoutMs }
          : {}),
        ...(this.options.receiver !== undefined ? { receiver: this.options.receiver } : {}),
      },
      peerAddress: from,
      sessionId,
      role: 'callee',
    });
    this.installPeerListeners(from, conn);
    this.byPeer.set(from, conn);
    try {
      await conn.acceptOffer({ type: 'offer', sdp });
    } catch (err) {
      console.warn('[WebRtcConnectionManager] acceptOffer failed:', err);
      await conn.close('acceptOffer-failed').catch(() => {});
    }
  }

  private async handleAnswer(from: string, sessionId: string, sdp: string): Promise<void> {
    const conn = this.byPeer.get(from);
    if (conn === undefined) return;
    if (conn.role !== 'caller' || conn.sessionId !== sessionId) return;
    try {
      await conn.acceptAnswer({ type: 'answer', sdp });
    } catch (err) {
      console.warn('[WebRtcConnectionManager] acceptAnswer failed:', err);
      await conn.close('acceptAnswer-failed').catch(() => {});
    }
  }

  private async handleIce(
    from: string,
    sessionId: string,
    candidate: import('./types.js').ShadeIceCandidate | null,
  ): Promise<void> {
    const conn = this.byPeer.get(from);
    if (conn === undefined) return;
    if (conn.sessionId !== sessionId) return;
    try {
      await conn.addRemoteCandidate(candidate);
    } catch (err) {
      console.warn('[WebRtcConnectionManager] addRemoteCandidate failed:', err);
    }
  }

  private async handleBye(from: string, sessionId: string, reason?: string): Promise<void> {
    const conn = this.byPeer.get(from);
    if (conn === undefined || conn.sessionId !== sessionId) return;
    await conn.close(`peer-bye${reason !== undefined ? `: ${reason}` : ''}`);
  }
}
