"use client";

/**
 * Call media layer.
 *
 * Phase 6 put LiveKit (an SFU) behind `createCallSession`. The mesh in this
 * file is the fallback when LiveKit is not configured, so a local without
 * docker-compose's livekit service still rings. Everything above this module
 * deals in participant ids and MediaStreams and knows nothing about SDP.
 */

import { buildIceServers } from "./public-env";

export type SignalKind = "offer" | "answer" | "ice" | "ready";

export interface OutboundSignal {
  toUserId: number;
  signal: SignalKind;
  data: unknown;
}

export interface InboundSignal {
  fromUserId: number;
  signal: SignalKind;
  data: unknown;
}

export type PeerConnectionState = "connecting" | "connected" | "failed" | "closed";

export interface PeerState {
  userId: number;
  stream: MediaStream | null;
  connection: PeerConnectionState;
  /** 0–1 short-term audio level, for the speaking indicator. */
  level: number;
  /** True if we gathered at least one TURN relay candidate. */
  sawRelay: boolean;
}

export interface CallQuality {
  label: "excellent" | "good" | "poor" | "lost";
  packetLoss: number | null;
  rttMs: number | null;
}

export interface CallDataEvent {
  fromUserId: number;
  type: "reaction" | "chat";
  emoji?: string;
  text?: string;
}

export interface CallSessionOptions {
  selfUserId: number;
  /** Whether to capture a camera at all. An audio call never acquires one, so
   * there is no video track to enable later — escalating audio→video means
   * starting a video call. LiveKit can add a camera later; mesh cannot. */
  video: boolean;
  sendSignal?: (signal: OutboundSignal) => void;
  livekit?: { url: string; token: string };
  /** Called on every change to any peer, plus the local mic level. */
  onState: (state: { peers: PeerState[]; localLevel: number; quality: CallQuality }) => void;
  onLocalStream: (stream: MediaStream | null) => void;
  onError: (error: Error) => void;
  onData?: (event: CallDataEvent) => void;
}

export interface CallSession {
  transport: "livekit" | "mesh";
  /** Sets the peers this session should be connected to — the joined
   * participants minus yourself. Connections to anyone dropped are torn down.
   * No-op on LiveKit (the SFU subscribes to the room). */
  setPeers(userIds: number[]): void;
  handleSignal(signal: InboundSignal): void;
  /** Announce that our signalling socket + media are ready so a peer that
   * offered into the void can renegotiate. */
  announceReady(userIds: number[]): void;
  setMicEnabled(enabled: boolean): void;
  setCameraEnabled(enabled: boolean): void;
  /** Swaps the outgoing video track for a screen capture. Resolves false when
   * the user cancels the picker, which is a normal outcome and not an error. */
  startScreenShare(): Promise<boolean>;
  stopScreenShare(): Promise<void>;
  sendReaction(emoji: string): void;
  sendChat(text: string): void;
  switchAudioDevice(deviceId: string): Promise<void>;
  switchVideoDevice(deviceId: string): Promise<void>;
  /** Resumes the AudioContext behind the speaking indicator. An AudioContext
   * constructed without a user gesture starts suspended, which leaves every
   * level reading at zero — call this from a click once one is available. Has no
   * bearing on whether the call is audible: playback is done by media elements,
   * not by WebAudio. */
  resumeAudio(): void;
  close(): void;
}

interface Peer {
  userId: number;
  pc: RTCPeerConnection;
  polite: boolean;
  makingOffer: boolean;
  ignoreOffer: boolean;
  stream: MediaStream | null;
  connection: PeerConnectionState;
  level: number;
  /** Local ICE produced a typ relay candidate (TURN allocate succeeded). */
  sawRelay: boolean;
  analyser?: AnalyserNode;
  /**
   * Candidates that arrived before the description they belong to.
   *
   * Trickle ICE means candidates are sent the instant they are gathered, and
   * the relay does not guarantee they arrive after the offer — the gateway
   * awaits a database check per signal, so a slow offer can be overtaken by the
   * candidates that follow it. addIceCandidate before setRemoteDescription
   * throws, and a dropped candidate is never resent. The ones gathered first are
   * the host candidates, which are exactly the ones that connect two peers on
   * the same machine or LAN, so losing them can leave a call that negotiates
   * successfully and still never carries media.
   */
  pendingCandidates: RTCIceCandidateInit[];
  /** Serialises signal handling per peer, so an offer cannot interleave with
   * the answer or candidates that follow it. */
  queue: Promise<void>;
  /** Timer that re-offers when a local offer never receives an answer — the
   * usual case when we offered before the other side's CallOverlay existed. */
  offerWatchdog: number | null;
}

