import getCycleTLS from "../cycletls.js";
import {
  b64,
  b64decode,
  buildActionSignature,
  content,
  conversationId1on1,
  decryptBody,
  deleteEventDetail,
  eciesUnwrap,
  eciesWrap,
  encryptBody,
  eventSignature,
  generateKeyMaterial,
  importIdentity,
  messageCreateEvent,
  parseThrift,
  readLeadingSequenceId,
  readStringField,
  recoverIdentityKeys,
  SCREEN_CAPTURE,
  thriftStr,
} from "./xchat-crypto.js";
import { loadWebRTC, PeriscopeSession, subscribeIncomingCalls, XChatCall } from "./xchat-calls.js";
import { extractStreamName, GroupCall } from "./xchat-group-calls.js";
import { juiceboxRecover, juiceboxRegister } from "./xchat-juicebox.js";
import { GET_CONVERSATION_PAGE, GET_INITIAL_CHAT_PAGE } from "./xchat-queries.js";

const TOKEN_MAP_FRAGMENT = `fragment TokenMapFragment on KeyStoreTokenMap { __typename realm_state realm_state_string max_guess_count recover_threshold register_threshold token_map { __typename key value { __typename token address public_key } } key_store_token_map_json }`;

const OPS = {
  GetPublicKeys: {
    id: "GJQbOZALDO5D3Zp2IZhH6w",
    doc: `query GetPublicKeys($ids: [NumericString!]!, $include_juicebox_tokens: Boolean = false ) { user_results_by_rest_ids(rest_ids: $ids, safety_level: XChat) { __typename ... on UserResults { rest_id id result { __typename ... on User { chat_permissions { __typename can_dm can_dm_on_xchat dm_blocking passes_premium_check } ...UserPublicKeysFragment } } } } }  ${TOKEN_MAP_FRAGMENT}  fragment UserPublicKeysFragment on User { __typename get_public_keys { __typename ... on GetPublicKeysResult { public_keys_with_token_map { __typename public_key_with_metadata { __typename public_key { __typename public_key signing_public_key identity_public_key_signature registration_method } version } token_map @include(if: $include_juicebox_tokens) { __typename ...TokenMapFragment } target_token_map @include(if: $include_juicebox_tokens) { __typename ...TokenMapFragment } } is_managed_pin_user registration_method } ... on GetPublicKeysError { error } } }`,
  },
  GenerateXChatTokenMutation: {
    id: "Qh3fZRjPPtPoHYR_2sCZsA",
    doc: `mutation GenerateXChatTokenMutation { user_get_x_chat_auth_token(safety_level: XChat) { __typename error_code token } }`,
  },
  AddEncryptedConversationKeysMutation: {
    id: "4V1KC8ue2tHHvRuIzeczdg",
    doc: `mutation AddEncryptedConversationKeysMutation($conversation_id: String!, $conversation_key_version: NumericString!, $conversation_participant_keys: [ApiConversationParticipantKeyInput!]!, $base64_encoded_key_rotation: String, $action_signatures: [ActionSignatureInput!], $ttl_msec: NumericString) { xchat_add_encrypted_conversation_key(conversation_id: $conversation_id, conversation_key_version: $conversation_key_version, conversation_participant_keys: $conversation_participant_keys, base64_encoded_key_rotation: $base64_encoded_key_rotation, safety_level: XChat, action_signatures: $action_signatures, ttl_msec: $ttl_msec) { __typename error_code conversation_key_change_sequence_id } }`,
  },
  SendMessageCreateMutation: {
    id: "TWRPP7gnKwV_R8-tE-Dd3Q",
    doc: `mutation SendMessageCreateMutation($conversation_id: String!, $message_id: String!, $conversation_token: String, $encoded_message_create_event: String, $encoded_message_event_signature: String) { xchat_send_create_message_event(conversation_id: $conversation_id, conversation_token: $conversation_token, encoded_message_create_event: $encoded_message_create_event, encoded_message_event_signature: $encoded_message_event_signature, message_id: $message_id, safety_level: XChat) { __typename encoded_message_event } }`,
  },
  AddXChatPublicKeyMutation: {
    id: "CQsk6GRuWAVabyXqqEG1sA",
    doc: `mutation AddXChatPublicKeyMutation($version: NumericString!, $generate_version: Boolean, $public_key: XChatPublicKeyInput!) { user_add_public_key(version: $version, public_key: $public_key, generate_version: $generate_version, safety_level: XChat) { __typename error_code version token_map { __typename ...TokenMapFragment } } }  ${TOKEN_MAP_FRAGMENT}`,
  },
  DeleteMessageMutation: {
    id: "4gsDQKEmYkOtvsSIpHXdQA",
    doc: `mutation DeleteMessageMutation($sequence_ids: [String!], $conversation_id: String, $delete_message_action: DeleteMessageActionInput, $action_signatures: [ActionSignatureInput!]) { xchat_delete_messages(safety_level: XChat, sequence_ids: $sequence_ids, conversation_id: $conversation_id, delete_message_action: $delete_message_action, action_signatures: $action_signatures) { __typename error_code } }`,
  },
  DeleteXChatPublicKeyMutation: {
    id: "W5iiIL1MVw4vomq-zLPHUQ",
    doc: `mutation DeleteXChatPublicKeyMutation($version: NumericString!, $clearSigningPublicKeyOnly: Boolean!) { user_delete_public_key(version: $version, clear_signing_public_key_only: $clearSigningPublicKeyOnly, safety_level: XChat) { __typename error_code } }`,
  },
  DmAvPermissionsQuery: {
    id: "kfX5AHDKZrivyHwCaz68mQ",
    doc: `query DmAvPermissionsQuery($recipient_ids: [NumericString!]!) { get_av_permissions(recipient_ids: $recipient_ids, safety_level: DirectMessagesConversationTimeline) { __typename result { __typename can_dm error_code } } }`,
  },
  GetInitialXChatPageQuery: { id: "4hJY4a3hB6CE4C6ROaYeTg", doc: GET_INITIAL_CHAT_PAGE },
  GetConversationPageQuery: { id: "IVlXls9JTnbgQ1gxsGAfJA", doc: GET_CONVERSATION_PAGE },
  MuteConversationMutation: {
    id: "6iDsxSkhGLvdiJpqtAtzTQ",
    doc: `mutation MuteConversationMutation($conversationIds: [String!], $action_signatures: [ActionSignatureInput!]) { xchat_mute_conversation(conversation_ids: $conversationIds, action_signatures: $action_signatures, safety_level: DirectMessagesMutedUsers) { __typename error_code } }`,
  },
  UnmuteConversationMutation: {
    id: "_f8wd8RlQCCysv8yMKeiaw",
    doc: `mutation UnmuteConversationMutation($conversationIds: [String!], $action_signatures: [ActionSignatureInput!]) { xchat_unmute_conversation(conversation_ids: $conversationIds, action_signatures: $action_signatures, safety_level: DirectMessagesMutedUsers) { __typename error_code } }`,
  },
  UpdateConversationTTLMutation: {
    id: "Gu3kCEwNN2V-Az8NDk30Zg",
    doc: `mutation UpdateConversationTTLMutation($conversationId: String!, $ttlMsec: NumericString!, $action_signatures: [ActionSignatureInput!]) { xchat_update_conversation_message_duration(conversation_id: $conversationId, ttl_msec: $ttlMsec, safety_level: XChat, action_signatures: $action_signatures) { __typename error_code } }`,
  },
  RemoveConversationTTLMutation: {
    id: "EqSXvxskUyw99ARuIbhYlg",
    doc: `mutation RemoveConversationTTLMutation($conversationId: String!, $action_signatures: [ActionSignatureInput!]) { xchat_remove_conversation_message_duration(conversation_id: $conversationId, safety_level: XChat, action_signatures: $action_signatures) { __typename error_code } }`,
  },
};

