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

const VENDOR = "m5-proxsee-login-a2011357b73e";
const PROXSEE = "https://proxsee.pscp.tv/api/v2";
const GUEST = "https://guest-cf.pscp.tv/api/v1";
const PERISCOPE_OP = { id: "zCYojd6h_gVXYjFlaAk4bA", name: "useDirectCallSetupQuery", doc: "query useDirectCallSetupQuery { authenticate_periscope }" };

const delay = (ms) => new Promise((r) => setTimeout(r, ms));

let _webrtc = null;
export async function loadWebRTC(override) {
  if (override) return override;
  if (_webrtc) return _webrtc;
  try {
    const mod = await import("@roamhq/wrtc");
    _webrtc = mod.default ?? mod;
    return _webrtc;
  } catch {
    throw new Error(
      "XChat calls need a WebRTC engine. Install one with `npm i @roamhq/wrtc`, or pass your own via client.xchat.useWebRTC(engine).",
    );
  }
}

// Bootstraps and holds the Periscope/guest-service session that backs calls for one client.
export class PeriscopeSession {
  constructor(client) {
    this.client = client;
    this.cookie = null;
    this.guestToken = null;
    this.userType = null;
    this.periscopeId = null;
    this.ice = null;
    this._ready = null;
  }

  get _fp() {
    return this.client.auth.client.fingerprints;
  }

  async _post(url, body, extraHeaders = {}) {
    const cycleTLS = await getCycleTLS();
    const res = await cycleTLS(
      url,
      {
        headers: { "content-type": "application/json", accept: "application/json", ...extraHeaders },
        userAgent: this._fp.userAgent,
        ja3: this._fp.ja3,
        ja4r: this._fp.ja4r,
        body: JSON.stringify(body),
        proxy: this.client.proxy || undefined,
      },
      "POST",
    );
    return { status: res.status, json: await res.json().catch(() => null) };
  }

  async _periscopeJwt() {
    const auth = this.client.auth;
    const cycleTLS = await getCycleTLS();
    const res = await cycleTLS(
      `https://api.x.com/graphql/${PERISCOPE_OP.id}/${PERISCOPE_OP.name}`,
      {
        headers: {
          accept: "*/*",
          authorization: `Bearer ${auth.client.bearer}`,
          "content-type": "application/json",
          origin: "https://chat.x.com",
          referer: "https://chat.x.com/",
          "x-csrf-token": auth.csrfToken,
          "x-twitter-active-user": "yes",
          "x-twitter-auth-type": "OAuth2Session",
          "x-twitter-client-language": "en",
          cookie: auth.client.headers.cookie + (this.client.elevatedCookies ? `; ${this.client.elevatedCookies}` : ""),
        },
        userAgent: this._fp.userAgent,
        ja3: this._fp.ja3,
        ja4r: this._fp.ja4r,
        body: JSON.stringify({ operationName: PERISCOPE_OP.name, variables: {}, query: PERISCOPE_OP.doc, queryId: PERISCOPE_OP.id }),
        proxy: this.client.proxy || undefined,
      },
      "POST",
    );
    const json = await res.json();
    const jwt = json?.data?.authenticate_periscope;
    if (!jwt) throw new Error(`xchat.calls: could not get Periscope auth token${json?.errors ? `: ${json.errors[0]?.message}` : ""}`);
    return jwt;
  }

  _prefix(name) {
    return this.userType === "Twitter" ? `twitter/${name}` : name;
  }

  async ensure() {
    if (this.cookie && this.guestToken) return this;
    this._ready ??= (async () => {
      const jwt = await this._periscopeJwt();
      const login = await this._post(`${PROXSEE}/loginTwitterToken`, { jwt, vendor_id: VENDOR, create_user: true });
      if (!login.json?.cookie) throw new Error(`xchat.calls: Periscope login failed (status ${login.status})`);
      this.cookie = login.json.cookie;
      this.userType = login.json.type;
      this.periscopeId = login.json.user?.id ?? null;
      const tok = await this._post(`${PROXSEE}/${this._prefix("authorizeToken")}`, { service: "guest", cookie: this.cookie });
      this.guestToken = tok.json?.authorization_token;
      if (!this.guestToken) throw new Error("xchat.calls: could not authorize guest service");
      await this.refreshIceServers();
      return this;
    })();
    try {
      return await this._ready;
    } catch (e) {
      this._ready = null;
      throw e;
    }
  }