const OFFER_WATCHDOG_MS = 4_000;

/** Requests the microphone (and camera, for a video call), falling back to
 * audio-only if the camera is refused or missing — a video call with no webcam
 * should still be a call, not an error. */
async function acquireLocalStream(video: boolean): Promise<MediaStream> {
  if (!video) return navigator.mediaDevices.getUserMedia({ audio: true });
  try {
    return await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 1280, height: 720 } });
  } catch {
    return navigator.mediaDevices.getUserMedia({ audio: true });
  }
}

/** addIceCandidate throws for stale/glare candidates — that is normal during
 * renegotiation and must not abort the call UI. */
async function safeAddIceCandidate(pc: RTCPeerConnection, candidate: RTCIceCandidateInit | null | undefined) {
  try {
    if (candidate == null) {
      await pc.addIceCandidate(null);
      return;
    }
    // Some peers send an empty end-of-candidates object rather than null.
    if (!candidate.candidate && candidate.sdpMid == null && candidate.sdpMLineIndex == null) {
      await pc.addIceCandidate(null);
      return;
    }
    await pc.addIceCandidate(candidate);
  } catch {
    // Expected after rollback, ignored offers, or ICE restart.
  }
}

export async function createCallSession(options: CallSessionOptions): Promise<CallSession> {
  if (options.livekit) {
    const { createLiveKitCallSession } = await import("./livekit-session");
    return createLiveKitCallSession(options);
  }
  return createMeshCallSession(options);
}