async function chatGql(client, name, variables = {}) {
  const op = OPS[name];
  const auth = client.auth;
  if (!auth?.csrfToken) throw new Error("must be logged in to use xchat");

  const cycleTLS = await getCycleTLS();
  const res = await cycleTLS(
    `https://api.x.com/graphql/${op.id}/${name}`,
    {
      headers: {
        accept: "*/*",
        "accept-language": "en-US,en;q=0.9",
        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",
        "x-apollo-operation-id": op.id,
        "x-apollo-operation-name": name,
        "apollo-require-preflight": "true",
        cookie: auth.client.headers.cookie + (client.elevatedCookies ? `; ${client.elevatedCookies}` : ""),
      },
      userAgent: auth.client.fingerprints.userAgent,
      ja3: auth.client.fingerprints.ja3,
      ja4r: auth.client.fingerprints.ja4r,
      body: JSON.stringify({ operationName: name, variables, query: op.doc, queryId: op.id }),
      proxy: client.proxy || undefined,
      referrer: "https://chat.x.com/",
    },
    "POST",
  );
  const json = await res.json();
  if (json?.errors?.[0]) {
    throw new Error(json.errors.map((e) => e.message).join(", "));
  }
  return json;
}

function parseUser(ur) {
  const result = ur?.result;
  const perms = result?.chat_permissions;
  const gpk = result?.get_public_keys;
  const list = gpk?.public_keys_with_token_map ?? [];
  return {
    userId: ur?.rest_id ?? null,
    onXChat: list.length > 0,
    isManagedPinUser: gpk?.is_managed_pin_user ?? null,
    permissions: perms
      ? {
          canDm: perms.can_dm,
          canDmOnXChat: perms.can_dm_on_xchat,
          dmBlocking: perms.dm_blocking,
          passesPremiumCheck: perms.passes_premium_check,
        }
      : null,
    keys: list.map((e) => {
      const m = e.public_key_with_metadata;
      return {
        version: m?.version ?? null,
        publicKey: m?.public_key?.public_key ?? null,
        signingPublicKey: m?.public_key?.signing_public_key ?? null,
        identityPublicKeySignature: m?.public_key?.identity_public_key_signature ?? null,
        registrationMethod: m?.public_key?.registration_method ?? null,
      };
    }),
  };
}