  async refreshIceServers() {
    const turn = await this._post(`${PROXSEE}/${this._prefix("turnServers")}`, { cookie: this.cookie, p2p: true });
    const uris = turn.json?.uris ?? [];
    this.ice = uris.length ? [{ urls: uris, username: turn.json.username, credential: turn.json.password }] : [];
    return this.ice;
  }

  async _guest(path, body, { signal } = {}) {
    await this.ensure();
    const send = () =>
      getCycleTLS().then((c) =>
        c(
          `${GUEST}/${path}`,
          {
            headers: { "content-type": "application/json", accept: "application/json", authorization: this.guestToken },
            userAgent: this._fp.userAgent,
            ja3: this._fp.ja3,
            ja4r: this._fp.ja4r,
            body: JSON.stringify(body),
            proxy: this.client.proxy || undefined,
            ...(signal ? { timeout: 40 } : {}),
          },
          "POST",
        ),
      );
    let res = await send();
    if (res.status === 401) {
      const tok = await this._post(`${PROXSEE}/${this._prefix("authorizeToken")}`, { service: "guest", cookie: this.cookie });
      this.guestToken = tok.json?.authorization_token ?? this.guestToken;
      res = await send();
    }
    return await res.json().catch(() => null);
  }

  broadcastCreate(inviteeIds, audioOnly) {
    return this._guest("p2p/broadcast/create", { invitees_twitter_str: inviteeIds.map(String), audio_only: !!audioOnly });
  }
  broadcastJoin(broadcastId) {
    return this._guest("p2p/broadcast/join", { broadcast_id: broadcastId });
  }
  broadcastPublish(broadcastId) {
    return this._guest("p2p/broadcast/publish", { broadcast_id: broadcastId });
  }
  broadcastLeave(broadcastId, sessionUuid, trigger = "User") {
    return this._guest("p2p/broadcast/leave", { broadcast_id: broadcastId, session_uuid: sessionUuid, trigger });
  }
  broadcastStatus(broadcastId) {
    return this._guest("p2p/broadcast/status", { broadcast_id: broadcastId });
  }
  signalingSend(broadcastId, recipientIdStr, message) {
    return this._guest("signaling/send", { broadcast_id: broadcastId, recipient_id_str: String(recipientIdStr), message });
  }
  signalingReceive(broadcastId, cursor) {
    return this._guest("signaling/receive", cursor != null ? { broadcast_id: broadcastId, cursor } : { broadcast_id: broadcastId }, { signal: true });
  }

  // generic TLS-fingerprinted request (used by the Janus client for the SFU REST API)
  async http(url, { method = "POST", body, headers = {}, timeoutSec } = {}) {
    const cycleTLS = await getCycleTLS();
    const opts = { headers, userAgent: this._fp.userAgent, ja3: this._fp.ja3, ja4r: this._fp.ja4r, proxy: this.client.proxy || undefined };
    if (body !== undefined) opts.body = typeof body === "string" ? body : JSON.stringify(body);
    if (timeoutSec) opts.timeout = timeoutSec;
    const res = await cycleTLS(url, opts, method);
    return { status: res.status, json: await res.json().catch(() => null) };
  }

  // --- audiospace/group broadcast lifecycle (proxsee) ---
  async createBroadcast({ audioOnly = true, conversationId = "", region = "us-west-2" } = {}) {
    await this.ensure();
    const r = await this._post(`${PROXSEE}/createBroadcast`, {
      app_component: "audio-room",
      is_webrtc: true,
      content_type: audioOnly ? "audio_spaces" : "video",
      height: 720,
      has_moderation: false,
      width: 1080,
      narrow_cast_space_type: 3,
      region,
      cookie: this.cookie,
      languages: ["en", "ar"],
      conversation_id: conversationId,
    });
    return r.json;
  }