async function createMeshCallSession(options: CallSessionOptions): Promise<CallSession> {
  if (!options.sendSignal) throw new Error("mesh calls need a signalling channel");
  const { selfUserId, video, onState, onLocalStream, onError, onData } = options;
  const sendSignal = options.sendSignal;

  const local = await acquireLocalStream(video);
  onLocalStream(local);

  const peers = new Map<number, Peer>();
  let cameraTrack: MediaStreamTrack | null = local.getVideoTracks()[0] ?? null;
  let screenStream: MediaStream | null = null;
  let closed = false;

  // One AudioContext for the whole call. Levels drive the speaking ring, which
  // is the only cue that tells you audio is actually flowing — worth having,
  // and cheap at one analyser per stream polled five times a second.
  const audioContext =
    typeof AudioContext !== "undefined" ? new AudioContext() : null;
  let localAnalyser: AnalyserNode | undefined;
  let localLevel = 0;
  if (audioContext) {
    try {
      localAnalyser = audioContext.createAnalyser();
      localAnalyser.fftSize = 512;
      audioContext.createMediaStreamSource(local).connect(localAnalyser);
    } catch {
      localAnalyser = undefined;
    }
  }

  function measure(analyser: AnalyserNode | undefined): number {
    if (!analyser) return 0;
    const buffer = new Uint8Array(analyser.frequencyBinCount);
    analyser.getByteTimeDomainData(buffer);
    let peak = 0;
    for (const sample of buffer) peak = Math.max(peak, Math.abs(sample - 128));
    return Math.min(1, peak / 40);
  }

  let lastQuality: CallQuality = { label: "good", packetLoss: null, rttMs: null };

  function emit() {
    if (closed) return;
    const failed = [...peers.values()].some((peer) => peer.connection === "failed");
    const connected = [...peers.values()].every((peer) => peer.connection === "connected");
    const lossy = (lastQuality.packetLoss ?? 0) >= 0.05;
    lastQuality = {
      ...lastQuality,
      label: failed || lossy ? "poor" : connected || peers.size === 0 ? "good" : "excellent",
    };
    onState({
      peers: [...peers.values()]
        .map((peer) => ({
          userId: peer.userId,
          stream: peer.stream,
          connection: peer.connection,
          level: peer.level,
          sawRelay: peer.sawRelay,
        }))
        .sort((a, b) => a.userId - b.userId),
      localLevel,
      quality: lastQuality,
    });
  }

  const levelTimer = window.setInterval(() => {
    if (closed) return;
    const nextLocal = measure(localAnalyser);
    let changed = Math.abs(nextLocal - localLevel) > 0.05;
    localLevel = nextLocal;
    for (const peer of peers.values()) {
      const next = measure(peer.analyser);
      if (Math.abs(next - peer.level) > 0.05) changed = true;
      peer.level = next;
    }
    if (changed) emit();
  }, 200);

  const statsTimer = window.setInterval(() => {
    if (closed) return;
    void (async () => {
      let fractionLost: number | null = null;
      let rttMs: number | null = null;
      for (const peer of peers.values()) {
        if (peer.connection !== "connected") continue;
        try {
          const report = await peer.pc.getStats();
          report.forEach((entry) => {
            const row = entry as { type: string; fractionLost?: number; state?: string; currentRoundTripTime?: number };
            if (row.type === "remote-inbound-rtp" && typeof row.fractionLost === "number") {
              fractionLost = Math.max(fractionLost ?? 0, row.fractionLost);
            }
            if (row.type === "candidate-pair" && row.state === "succeeded" && typeof row.currentRoundTripTime === "number") {
              rttMs = Math.max(rttMs ?? 0, row.currentRoundTripTime * 1000);
            }
          });
        } catch {
          // PeerConnection already closed.
        }
      }
      lastQuality = { ...lastQuality, packetLoss: fractionLost, rttMs };
      emit();
    })();
  }, 2000);

  function attachRemoteAnalyser(peer: Peer, stream: MediaStream) {
    if (!audioContext || peer.analyser) return;
    try {
      const analyser = audioContext.createAnalyser();
      analyser.fftSize = 512;
      audioContext.createMediaStreamSource(stream).connect(analyser);
      peer.analyser = analyser;
    } catch {
      // A stream with no audio track yet — the next ontrack will retry.
    }
  }

  function clearOfferWatchdog(peer: Peer) {
    if (peer.offerWatchdog != null) {
      window.clearTimeout(peer.offerWatchdog);
      peer.offerWatchdog = null;
    }
  }

  function armOfferWatchdog(peer: Peer) {
    clearOfferWatchdog(peer);
    peer.offerWatchdog = window.setTimeout(() => {
      peer.offerWatchdog = null;
      if (closed || peers.get(peer.userId) !== peer) return;
      // Still waiting on an answer for our unanswered local offer — renegotiate.
      if (peer.pc.signalingState === "have-local-offer" && !peer.pc.currentRemoteDescription) {
        peer.pendingCandidates = [];
        try {
          peer.pc.restartIce();
        } catch {
          // restartIce can throw if the PC is already closing.
        }
      }
    }, OFFER_WATCHDOG_MS);
  }

  async function makeOffer(peer: Peer) {
    const { pc, userId } = peer;
    try {
      peer.makingOffer = true;
      await pc.setLocalDescription();
      sendSignal({ toUserId: userId, signal: "offer", data: pc.localDescription });
      armOfferWatchdog(peer);
    } catch (err) {
      onError(err instanceof Error ? err : new Error(String(err)));
    } finally {
      peer.makingOffer = false;
    }
  }

  function connect(userId: number): Peer {
    const existing = peers.get(userId);
    if (existing) return existing;

    const pc = new RTCPeerConnection({ iceServers: buildIceServers() });
    const peer: Peer = {
      userId,
      pc,
      // The higher id yields. Both ends compute the same answer from ids alone,
      // so no round-trip is needed to agree who is polite.
      polite: selfUserId > userId,
      makingOffer: false,
      ignoreOffer: false,
      stream: null,
      connection: "connecting",
      level: 0,
      sawRelay: false,
      pendingCandidates: [],
      queue: Promise.resolve(),
      offerWatchdog: null,
    };
    peers.set(userId, peer);

    for (const track of local.getTracks()) pc.addTrack(track, local);

    pc.onnegotiationneeded = () => {
      void makeOffer(peer);
    };

    pc.onicecandidate = ({ candidate }) => {
      if (!candidate) return;
      if (candidate.type === "relay" || candidate.candidate.includes(" typ relay ")) {
        peer.sawRelay = true;
        emit();
      }
      sendSignal({ toUserId: userId, signal: "ice", data: candidate.toJSON() });
    };

    pc.onicegatheringstatechange = () => {
      if (pc.iceGatheringState !== "complete") return;
      // Helps diagnose "TURN configured but still Connecting" on the server.
      console.info("[webrtc] ICE gathering complete", {
        peer: userId,
        sawRelay: peer.sawRelay,
        turnUrls: buildIceServers()
          .filter((s) => String(s.urls).includes("turn:"))
          .map((s) => s.urls),
      });
    };

    pc.ontrack = ({ streams, track }) => {
      const stream = streams[0] ?? new MediaStream([track]);
      peer.stream = stream;
      attachRemoteAnalyser(peer, stream);
      emit();
    };

    pc.onconnectionstatechange = () => {
      const state = pc.connectionState;
      peer.connection =
        state === "connected"
          ? "connected"
          : state === "failed"
            ? "failed"
            : state === "closed"
              ? "closed"
              : "connecting";
      if (state === "connected" || state === "failed" || state === "closed") clearOfferWatchdog(peer);
      // A failed connection is usually a NAT that STUN cannot traverse. ICE
      // restart is the one recovery worth attempting automatically; beyond that
      // it needs TURN, which no amount of retrying can substitute for.
      // Not gated on politeness: that made recovery depend on which id happened
      // to be lower, so half of all failures were never retried. The restart
      // fires onnegotiationneeded, and a simultaneous restart from both ends is
      // just another collision, which the politeness rule already resolves.
      if (state === "failed") pc.restartIce();
      emit();
    };

    return peer;
  }

  function disconnect(userId: number) {
    const peer = peers.get(userId);
    if (!peer) return;
    clearOfferWatchdog(peer);
    peer.pc.onnegotiationneeded = null;
    peer.pc.onicecandidate = null;
    peer.pc.ontrack = null;
    peer.pc.onconnectionstatechange = null;
    peer.pc.close();
    peers.delete(userId);
    emit();
  }

  // Named rather than returned inline so the screen-share handlers can call
  // back into the session (the browser's own "stop sharing" bar has to unwind
  // exactly like the in-app button).
  const session: CallSession = {
    transport: "mesh",
    setPeers(userIds: number[]) {
      if (closed) return;
      const wanted = new Set(userIds.filter((id) => id !== selfUserId));
      for (const userId of peers.keys()) {
        if (!wanted.has(userId)) disconnect(userId);
      }
      for (const userId of wanted) {
        // BOTH sides dial. Perfect negotiation resolves glare; announceReady +
        // the offer watchdog recover when the first offer was sent before the
        // other side's CallOverlay existed (RealtimeProvider drops call:signal).
        if (!peers.has(userId)) connect(userId);
      }
      emit();
    },

    announceReady(userIds: number[]) {
      if (closed) return;
      for (const userId of userIds) {
        if (userId === selfUserId) continue;
        sendSignal({ toUserId: userId, signal: "ready", data: null });
      }
    },

    handleSignal({ fromUserId, signal, data }: InboundSignal) {
      if (closed) return;

      // Peer is online and ready to negotiate — if our earlier offer was lost
      // (no remote description yet), restart ICE so onnegotiationneeded fires
      // a fresh offer into a listening socket. Do not re-offer once we already
      // have a remote description: that races with a late answer and hits
      // "Called in wrong state: stable".
      if (signal === "ready") {
        const peer = peers.get(fromUserId) ?? connect(fromUserId);
        if (peer.pc.signalingState === "have-local-offer" && !peer.pc.currentRemoteDescription) {
          peer.pendingCandidates = [];
          try {
            peer.pc.restartIce();
          } catch {
            void makeOffer(peer);
          }
        }
        return;
      }

      const peer = connect(fromUserId);
      const { pc } = peer;

      // Chained, not fired-and-forgotten: two signals handled concurrently can
      // interleave their setRemoteDescription/setLocalDescription steps and
      // leave the connection in the wrong signalling state.
      peer.queue = peer.queue.then(async () => {
        if (closed || peers.get(fromUserId) !== peer) return;
        try {
          if (signal === "ice") {
            const candidate = data as RTCIceCandidateInit | null;
            // Hold anything that arrives before the description it belongs to,
            // rather than letting addIceCandidate throw it away.
            if (!pc.remoteDescription) {
              if (candidate) peer.pendingCandidates.push(candidate);
              return;
            }
            await safeAddIceCandidate(pc, candidate);
            return;
          }

          const description = data as RTCSessionDescriptionInit;

          // Late/duplicate answer after we already completed negotiation (or after
          // a glare rollback left us stable). Applying it throws
          // "Called in wrong state: stable".
          if (description.type === "answer" && pc.signalingState !== "have-local-offer") {
            return;
          }

          // Offer only belongs in stable (or a glare we resolve below).
          if (
            description.type === "offer" &&
            pc.signalingState !== "stable" &&
            !(peer.makingOffer || pc.signalingState === "have-local-offer")
          ) {
            return;
          }

          const collision = description.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
          peer.ignoreOffer = !peer.polite && collision;

          // Impolite + collision normally drops the remote offer. That is correct
          // for simultaneous glare — but not when our local offer was never
          // answered (sent before their socket existed). Accept theirs instead.
          if (peer.ignoreOffer) {
            const unansweredLocal =
              pc.signalingState === "have-local-offer" && !pc.currentRemoteDescription;
            if (unansweredLocal) {
              peer.ignoreOffer = false;
              // Drop ICE gathered against the unanswered local offer — they are
              // invalid once we roll back and answer the remote offer instead.
              peer.pendingCandidates = [];
              try {
                await pc.setLocalDescription({ type: "rollback" });
              } catch {
                // Older browsers may lack rollback — restart the PC path via ICE.
                peer.pendingCandidates = [];
                peer.pc.restartIce();
                return;
              }
            } else {
              // Ignoring this offer: discard any trickle ICE that belonged to it.
              peer.pendingCandidates = [];
              return;
            }
          }

          // Re-check after possible rollback — state may already have moved.
          if (description.type === "answer" && pc.signalingState !== "have-local-offer") {
            return;
          }

          await pc.setRemoteDescription(description);
          clearOfferWatchdog(peer);

          // The description is in place, so anything queued can be applied now.
          const queued = peer.pendingCandidates.splice(0);
          for (const candidate of queued) {
            await safeAddIceCandidate(pc, candidate);
          }

          if (description.type === "offer") {
            await pc.setLocalDescription();
            sendSignal({ toUserId: fromUserId, signal: "answer", data: pc.localDescription });
          }
        } catch (err) {
          const message = err instanceof Error ? err.message : String(err);
          // SDP/ICE state races during glare recovery are expected; do not abort the UI.
          if (/wrong state|InvalidStateError|setRemoteDescription|setLocalDescription/i.test(message)) {
            return;
          }
          onError(err instanceof Error ? err : new Error(message));
        }
      });
    },

    setMicEnabled(enabled: boolean) {
      for (const track of local.getAudioTracks()) track.enabled = enabled;
    },

    setCameraEnabled(enabled: boolean) {
      // Disabling the track rather than removing it: the peer keeps receiving a
      // (black) stream, so no renegotiation is needed to come back.
      if (cameraTrack) cameraTrack.enabled = enabled;
    },

    async startScreenShare() {
      if (closed || screenStream) return false;
      let display: MediaStream;
      try {
        display = await navigator.mediaDevices.getDisplayMedia({ video: true });
      } catch {
        // Cancelling the picker throws — a decision, not a failure.
        return false;
      }
      screenStream = display;
      const track = display.getVideoTracks()[0];
      if (!track) {
        screenStream = null;
        return false;
      }
      track.addEventListener("ended", () => {
        void session.stopScreenShare();
      });

      for (const peer of peers.values()) {
        const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
        // replaceTrack swaps the media without touching the SDP, so a screen
        // share in an existing video call costs no renegotiation. In an
        // audio-only call there is no video sender, so the track is added and
        // perfect negotiation handles the renegotiation that follows.
        if (sender) await sender.replaceTrack(track);
        else peer.pc.addTrack(track, display);
      }
      onLocalStream(display);
      return true;
    },

    async stopScreenShare() {
      if (!screenStream) return;
      for (const track of screenStream.getTracks()) track.stop();
      screenStream = null;
      for (const peer of peers.values()) {
        const sender = peer.pc.getSenders().find((s) => s.track?.kind === "video");
        if (sender) await sender.replaceTrack(cameraTrack ?? null);
      }
      onLocalStream(local);
    },

    resumeAudio() {
      if (audioContext && audioContext.state === "suspended") {
        void audioContext.resume().catch(() => undefined);
      }
    },

    sendReaction(emoji: string) {
      onData?.({ fromUserId: selfUserId, type: "reaction", emoji });
    },

    sendChat(text: string) {
      onData?.({ fromUserId: selfUserId, type: "chat", text });
    },

    async switchAudioDevice() {
      // Mesh holds a single getUserMedia stream; device switching is LiveKit-only.
    },

    async switchVideoDevice() {},

    close() {
      if (closed) return;
      closed = true;
      window.clearInterval(levelTimer);
      window.clearInterval(statsTimer);
      for (const userId of [...peers.keys()]) disconnect(userId);
      for (const track of local.getTracks()) track.stop();
      if (screenStream) for (const track of screenStream.getTracks()) track.stop();
      cameraTrack = null;
      void audioContext?.close().catch(() => undefined);
      onLocalStream(null);
    },
  };

  return session;
}