export async function publicKeys(userIds, opts = {}) {
  const arr = Array.isArray(userIds) ? userIds : [userIds];
  const ids = await Promise.all(arr.map((u) => resolveUserId(this, u)));
  const res = await chatGql(this, "GetPublicKeys", { ids, include_juicebox_tokens: !!opts.includeTokens });
  return (res?.data?.user_results_by_rest_ids ?? []).map(parseUser);
}

export async function publicKey(userId) {
  const [u] = await publicKeys.call(this, [userId]);
  return u?.keys?.[0] ?? null;
}

export async function profile(userId) {
  const [u] = await publicKeys.call(this, [userId]);
  return u ?? null;
}

export async function permissions(userId) {
  const [u] = await publicKeys.call(this, [userId]);
  return u?.permissions ?? null;
}

export async function canMessage(userId) {
  const p = await permissions.call(this, userId);
  return !!p?.canDmOnXChat;
}

export async function isOnXChat(userId) {
  const [u] = await publicKeys.call(this, [userId]);
  return !!u?.onXChat;
}

export async function token() {
  const res = await chatGql(this, "GenerateXChatTokenMutation", {});
  return res?.data?.user_get_x_chat_auth_token?.token ?? null;
}

export async function gql(name, variables = {}) {
  return chatGql(this, name, variables);
}

export async function fingerprint(key) {
  const keyB64 = typeof key === "string" ? key : key?.publicKey;
  if (!keyB64) throw new Error("xchat.fingerprint: pass a public key (SPKI base64) or a key object");
  const bytes = Uint8Array.from(Buffer.from(keyB64, "base64"));
  const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
  const hex = [...hash].map((b) => b.toString(16).padStart(2, "0")).join("");
  return hex.match(/.{1,4}/g).join(":");
}

// --- identity (required to send) ---

export async function createIdentity(opts = {}) {
  const selfCustody = opts.selfCustody === true;
  const pin = opts.pin != null ? String(opts.pin) : null;
  if (!selfCustody && !pin) {
    throw new Error("xchat.createIdentity: pass a { pin } to back the identity up to X's realms (the default, like the app), or { selfCustody: true } to skip the PIN backup");
  }
  const registrationMethod = opts.registrationMethod ?? (selfCustody ? "SelfCustody" : "CustomPin");
  const km = await generateKeyMaterial();
  const version = String(opts.version ?? Date.now());
  const res = await chatGql(this, "AddXChatPublicKeyMutation", {
    version,
    generate_version: true,
    public_key: {
      public_key: km.publicKeyB64,
      signing_public_key: km.signingPublicKeyB64,
      identity_public_key_signature: km.identitySignatureB64,
      registration_method: registrationMethod,
    },
  });
  const result = res?.data?.user_add_public_key;
  if (result?.error_code) throw new Error(`createIdentity failed: ${result.error_code}`);
  const assignedVersion = String(result?.version ?? version);

  if (!selfCustody) {
    try {
      await juiceboxRegister(result.token_map, pin, km.juiceboxSecret);
    } catch (e) {
      try {
        await chatGql(this, "DeleteXChatPublicKeyMutation", { version: assignedVersion, clearSigningPublicKeyOnly: false });
      } catch {}
      throw new Error(`xchat.createIdentity: PIN backup failed (${e?.message ?? e}); identity rolled back. Pass { selfCustody: true } to skip the PIN backup.`);
    }
  }

  const identity = {
    userId: String(this.user?.id),
    version: assignedVersion,
    registrationMethod,
    pinBacked: !selfCustody,
    publicKeyB64: km.publicKeyB64,
    signingPublicKeyB64: km.signingPublicKeyB64,
    identityPrivateJwk: km.identityPrivateJwk,
    signingPrivateJwk: km.signingPrivateJwk,
  };
  await loadIdentity.call(this, identity);
  return identity;
}