  async publishBroadcast(body) {
    await this.ensure();
    const r = await this._post(`${PROXSEE}/publishBroadcast`, { cookie: this.cookie, ...body }, { "X-Idempotence": String(Date.now()), "X-Periscope-User-Agent": "Twitter/m5" });
    return r.json;
  }

  async getAudiospace(broadcastId) {
    await this.ensure();
    return (await this._post(`${PROXSEE}/getAudiospace`, { broadcast_id: broadcastId, cookie: this.cookie })).json;
  }

  // guest join handshake: returns the Janus gateway for a live broadcast
  async joinBroadcast(broadcastId, { audioOnly = true } = {}) {
    await this.ensure();
    const aus = await this.getAudiospace(broadcastId);
    const sp = aus?.audioSpace ?? aus?.audio_space ?? {};
    await this._guest("audiospace/join", { should_auto_join: false, broadcast_id: broadcastId });
    const p2 = await this._guest("audiospace/join", { should_auto_join: true, audio_only: audioOnly, join_as_admin: false, ntp_for_live_frame: 0, ntp_for_broadcaster_frame: 0, broadcast_id: broadcastId, chat_token: "" });
    if (!p2?.session_uuid) throw new Error("xchat.calls: audiospace join failed (broadcast not live / cannot auto-join)");
    const neg = await this._guest("audiospace/stream/negotiate", { session_uuid: p2.session_uuid });
    return { sessionUuid: p2.session_uuid, janusUrl: neg.webrtc_gw_url, jwt: neg.janus_jwt, encryptionInfo: sp.encryption_info, primaryAdmin: sp.primary_admin_user_id };
  }
}

// One live 1:1 call. EventEmitter: 'connected', 'track', 'mediastatus', 'ended', 'error'.
export class XChatCall extends EventEmitter {
  constructor({ periscope, webrtc, iceServers, role, broadcastId, sessionUuid, selfId, peerId, conversationId, audioOnly, transport }) {
    super();
    this.periscope = periscope;
    this.webrtc = webrtc;
    this.iceServers = iceServers ?? [];
    this.transport = transport ?? null; // pluggable signaling; default is the Periscope relay
    this.role = role; // "host" | "guest"
    this.broadcastId = broadcastId;
    this.sessionUuid = sessionUuid;
    this.selfId = String(selfId);
    this.peerId = String(peerId);
    this.conversationId = conversationId;
    this.audioOnly = !!audioOnly;
    this.connected = false;
    this.startedAt = null;
    this._closed = false;
    this._published = false;
    this._candidateQueue = [];
    this._remoteSet = false;
    this.remoteTracks = [];
  }

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

  async start() {
    const { RTCPeerConnection } = this.webrtc;
    const ns = this.webrtc.nonstandard;
    this.pc = new RTCPeerConnection({ iceServers: this.iceServers });

    this.audioSource = new ns.RTCAudioSource();
    this.pc.addTrack(this.audioSource.createTrack());
    if (!this.audioOnly) {
      this.videoSource = new ns.RTCVideoSource();
      this.pc.addTrack(this.videoSource.createTrack());
    }

    this.pc.onicecandidate = (e) => {
      if (!e.candidate || !e.candidate.candidate) return;
      this._send({
        type: "CANDIDATE",
        message: JSON.stringify({ id: e.candidate.sdpMid, label: e.candidate.sdpMLineIndex, candidate: e.candidate.candidate }),
      }).catch(() => {});
    };
    this.pc.ontrack = (e) => {
      const track = e.track;
      this.remoteTracks.push(track);
      this.emit("track", { kind: track.kind, track });
    };
    const onState = () => {
      const s = this.pc.connectionState;
      if ((s === "connected" || this.pc.iceConnectionState === "connected" || this.pc.iceConnectionState === "completed") && !this.connected) {
        this.connected = true;
        this.startedAt = Date.now();
        this.emit("connected");
      }
      if (s === "failed" || s === "closed") this._end("connection_" + s);
    };
    this.pc.onconnectionstatechange = onState;
    this.pc.oniceconnectionstatechange = onState;

    if (this.transport) {
      this.transport.onMessage = (m) => this._ingest(m);
      this.transport.start?.();
    } else {
      this._loop();
    }
    // announce presence; the host publishes + offers when it sees this
    await this._send({ type: "MEDIA_STATUS", message: JSON.stringify({ isCameraDeactivated: this.audioOnly, isMicrophoneDeactivated: false }) }).catch(() => {});
    return this;
  }

