import { EventEmitter } from "node:events";
import { recordAudioSink, recordVideoSink, streamAudioFile, streamVideoFile } from "./xchat-call-media.js";

const txn = () => Math.random().toString(36).slice(2);
const delay = (ms) => new Promise((r) => setTimeout(r, ms));

export function extractStreamName(jwt) {
  try {
    return JSON.parse(Buffer.from(String(jwt).split(".")[1], "base64").toString("utf8")).StreamName ?? "";
  } catch {
    return "";
  }
}

// Minimal Janus REST + long-poll client (videoroom plugin). Uses the periscope session's
// TLS-fingerprinted transport. Auth = the raw Janus JWT in the Authorization header.
class JanusClient {
  constructor({ http, janusUrl, jwt }) {
    this.http = http;
    this.janusUrl = janusUrl;
    this.jwt = jwt;
    this.sessionId = null;
    this._poll = false;
  }
  async _req(url, body, method = "POST") {
    let lastErr;
    for (let attempt = 0; attempt < 3; attempt++) {
      let r;
      try {
        r = await this.http(url, { method, body, headers: { "Content-Type": "application/json", Authorization: this.jwt } });
      } catch (e) {
        lastErr = e;
        await new Promise((res) => setTimeout(res, 500));
        continue;
      }
      if (r.status >= 200 && r.status <= 399) return r.json;
      lastErr = new Error(`janus ${method} -> ${r.status}`);
      if (r.status !== 408 && r.status !== 429 && r.status < 500) break; // only retry transient
      await new Promise((res) => setTimeout(res, 500));
    }
    throw lastErr;
  }
  async create() {
    const r = await this._req(this.janusUrl, { janus: "create", transaction: txn() });
    this.sessionId = r?.data?.id;
    return this.sessionId;
  }
  async attach() {
    const r = await this._req(`${this.janusUrl}/${this.sessionId}`, { janus: "attach", plugin: "janus.plugin.videoroom", transaction: txn() });
    return r?.data?.id;
  }
  message(handleId, body, jsep) {
    return this._req(`${this.janusUrl}/${this.sessionId}/${handleId}`, { janus: "message", transaction: txn(), body, ...(jsep ? { jsep } : {}) });
  }
  trickle(handleId, candidate) {
    return this._req(`${this.janusUrl}/${this.sessionId}/${handleId}`, { janus: "trickle", transaction: txn(), candidate });
  }
  async destroy() {
    this._poll = false;
    try {
      await this._req(`${this.janusUrl}/${this.sessionId}`, { janus: "destroy", transaction: txn() });
    } catch {}
  }
  startPolling(onEvent) {
    this._poll = true;
    (async () => {
      while (this._poll) {
        let r;
        try {
          r = await this.http(`${this.janusUrl}/${this.sessionId}?maxev=1`, { method: "GET", headers: { Authorization: this.jwt } });
        } catch {
          await delay(400);
          continue;
        }
        if (!this._poll) break;
        if (r.status === 404) {
          onEvent({ janus: "restart" });
          break;
        }
        if (r.json?.janus) onEvent(r.json);
        else await delay(200);
      }
    })();
  }
}

// A Janus SFU group call (1..N participants). EventEmitter: connected, track, ended, error.
export class GroupCall extends EventEmitter {
  constructor({ periscope, webrtc, iceServers, janusUrl, jwt, sessionUuid, streamName, periscopeUserId, roomId, audioOnly, isHost, conversationId, recipientIds, videoCodec }) {
    super();
    this.periscope = periscope;
    this.webrtc = webrtc;
    this.iceServers = iceServers ?? [];
    this.janusUrl = janusUrl;
    this.jwt = jwt;
    this.sessionUuid = sessionUuid ?? "";
    this.streamName = streamName || extractStreamName(jwt);
    this.periscopeUserId = periscopeUserId;
    this.roomId = String(roomId);
    this.audioOnly = !!audioOnly;
    // X's app uses h264; @roamhq/wrtc only does vp8/vp9/av1. Default to vp8 so calls between
    // emusks clients have working video out of the box; pass "h264" with an h264-capable engine
    // for interop with the official X app.
    this.videoCodec = videoCodec ?? "vp8";
    this.isHost = !!isHost;
    this.conversationId = conversationId ?? "";
    this.recipientIds = (recipientIds ?? []).map(String);
    this.connected = false;
    this.startedAt = null;
    this._published = false;
    this._subscriberJoined = false;
    this._subscribedFeeds = new Set();
    this._closed = false;
    this.remoteTracks = [];
  }