// recover your identity from the realms with just your PIN + this session, then load it.
// after this, you can read and send on all your existing conversations.
export async function recover(pin) {
  const userId = String(this.user?.id ?? "");
  if (!userId) throw new Error("xchat.recover: log in first");
  const res = await chatGql(this, "GetPublicKeys", { ids: [userId], include_juicebox_tokens: true });
  const entry = res?.data?.user_results_by_rest_ids?.[0]?.result?.get_public_keys?.public_keys_with_token_map?.[0];
  const meta = entry?.public_key_with_metadata;
  if (!meta?.public_key?.public_key) throw new Error("xchat.recover: no published key on this account; call createIdentity first");
  const secret = await juiceboxRecover(entry.token_map, String(pin));
  const { identityKey, signingKey } = await recoverIdentityKeys({
    publicKeyB64: meta.public_key.public_key,
    signingPublicKeyB64: meta.public_key.signing_public_key,
    secret,
  });
  this._xchat = {
    userId,
    version: String(meta.version),
    publicKeySpki: b64decode(meta.public_key.public_key),
    signingPublicKeyB64: meta.public_key.signing_public_key,
    identityKey,
    signingKey,
    cKeys: {},
  };
  return { userId, version: String(meta.version), recovered: true };
}

export async function loadIdentity(identity) {
  const { identityKey, signingKey } = await importIdentity(identity);
  const userId = String(identity.userId ?? this.user?.id ?? "");
  if (!userId || userId === "undefined") {
    throw new Error("xchat.loadIdentity: unknown userId; log in first or pass identity.userId");
  }
  this._xchat = {
    userId,
    version: String(identity.version),
    publicKeySpki: b64decode(identity.publicKeyB64),
    signingPublicKeyB64: identity.signingPublicKeyB64,
    identityKey,
    signingKey,
    cKeys: {},
  };
  return this._xchat;
}

function requireIdentity(client) {
  if (!client._xchat) {
    throw new Error("no xchat identity loaded; call client.xchat.createIdentity() or client.xchat.loadIdentity() first");
  }
  return client._xchat;
}

async function ensureConversationKey(client, conversationId, id, recipients) {
  if (id.cKeys[conversationId]) return id.cKeys[conversationId];
  const cKey = crypto.getRandomValues(new Uint8Array(32));
  const version = String(Date.now());
  const participantKeys = [
    { user_id: id.userId, encrypted_conversation_key: await eciesWrap(cKey, id.publicKeySpki), public_key_version: id.version },
    ...(await Promise.all(
      recipients.map(async (r) => ({
        user_id: r.userId,
        encrypted_conversation_key: await eciesWrap(cKey, r.spki),
        public_key_version: String(r.version),
      })),
    )),
  ];
  await chatGql(client, "AddEncryptedConversationKeysMutation", {
    conversation_id: conversationId,
    conversation_key_version: version,
    conversation_participant_keys: participantKeys,
  });
  id.cKeys[conversationId] = { key: cKey, version };
  return id.cKeys[conversationId];
}

async function resolveUserId(client, recipient) {
  if (recipient && typeof recipient === "object") return String(recipient.id ?? recipient.userId);
  const s = String(recipient).trim();
  if (/^\d+$/.test(s)) return s;
  const handle = s.replace(/^@/, "").toLowerCase();
  client._xchatUserCache ??= {};
  if (client._xchatUserCache[handle]) return client._xchatUserCache[handle];
  const user = await client.users.getByUsername(handle);
  if (!user?.id) throw new Error(`xchat: could not resolve user "${recipient}"`);
  client._xchatUserCache[handle] = String(user.id);
  return client._xchatUserCache[handle];
}