  _send(message) {
    const full = { id: cryptoRandomId(), ...message };
    if (this.transport) return Promise.resolve(this.transport.send(full));
    return this.periscope.signalingSend(this.broadcastId, this.peerId, full);
  }

  // unified inbound handler: host publishes + offers on first signal, then dispatch
  _ingest(m) {
    if (this.isHost && !this._published) {
      this._published = true;
      const pub = this.periscope && !this.transport ? this.periscope.broadcastPublish(this.broadcastId) : Promise.resolve();
      pub.then(() => this._makeOffer()).catch((e) => this.emit("error", e));
    }
    this._handle(m).catch((e) => this.emit("error", e));
  }

  async _loop() {
    let cursor;
    while (!this._closed) {
      let resp;
      try {
        resp = await this.periscope.signalingReceive(this.broadcastId, cursor);
      } catch (e) {
        if (this._closed) break;
        await delay(800);
        continue;
      }
      if (this._closed) break;
      if (resp?.cursor != null) cursor = resp.cursor;
      const messages = resp?.messages ?? [];
      for (const m of messages) this._ingest(m);
      if (!messages.length) await delay(300);
    }
  }

  async _makeOffer() {
    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);
    await this._send({ type: "OFFER", message: this.pc.localDescription.sdp });
  }

  async _handle(m) {
    switch (m.type) {
      case "OFFER": {
        await this.pc.setRemoteDescription({ type: "offer", sdp: m.message });
        this._remoteSet = true;
        await this._flushCandidates();
        const answer = await this.pc.createAnswer();
        await this.pc.setLocalDescription(answer);
        await this._send({ type: "ANSWER", message: this.pc.localDescription.sdp });
        break;
      }
      case "ANSWER": {
        if (this.pc.signalingState === "have-local-offer") {
          await this.pc.setRemoteDescription({ type: "answer", sdp: m.message });
          this._remoteSet = true;
          await this._flushCandidates();
        }
        break;
      }
      case "CANDIDATE": {
        const c = JSON.parse(m.message);
        const ice = { sdpMid: c.id, sdpMLineIndex: c.label, candidate: c.candidate };
        if (this._remoteSet) await this.pc.addIceCandidate(ice).catch(() => {});
        else this._candidateQueue.push(ice);
        break;
      }
      case "MEDIA_STATUS": {
        try {
          this.emit("mediastatus", JSON.parse(m.message));
        } catch {}
        break;
      }
    }
  }

  async _flushCandidates() {
    const q = this._candidateQueue;
    this._candidateQueue = [];
    for (const c of q) await this.pc.addIceCandidate(c).catch(() => {});
  }

  // push an audio file into the call (transcoded to PCM, paced in real time)
  async sendAudioFile(filePath, opts = {}) {
    if (!this.audioSource) throw new Error("call has no audio track");
    const ctl = await streamAudioFile(this.audioSource, filePath, opts);
    return ctl;
  }

  // push a video file into the call (video frames + its audio track)
  async sendVideoFile(filePath, opts = {}) {
    if (!this.videoSource) throw new Error("this is an audio-only call; start it with { video: true } to send video");
    const video = await streamVideoFile(this.videoSource, filePath, opts);
    let audio = null;
    if (this.audioSource && opts.audio !== false) {
      try {
        audio = await streamAudioFile(this.audioSource, filePath, opts);
      } catch {}
    }
    return { video, audio, done: Promise.all([video.done, audio?.done].filter(Boolean)) };
  }

  // record incoming media to files; returns recorders with .stop()
  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;
    const dur = this.durationSeconds;
    try {
      this.pc?.close();
    } catch {}
    try {
      this.transport?.close?.();
    } catch {}
    try {
      if (this.periscope && !this.transport) await this.periscope.broadcastLeave(this.broadcastId, this.sessionUuid, "User");
    } catch {}
    if (this.onLifecycle) {
      try {
        await this.onLifecycle(this.connected ? "ended" : "missed", { durationSeconds: dur, audioOnly: this.audioOnly });
      } catch {}
    }
    this.emit("ended", { reason, durationSeconds: dur });
  }
}