  get durationSeconds() {
    return this.startedAt ? Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000)) : 0;
  }

  async start() {
    const { RTCPeerConnection, nonstandard } = this.webrtc;
    this.janus = new JanusClient({ http: this.periscope.http.bind(this.periscope), janusUrl: this.janusUrl, jwt: this.jwt });
    await this.janus.create();
    this.pubHandle = await this.janus.attach();
    this.subHandle = await this.janus.attach();

    this.pubPc = new RTCPeerConnection({ iceServers: this.iceServers });
    this.subPc = new RTCPeerConnection({ iceServers: this.iceServers });
    this.audioSource = new nonstandard.RTCAudioSource();
    this.pubPc.addTransceiver(this.audioSource.createTrack(), { direction: "sendonly" });
    if (!this.audioOnly) {
      this.videoSource = new nonstandard.RTCVideoSource();
      const vt = this.pubPc.addTransceiver(this.videoSource.createTrack(), { direction: "sendonly" });
      try {
        const caps = this.webrtc.RTCRtpSender?.getCapabilities?.("video");
        if (caps?.codecs && vt.setCodecPreferences) {
          const h264 = caps.codecs.filter((c) => /h264/i.test(c.mimeType));
          if (h264.length) vt.setCodecPreferences([...h264, ...caps.codecs.filter((c) => !/h264/i.test(c.mimeType))]);
        }
      } catch {}
    }

    this.pubPc.onicecandidate = (e) => {
      if (e.candidate?.candidate) this.janus.trickle(this.pubHandle, e.candidate.toJSON()).catch(() => {});
    };
    this.subPc.onicecandidate = (e) => {
      if (e.candidate?.candidate) this.janus.trickle(this.subHandle, e.candidate.toJSON()).catch(() => {});
    };
    this.subPc.ontrack = (e) => {
      this.remoteTracks.push(e.track);
      this.emit("track", { kind: e.track.kind, track: e.track });
    };
    const onState = () => {
      const s = this.pubPc.connectionState;
      if ((s === "connected" || this.pubPc.iceConnectionState === "connected" || this.pubPc.iceConnectionState === "completed") && !this.connected) {
        this.connected = true;
        this.startedAt = Date.now();
        this.emit("connected");
      }
      if (s === "failed" || s === "closed") this._end("connection_" + s);
    };
    this.pubPc.onconnectionstatechange = onState;
    this.pubPc.oniceconnectionstatechange = onState;

    this.janus.startPolling((ev) => this._onEvent(ev).catch((e) => this.emit("error", e)));

    if (this.isHost) {
      await this.janus.message(this.pubHandle, {
        room: this.roomId,
        periscope_user_id: this.periscopeUserId,
        request: "create",
        audiocodec: "opus",
        videocodec: this.videoCodec,
        transport_wide_cc_ext: true,
        app_component: "audio-room",
        h264_profile: "42e01f",
        dummy_publisher: true,
      });
    }
    await this.janus.message(this.pubHandle, {
      room: this.roomId,
      periscope_user_id: this.periscopeUserId,
      request: "join",
      ptype: "publisher",
      display: this.sessionUuid || this.periscopeUserId,
      session_uuid: this.sessionUuid,
      stream_name: this.streamName,
      vidman_token: this.jwt,
      is_private: false,
    });
    // Janus' new-publisher push is unreliable here; actively poll the room for publishers.
    this._discoverTimer = setInterval(() => this._discoverPublishers().catch(() => {}), 2500);
    return this;
  }

  async _discoverPublishers() {
    if (this._closed) return;
    const r = await this.janus.message(this.pubHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "listparticipants" });
    const parts = r?.plugindata?.data?.participants ?? [];
    if (this.listenerCount("debug")) this.emit("debug", { participants: parts.map((p) => ({ id: p.id, display: p.display, publisher: p.publisher })), me: this.publisherId });
    const fresh = parts
      .filter((p) => p.publisher && p.display !== "Dummy publisher" && String(p.id) !== String(this.publisherId) && !this._subscribedFeeds.has(p.id))
      .map((p) => p.id);
    if (fresh.length) {
      fresh.forEach((f) => this._subscribedFeeds.add(f));
      this.emit("debug", { discovered: fresh });
      await this._subscribeTo(fresh.map((feed) => ({ feed })));
    }
  }

  async _onEvent(j) {
    if (j.janus === "restart") return; // SFU dropped us; surface via 'ended' on pc failure
    const data = j.plugindata?.data;
    if (data?.videoroom && data.videoroom !== "event") this.emit("debug", { videoroom: data.videoroom, hasJsep: !!j.jsep });
    if (data?.videoroom === "joined") {
      this.publisherId = data.id;
      await this._publish();
      if (this.isHost) this._goLive().catch((e) => this.emit("error", e));
      if (Array.isArray(data.publishers)) await this._subscribe(data.publishers);
    }
    if (data && Array.isArray(data.publishers)) await this._subscribe(data.publishers);
    if (j.jsep) {
      if (j.sender === this.pubHandle && j.jsep.type === "answer") await this.pubPc.setRemoteDescription(j.jsep).catch((e) => this.emit("error", e));
      else if (j.sender === this.subHandle && j.jsep.type === "offer") await this._answerSubscriber(j.jsep);
    }
  }

  async _publish() {
    if (this._published) return;
    this._published = true;
    const offer = await this.pubPc.createOffer();
    await this.pubPc.setLocalDescription(offer);
    await this.janus.message(
      this.pubHandle,
      {
        room: this.roomId,
        periscope_user_id: this.periscopeUserId,
        request: "configure",
        session_uuid: this.sessionUuid,
        stream_name: this.streamName,
        vidman_token: this.jwt,
        audio: true,
        video: !this.audioOnly,
        is_private: false,
      },
      { type: "offer", sdp: this.pubPc.localDescription.sdp },
    );
  }

  async _goLive() {
    await this.periscope.publishBroadcast({
      broadcast_id: this.roomId,
      status: this.conversationId,
      content_type: this.audioOnly ? "audio" : "video",
      mentioned_twitter_user_ids: this.recipientIds,
      narrow_cast_space_type: 3,
      accept_guests: true,
      janus_room_id: this.roomId,
      janus_publisher_id: this.publisherId,
      webrtc_handle_id: this.pubHandle,
      webrtc_session_id: this.janus.sessionId,
    });
    this.emit("live");
  }

  async _subscribe(publishers) {
    const streams = [];
    for (const p of publishers) {
      if (p.dummy || p.display === "Dummy publisher" || String(p.periscope_user_id) === String(this.periscopeUserId) || String(p.id) === String(this.publisherId)) continue;
      if (this._subscribedFeeds.has(p.id)) continue;
      this._subscribedFeeds.add(p.id);
      const mids = (p.streams ?? []).map((s) => ({ feed: p.id, mid: s.mid }));
      streams.push(...(mids.length ? mids : [{ feed: p.id }]));
    }
    if (streams.length) await this._subscribeTo(streams);
  }

  async _subscribeTo(streams) {
    if (!streams.length) return;
    if (!this._subscriberJoined) {
      this._subscriberJoined = true;
      await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "join", ptype: "subscriber", streams });
    } else {
      await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "subscribe", streams });
    }
  }

  async _answerSubscriber(offer) {
    await this.subPc.setRemoteDescription(offer);
    const answer = await this.subPc.createAnswer();
    await this.subPc.setLocalDescription(answer);
    await this.janus.message(this.subHandle, { room: this.roomId, periscope_user_id: this.periscopeUserId, request: "start" }, { type: "answer", sdp: this.subPc.localDescription.sdp });
  }

  async sendAudioFile(filePath, opts = {}) {
    if (!this.audioSource) throw new Error("call has no audio track");
    return streamAudioFile(this.audioSource, filePath, opts);
  }
  async sendVideoFile(filePath, opts = {}) {
    if (!this.videoSource) throw new Error("audio-only call; start with { video: true } to send video");
    const video = await streamVideoFile(this.videoSource, filePath, opts);
    let audio = null;
    if (this.audioSource && opts.audio !== false) audio = await streamAudioFile(this.audioSource, filePath, opts).catch(() => null);
    return { video, audio, done: Promise.all([video.done, audio?.done].filter(Boolean)) };
  }
  recordIncoming({ audioPath, videoPath } = {}) {
    const ns = this.webrtc.nonstandard;
    const recorders = {};
    const attach = (track) => {
      if (track.kind === "audio" && audioPath && !recorders.audio) recorders.audio = recordAudioSink(new ns.RTCAudioSink(track), audioPath);
      if (track.kind === "video" && videoPath && !recorders.video) recorders.video = recordVideoSink(new ns.RTCVideoSink(track), videoPath);
    };
    for (const t of this.remoteTracks) attach(t);
    this.on("track", ({ track }) => attach(track));
    return recorders;
  }

  async hangup() {
    await this._end("hung_up");
  }
  async _end(reason) {
    if (this._closed) return;
    this._closed = true;
    this.endReason = reason;
    try {
      clearInterval(this._discoverTimer);
    } catch {}
    try {
      this.pubPc?.close();
    } catch {}
    try {
      this.subPc?.close();
    } catch {}
    try {
      await this.janus?.destroy();
    } catch {}
    this.emit("ended", { reason, durationSeconds: this.durationSeconds });
  }
}