// the established conversation has a server-issued token (a JWT); the message signature must
// include it or the recipient/server rejects the message and it never enters the readable stream.
async function conversationToken(client, conversationId, id) {
  id.tokens ??= {};
  if (id.tokens[conversationId] !== undefined) return id.tokens[conversationId];
  let token = "";
  try {
    const res = await chatGql(client, "GetConversationPageQuery", {
      conversation_id: conversationId,
      min_local_sequence_id: "9223372036854775807",
      min_conversation_key_version: "9223372036854775807",
    });
    for (const b of res?.data?.get_conversation_page?.encoded_message_events ?? []) {
      const t = readStringField(b, 5);
      if (t) {
        token = t;
        break;
      }
    }
  } catch {}
  id.tokens[conversationId] = token;
  return token;
}

async function sendEntry(client, recipient, holderBytes, opts = {}) {
  const id = requireIdentity(client);
  const recipientId = await resolveUserId(client, recipient);
  const rk = await publicKey.call(client, recipientId);
  if (!rk) throw new Error(`user ${recipientId} is not on XChat`);
  const conversationId = conversationId1on1(id.userId, recipientId);
  const ck = await ensureConversationKey(client, conversationId, id, [
    { userId: recipientId, spki: b64decode(rk.publicKey), version: rk.version },
  ]);
  const token = await conversationToken(client, conversationId, id);
  const frame = encryptBody(holderBytes, ck.key);
  const mce = messageCreateEvent(frame, ck.version, opts);
  const signature = await eventSignature({
    signingKey: id.signingKey,
    signingPublicKeyB64: id.signingPublicKeyB64,
    conversationToken: token,
    senderId: id.userId,
    conversationId,
    conversationKeyVersion: ck.version,
    frame,
    myVersion: id.version,
  });
  const messageId = crypto.randomUUID();
  const res = await chatGql(client, "SendMessageCreateMutation", {
    conversation_id: conversationId,
    message_id: messageId,
    conversation_token: token || null,
    encoded_message_create_event: b64(mce),
    encoded_message_event_signature: signature,
  });
  const event = res?.data?.xchat_send_create_message_event ?? null;
  return {
    conversationId,
    recipientId,
    messageId,
    sequenceId: readLeadingSequenceId(event?.encoded_message_event),
    response: event,
  };
}

// --- send: text + everything else ---

export async function message(recipientId, text, opts = {}) {
  return sendEntry(this, String(recipientId), content.text(text), opts);
}

export async function react(recipientId, messageSequenceId, emoji, opts = {}) {
  return sendEntry(this, String(recipientId), content.reactionAdd(messageSequenceId, emoji, opts.attachmentId), opts);
}

export async function unreact(recipientId, messageSequenceId, emoji, opts = {}) {
  return sendEntry(this, String(recipientId), content.reactionRemove(messageSequenceId, emoji, opts.attachmentId), opts);
}

export async function edit(recipientId, messageSequenceId, newText, opts = {}) {
  return sendEntry(this, String(recipientId), content.edit(messageSequenceId, newText), opts);
}

export async function markRead(recipientId, sequenceId, opts = {}) {
  return sendEntry(this, recipientId, content.markRead(sequenceId, opts.seenAtMillis ?? Date.now()), opts);
}

export async function markUnread(recipientId, sequenceId, opts = {}) {
  return sendEntry(this, recipientId, content.markUnread(sequenceId), opts);
}

export async function pin(recipientId, opts = {}) {
  const conversationId = conversationId1on1(requireIdentity(this).userId, await resolveUserId(this, recipientId));
  return sendEntry(this, recipientId, content.pin(conversationId), opts);
}

export async function unpin(recipientId, opts = {}) {
  const conversationId = conversationId1on1(requireIdentity(this).userId, await resolveUserId(this, recipientId));
  return sendEntry(this, recipientId, content.unpin(conversationId), opts);
}

export async function setNickname(recipientId, nickname, opts = {}) {
  const target = await resolveUserId(this, opts.userId ?? recipientId);
  return sendEntry(this, recipientId, content.nickname(target, nickname), opts);
}

export async function reportScreenCapture(recipientId, opts = {}) {
  const type = opts.recording ? SCREEN_CAPTURE.Recording : SCREEN_CAPTURE.Screenshot;
  return sendEntry(this, recipientId, content.screenCapture(type), opts);
}