function cryptoRandomId() {
  return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
}

// A cross-wired pair of in-memory signaling transports (for tests / custom relays).
// Each delivers what the other sends, asynchronously, like the real relay.
export function loopbackPair() {
  const mk = () => ({ peer: null, onMessage: null, send(m) { setTimeout(() => this.peer?.onMessage?.(m), 0); }, close() {} });
  const a = mk();
  const b = mk();
  a.peer = b;
  b.peer = a;
  return [a, b];
}

// --- incoming call detection ---

// LivePipeline SSE subscription to /avcall/create/<userId>; falls back to message polling.
export function subscribeIncomingCalls(client, periscope, myUserId, onCall, opts = {}) {
  const topics = [`/avcall/create/${myUserId}`, `/avcall/clear/${myUserId}`];
  const url = `https://api.x.com/live_pipeline/events?topic=${topics.map(encodeURIComponent).join(",")}`;
  const ac = new AbortController();
  let stopped = false;
  let usedFallback = false;

  const handleEnvelope = (obj) => {
    const payloadStr = obj?.payload;
    let payload = payloadStr;
    if (typeof payloadStr === "string") {
      try {
        payload = JSON.parse(payloadStr);
      } catch {}
    }
    const create = payload?.["avcall/create"] ?? payload?.avcall_create ?? (obj?.topic?.includes("avcall/create") ? payload : null);
    if (create?.broadcast_id && create?.sender_id) {
      onCall({ broadcastId: create.broadcast_id, hostId: String(create.sender_id), conversationId: create.conversation_id ?? null, audioOnly: !!create.audio_only, source: "livepipeline" });
    }
  };

  const sse = async () => {
    const auth = client.auth;
    const res = await fetch(url, {
      headers: {
        accept: "text/event-stream",
        authorization: `Bearer ${auth.client.bearer}`,
        "x-csrf-token": auth.csrfToken,
        "x-twitter-auth-type": "OAuth2Session",
        cookie: auth.client.headers.cookie,
      },
      signal: ac.signal,
    });
    if (!res.ok || !res.body) throw new Error(`live_pipeline status ${res.status}`);
    let buf = "";
    for await (const chunk of res.body) {
      if (stopped) break;
      buf += Buffer.from(chunk).toString("utf8");
      let idx;
      while ((idx = buf.indexOf("\n")) >= 0) {
        const line = buf.slice(0, idx).trim();
        buf = buf.slice(idx + 1);
        if (!line.startsWith("data:")) continue;
        try {
          handleEnvelope(JSON.parse(line.slice(5).trim()));
        } catch {}
      }
    }
  };

  const poll = async () => {
    usedFallback = true;
    const seen = new Set();
    const intervalMs = opts.pollMs ?? 4000;
    let first = true;
    while (!stopped) {
      try {
        const convos = await client.xchat.conversations();
        for (const c of convos) {
          const lm = c.latestMessage;
          if (lm?.kind === "av_call_started" && lm.broadcastId && !seen.has(lm.broadcastId)) {
            seen.add(lm.broadcastId);
            if (!first && lm.senderId && String(lm.senderId) !== String(myUserId)) {
              onCall({ broadcastId: lm.broadcastId, hostId: String(lm.senderId), conversationId: c.conversationId, audioOnly: !!lm.audioOnly, source: "poll" });
            }
          }
        }
      } catch {}
      first = false;
      await delay(intervalMs);
    }
  };

  (async () => {
    if (opts.transport !== "poll") {
      try {
        await sse();
        if (!stopped && !usedFallback) await poll();
      } catch {
        if (!stopped) await poll();
      }
    } else {
      await poll();
    }
  })();

  return { close: () => { stopped = true; ac.abort(); } };
}