export async function callPermissions(userIds) {
  const arr = Array.isArray(userIds) ? userIds : [userIds];
  const ids = await Promise.all(arr.map((u) => resolveUserId(this, u)));
  const res = await chatGql(this, "DmAvPermissionsQuery", { recipient_ids: ids });
  return res?.data?.get_av_permissions?.result ?? null;
}

// --- audio/video calls (WebRTC over Periscope signaling) ---

function periscopeFor(client) {
  client._periscope ??= new PeriscopeSession(client);
  return client._periscope;
}

export function useWebRTC(engine) {
  this._webrtcEngine = engine;
  return this;
}

async function sendCallLifecycle(client, recipientId, kind, broadcastId, data) {
  if (!client._xchat) return;
  try {
    if (kind === "ended") await sendEntry(client, recipientId, content.avCallEnded(broadcastId, data.durationSeconds ?? 0, !!data.audioOnly));
    else if (kind === "missed") await sendEntry(client, recipientId, content.avCallMissed(!!data.audioOnly));
  } catch {}
}

export async function call(recipient, opts = {}) {
  const selfId = String(this._xchat?.userId ?? this.user?.id ?? "");
  if (!selfId) throw new Error("xchat.call: log in first");
  const recipientId = await resolveUserId(this, recipient);
  const audioOnly = opts.video ? false : (opts.audioOnly ?? true);
  const webrtc = await loadWebRTC(this._webrtcEngine);
  const ps = periscopeFor(this);
  await ps.ensure();
  const created = await ps.broadcastCreate([recipientId], audioOnly);
  const broadcastId = created?.broadcast_id;
  if (!broadcastId) throw new Error(`xchat.call: broadcast create failed: ${JSON.stringify(created)?.slice(0, 200)}`);
  const conversationId = conversationId1on1(selfId, recipientId);
  const callObj = new XChatCall({
    periscope: ps,
    webrtc,
    iceServers: ps.ice,
    role: "host",
    broadcastId,
    sessionUuid: created.session_uuid,
    selfId,
    peerId: recipientId,
    conversationId,
    audioOnly,
  });
  callObj.onLifecycle = (kind, data) => sendCallLifecycle(this, recipientId, kind, broadcastId, data);
  if (opts.announce !== false) sendEntry(this, recipientId, content.avCallStarted(broadcastId, audioOnly)).catch(() => {});
  await callObj.start();
  return callObj;
}

export async function answerCall(incoming, opts = {}) {
  const webrtc = await loadWebRTC(this._webrtcEngine);
  const ps = periscopeFor(this);
  await ps.ensure();
  const broadcastId = incoming.broadcastId ?? incoming;
  const hostId = String(incoming.hostId ?? opts.hostId ?? "");
  const audioOnly = opts.video ? false : (opts.audioOnly ?? incoming.audioOnly ?? true);
  const joined = await ps.broadcastJoin(broadcastId);
  const selfId = String(this._xchat?.userId ?? this.user?.id ?? "");
  const conversationId = incoming.conversationId ?? (selfId && hostId ? conversationId1on1(selfId, hostId) : null);
  const callObj = new XChatCall({
    periscope: ps,
    webrtc,
    iceServers: ps.ice,
    role: "guest",
    broadcastId,
    sessionUuid: joined?.session_uuid,
    selfId,
    peerId: hostId,
    conversationId,
    audioOnly,
  });
  await callObj.start();
  return callObj;
}

export async function startGroupCall(opts = {}) {
  const audioOnly = opts.video ? false : (opts.audioOnly ?? true);
  const webrtc = await loadWebRTC(this._webrtcEngine);
  const ps = periscopeFor(this);
  await ps.ensure();
  const invites = opts.invite ? (Array.isArray(opts.invite) ? opts.invite : [opts.invite]) : [];
  const recipientIds = await Promise.all(invites.map((u) => resolveUserId(this, u)));
  const cb = await ps.createBroadcast({ audioOnly, conversationId: opts.conversationId ?? "" });
  const broadcastId = cb?.broadcast?.id;
  if (!broadcastId) throw new Error(`xchat.startGroupCall: createBroadcast failed: ${JSON.stringify(cb)?.slice(0, 200)}`);
  const call = new GroupCall({
    periscope: ps,
    webrtc,
    iceServers: ps.ice,
    janusUrl: cb.webrtc_gw_url,
    jwt: cb.credential,
    sessionUuid: cb.session_uuid,
    streamName: cb.stream_name,
    periscopeUserId: ps.periscopeId,
    roomId: broadcastId,
    audioOnly,
    isHost: true,
    conversationId: opts.conversationId ?? "",
    recipientIds,
    videoCodec: opts.videoCodec,
  });
  await call.start();
  call.broadcastId = broadcastId;
  return call;
}

export async function joinGroupCall(broadcastId, opts = {}) {
  const audioOnly = opts.video ? false : (opts.audioOnly ?? true);
  const webrtc = await loadWebRTC(this._webrtcEngine);
  const ps = periscopeFor(this);
  await ps.ensure();
  const j = await ps.joinBroadcast(broadcastId, { audioOnly });
  const call = new GroupCall({
    periscope: ps,
    webrtc,
    iceServers: ps.ice,
    janusUrl: j.janusUrl,
    jwt: j.jwt,
    sessionUuid: j.sessionUuid,
    streamName: extractStreamName(j.jwt),
    periscopeUserId: ps.periscopeId,
    roomId: broadcastId,
    audioOnly,
    isHost: false,
  });
  await call.start();
  call.broadcastId = broadcastId;
  return call;
}

export function listenForCalls(handler, opts = {}) {
  const selfId = String(this._xchat?.userId ?? this.user?.id ?? "");
  if (!selfId) throw new Error("xchat.listenForCalls: log in first");
  const ps = periscopeFor(this);
  const client = this;
  ps.ensure().catch(() => {});
  return subscribeIncomingCalls(
    this,
    ps,
    selfId,
    (incoming) => {
      handler({
        ...incoming,
        accept: (acceptOpts = {}) => answerCall.call(client, incoming, acceptOpts),
        decline: async () => {
          try {
            await ps.broadcastLeave(incoming.broadcastId, null, "User");
          } catch {}
        },
      });
    },
    opts,
  );
}

// --- reading (decrypt inbound) ---

function detailKind(detail) {
  if (detail[1]) return "message";
  if (detail[3]) return "conversation_key_change";
  if (detail[6]) return "typing";
  if (detail[7]) return "delete";
  if (detail[8]) return "conversation_delete";
  if (detail[9]) return "metadata_change";
  if (detail[12]) return "read";
  if (detail[13]) return "unread";
  return "unknown";
}

async function buildCKeyMap(keyChangeB64s, myUserId, identityKey) {
  const map = {};
  for (const b64v of keyChangeB64s ?? []) {
    try {
      const cke = parseThrift(b64decode(b64v))[7]?.[3];
      if (!cke) continue;
      const version = thriftStr(cke[1]);
      for (const p of cke[2] ?? []) {
        if (thriftStr(p[1]) === String(myUserId)) map[version] = await eciesUnwrap(thriftStr(p[2]), identityKey);
      }
    } catch {}
  }
  return map;
}

function decodeMessageEvent(b64v, cKeyMap) {
  let ev;
  try {
    ev = parseThrift(b64decode(b64v));
  } catch {
    return null;
  }
  const detail = ev[7] ?? {};
  const out = {
    sequenceId: thriftStr(ev[1]),
    messageId: thriftStr(ev[2]),
    senderId: thriftStr(ev[3]),
    conversationId: thriftStr(ev[4]),
    createdAt: thriftStr(ev[6]),
    kind: detailKind(detail),
  };
  const mce = detail[1];
  if (!mce) return out;
  const contents = mce[100];
  const cKey = cKeyMap[thriftStr(mce[101])];
  if (!cKey || !contents) {
    out.encrypted = true;
    return out;
  }
  const plain = decryptBody(contents, cKey);
  if (!plain) {
    out.decryptError = true;
    return out;
  }
  let entry;
  try {
    entry = parseThrift(plain)[1] ?? {};
  } catch {
    return out;
  }
  if (entry[1]) {
    out.kind = "message";
    out.text = thriftStr(entry[1][1]);
  } else if (entry[2]) {
    out.kind = "reaction_add";
    out.targetSequenceId = thriftStr(entry[2][1]);
    out.emoji = thriftStr(entry[2][2]);
  } else if (entry[3]) {
    out.kind = "reaction_remove";
    out.targetSequenceId = thriftStr(entry[3][1]);
    out.emoji = thriftStr(entry[3][2]);
  } else if (entry[4]) {
    out.kind = "edit";
    out.targetSequenceId = thriftStr(entry[4][1]);
    out.text = thriftStr(entry[4][2]);
  } else if (entry[16]) {
    out.kind = "av_call_started";
    out.audioOnly = !!entry[16][1];
    out.broadcastId = thriftStr(entry[16][3]);
  } else if (entry[10]) {
    out.kind = "av_call_ended";
    out.durationSeconds = entry[10][2] != null ? Number(entry[10][2]) : null;
    out.audioOnly = !!entry[10][3];
    out.broadcastId = thriftStr(entry[10][5]);
  } else if (entry[11]) {
    out.kind = "av_call_missed";
    out.audioOnly = !!entry[11][2];
  }
  return out;
}

export async function read(recipient, opts = {}) {
  const id = requireIdentity(this);
  const recipientId = await resolveUserId(this, recipient);
  const conversationId = conversationId1on1(id.userId, recipientId);
  // these "min_" params are actually upper bounds; pass a high value to read from the latest down
  const res = await chatGql(this, "GetConversationPageQuery", {
    conversation_id: conversationId,
    min_local_sequence_id: String(opts.before ?? "9223372036854775807"),
    min_conversation_key_version: "9223372036854775807",
  });
  const page = res?.data?.get_conversation_page ?? {};
  const cKeyMap = await buildCKeyMap(page.missing_conversation_key_change_events, id.userId, id.identityKey);
  const cached = id.cKeys[conversationId];
  if (cached) cKeyMap[cached.version] ??= cached.key;
  const messages = (page.encoded_message_events ?? []).map((b) => decodeMessageEvent(b, cKeyMap)).filter(Boolean);
  return { conversationId, hasMore: !!page.has_more, messages };
}

export async function conversations(opts = {}) {
  const res = await chatGql(this, "GetInitialXChatPageQuery", {
    max_local_sequence_id: opts.maxLocalSequenceId ?? null,
    query_settings: null,
    message_pull_version: opts.messagePullVersion ?? null,
  });
  const page = res?.data?.get_initial_chat_page ?? {};
  const id = this._xchat;
  return Promise.all(
    (page.items ?? []).map(async (it) => {
      const d = it.conversation_detail ?? {};
      const isGroup = d.__typename === "XChatGroupConversationDetail";
      const conv = {
        conversationId: d.conversation_id,
        type: isGroup ? "group" : "direct",
        isMuted: d.is_muted,
        groupName: d.group_metadata?.group_name,
        participants: ((isGroup ? d.group_members_results : d.participants_results) ?? []).map((p) => p.rest_id),
        latestSequenceId: it.latest_message_sequence_id,
      };
      if (id && it.latest_message_events?.length) {
        try {
          const cKeyMap = await buildCKeyMap(it.latest_conversation_key_change_events, id.userId, id.identityKey);
          const decoded = it.latest_message_events.map((b) => decodeMessageEvent(b, cKeyMap)).filter(Boolean);
          conv.latestMessage = decoded[decoded.length - 1] ?? null;
        } catch {}
      }
      return conv;
    }),
  );
}

export async function deleteMessages(recipient, sequenceIds, opts = {}) {
  const userId = String(this._xchat?.userId ?? this.user?.id ?? "");
  if (!userId) throw new Error("xchat.deleteMessages: log in first");
  const recipientId = await resolveUserId(this, recipient);
  const conversationId = conversationId1on1(userId, recipientId);
  const ids = (Array.isArray(sequenceIds) ? sequenceIds : [sequenceIds]).map(String);

  let action_signatures = null;
  if (opts.forEveryone) {
    const id = requireIdentity(this);
    const token = await conversationToken(this, conversationId, id);
    action_signatures = [
      await buildActionSignature({
        signingKey: id.signingKey,
        signingPublicKeyB64: id.signingPublicKeyB64,
        typeName: "MessageDeleteEvent",
        conversationToken: token,
        senderId: userId,
        conversationId,
        dataElements: ["2", ...ids], // 2 = DELETE_FOR_ALL, then each sequence id
        eventDetailBytes: deleteEventDetail(ids, 2),
        myVersion: id.version,
      }),
    ];
  }

  const res = await chatGql(this, "DeleteMessageMutation", {
    conversation_id: conversationId,
    sequence_ids: ids,
    delete_message_action: opts.forEveryone ? "DeleteForAll" : "DeleteForSelf",
    action_signatures,
  });
  return res?.data?.xchat_delete_messages ?? null;
}
