import { parseUri } from "/js/dataHelpers.js";
import { RefreshTokenError, auth } from "/js/auth.js";
import { TokenRefreshError as OauthRefreshTokenError } from "/js/oauth.js";
import { batch, buildQueryString, getCurrentTimestamp } from "/js/utils.js";
import { linkToLogin } from "/js/navigation.js";
import {
  PUBLIC_SERVICE_ENDPOINT_URL,
  BSKY_APPVIEW_SERVICE_DID,
  BSKY_CHAT_SERVICE_DID,
  BSKY_LABELER_DID,
  VIDEO_SERVICE_URL,
  VIDEO_SERVICE_DID,
} from "/js/config.js";

// Matches the header format in @atproto/api
function buildAcceptLabelersHeader(labelers) {
  return labelers
    .map((did) => (did === BSKY_LABELER_DID ? `${did};redact` : did))
    .join(", ");
}

export class ApiError extends Error {
  constructor(res) {
    const message = `${res.status} ${res.statusText}`;
    super(message);
    this.status = res.status;
    this.statusText = res.statusText;
    this.data = res.data;
    this.headers = res.headers;
    this.url = res.url;
  }
}

class PublicSession {
  constructor() {
    this.serviceEndpoint = PUBLIC_SERVICE_ENDPOINT_URL;
  }
  async fetch(url, options) {
    return fetch(url, options);
  }
  get did() {
    throw new Error("Public session does not have a DID");
  }
}

export class Api {
  constructor(
    session,
    {
      bskyAppViewServiceDid = BSKY_APPVIEW_SERVICE_DID,
      chatAppViewServiceDid = BSKY_CHAT_SERVICE_DID,
    } = {},
  ) {
    this.isAuthenticated = !!session;
    this.session = session ?? new PublicSession();
    this.bskyAppViewServiceDid = bskyAppViewServiceDid;
    this.chatAppViewServiceDid = chatAppViewServiceDid;
  }
  async request(path, options = {}) {
    const {
      body,
      query,
      method,
      headers = {},
      parseJson = true,
      stringifyBody = true,
      ...restOptions
    } = options;
    let queryString = "";
    if (query) {
      queryString = "?" + buildQueryString(query);
    }
    let res = null;
    try {
      const fetchOptions = {
        ...restOptions,
        method: method ?? "GET",
        headers: {
          "Content-Type": "application/json",
          ...headers,
        },
      };
      if (body) {
        if (stringifyBody) {
          fetchOptions.body = JSON.stringify(body);
        } else {
          fetchOptions.body = body;
        }
      }
      res = await this.session.fetch(
        `${this.session.serviceEndpoint}/xrpc/${path}${queryString}`,
        fetchOptions,
      );
    } catch (error) {
      // Handle token refresh error
      if (
        error instanceof RefreshTokenError ||
        error instanceof OauthRefreshTokenError
      ) {
        console.error("Token refresh error", error);
        const did = this.isAuthenticated ? (this.session.did ?? null) : null;
        await auth.logout(did);
        window.location.href = linkToLogin();
        await new Promise(() => {});
      }
      throw error;
    }
    let data = null;
    if (parseJson) {
      // If body was already consumed by the oauth library, use that
      data = res.data ?? (await res.json());
    }
    res.data = data;
    if (!res.ok) {
      throw new ApiError(res);
    }
    return res;
  }

  async createLikeRecord(post) {
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.like",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: { uri: post.uri, cid: post.cid },
        },
      },
    });
    return res.data;
  }

  async deleteLikeRecord(post) {
    const like = post.viewer.like;
    const rkey = like.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.like",
        rkey,
      },
    });
    return res.data;
  }

  async createRepostRecord(post) {
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.repost",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: { uri: post.uri, cid: post.cid },
        },
      },
    });
    return res.data;
  }

  async deleteRepostRecord(post) {
    const repost = post.viewer.repost;
    const rkey = repost.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.repost",
        rkey,
      },
    });
    return res.data;
  }

  async createBookmark(post) {
    const res = await this.request("app.bsky.bookmark.createBookmark", {
      method: "POST",
      body: {
        uri: post.uri,
        cid: post.cid,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res.data;
  }

  async deleteBookmark(post) {
    const res = await this.request("app.bsky.bookmark.deleteBookmark", {
      method: "POST",
      body: {
        uri: post.uri,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res.data;
  }

  async getDrafts({ cursor = "", limit } = {}) {
    const query = {};
    if (cursor) {
      query.cursor = cursor;
    }
    if (limit) {
      query.limit = limit;
    }
    const res = await this.request("app.bsky.draft.getDrafts", {
      query,
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async createDraft(draft) {
    const res = await this.request("app.bsky.draft.createDraft", {
      method: "POST",
      body: { draft },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async updateDraft(id, draft) {
    const res = await this.request("app.bsky.draft.updateDraft", {
      method: "POST",
      body: { draft: { id, draft } },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res.data;
  }

  async deleteDraft(id) {
    const res = await this.request("app.bsky.draft.deleteDraft", {
      method: "POST",
      body: { id },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res.data;
  }

  async createFollowRecord(profile) {
    const res = await this.request(`com.atproto.repo.createRecord`, {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.follow",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: profile.did,
        },
      },
    });
    return res.data;
  }

  async deleteFollowRecord(profile) {
    const follow = profile.viewer.following;
    const rkey = follow.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.follow",
        rkey,
      },
    });
    return res.data;
  }

  async createListItemRecord(listUri, subjectDid) {
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.listitem",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: subjectDid,
          list: listUri,
        },
      },
    });
    return res.data;
  }

  async deleteListItemRecord(listItemUri) {
    const rkey = listItemUri.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.listitem",
        rkey,
      },
    });
    return res.data;
  }

  async getListItems({ limit = 100, cursor = "" } = {}) {
    const query = {
      repo: this.session.did,
      collection: "app.bsky.graph.listitem",
      limit,
    };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("com.atproto.repo.listRecords", { query });
    return res.data;
  }

  async getPostThread(postUri, { labelers = [], depth = 6 } = {}) {
    const res = await this.request(`app.bsky.feed.getPostThread`, {
      query: {
        uri: postUri,
        depth,
        parentHeight: 1000, // max height, just so we don't set the wrong reply root by accident. This should be really rare - the default is 80.
      },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.thread;
  }

  async getPostThreadOther(postUri, { labelers = [] } = {}) {
    const res = await this.request(`app.bsky.unspecced.getPostThreadOtherV2`, {
      query: { anchor: postUri },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
      },
    });
    return res.data.thread;
  }

  async getFeed(feedURI, { limit = 31, cursor = "", labelers = [] } = {}) {
    const res = await this.request(`app.bsky.feed.getFeed`, {
      query: {
        feed: feedURI,
        limit,
        cursor,
      },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getFeedGenerator(feedURI) {
    const res = await this.request(`app.bsky.feed.getFeedGenerator`, {
      query: { feed: feedURI },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.view; // note- returning the view object.
  }

  async getFeedGenerators(feedURIs) {
    const res = await this.request(`app.bsky.feed.getFeedGenerators`, {
      query: { feeds: feedURIs },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.feeds;
  }

  async getStarterPack(starterPackURI) {
    const res = await this.request(`app.bsky.graph.getStarterPack`, {
      query: { starterPack: starterPackURI },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.starterPack;
  }

  async getList(listURI, { limit = 1, cursor = "" } = {}) {
    const query = { list: listURI, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request(`app.bsky.graph.getList`, {
      query,
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getListFeed(listURI, { limit = 31, cursor = "", labelers = [] } = {}) {
    const query = { list: listURI, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request(`app.bsky.feed.getListFeed`, {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getActorFeeds(did, { limit = 50, cursor = "" } = {}) {
    const query = { actor: did, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request(`app.bsky.feed.getActorFeeds`, {
      query,
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getListsWithMembership(actor, { limit = 50, cursor = "" } = {}) {
    const query = { actor, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getListsWithMembership", {
      query,
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getActorLists(did, { limit = 50, cursor = "" } = {}) {
    const query = { actor: did, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request(`app.bsky.graph.getLists`, {
      query,
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async searchFeedGenerators(query, { limit = 15, cursor = "" } = {}) {
    const queryParams = { limit, query };
    if (cursor) {
      queryParams.cursor = cursor;
    }
    const res = await this.request(
      `app.bsky.unspecced.getPopularFeedGenerators`,
      {
        query: queryParams,
        headers: {
          "atproto-proxy": this.bskyAppViewServiceDid,
        },
      },
    );
    return res.data;
  }

  async getFollowingFeed({ limit = 31, cursor = "", labelers = [] } = {}) {
    const res = await this.request(`app.bsky.feed.getTimeline`, {
      query: { limit, cursor },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getPosts(postURIs, { labelers = [] } = {}) {
    const batches = batch(postURIs, 25);
    let posts = [];
    for (const batch of batches) {
      const res = await this.request(`app.bsky.feed.getPosts`, {
        query: { uris: batch },
        headers: {
          "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
          "atproto-proxy": this.bskyAppViewServiceDid,
        },
      });
      posts.push(...res.data.posts);
    }
    return posts;
  }

  async getPost(postUri, { labelers = [] } = {}) {
    // todo - individual API call?
    const posts = await this.getPosts([postUri], { labelers });
    if (posts.length === 0) {
      throw new Error(`Post not found: ${postUri}`);
    }
    return posts[0];
  }

  async getRecord(uri) {
    const { repo, rkey, collection } = parseUri(uri);
    const res = await this.request(`com.atproto.repo.getRecord`, {
      query: {
        repo,
        collection,
        rkey,
      },
    });
    return res.data;
  }

  async getRepost(repostUri) {
    return this.getRecord(repostUri);
  }

  async getReposts(repostUris) {
    const reposts = [];
    // Batch to avoid rate limiting
    const batches = batch(repostUris, 5);
    for (const batch of batches) {
      const results = await Promise.allSettled(
        batch.map((repostUri) => this.getRepost(repostUri)),
      );
      // Only keep successful responses. This is similar to getPosts()
      const successfulResponses = results
        .filter((result) => result.status === "fulfilled")
        .map((result) => result.value);
      reposts.push(...successfulResponses);
    }
    return reposts;
  }

  async getProfile(did, { labelers = [] } = {}) {
    const res = await this.request(`app.bsky.actor.getProfile`, {
      query: { actor: did },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getProfiles(dids, { labelers = [] } = {}) {
    const results = await Promise.all(
      batch(dids, 25).map((chunk) =>
        this.request(`app.bsky.actor.getProfiles`, {
          query: { actors: chunk },
          headers: {
            "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
            "atproto-proxy": this.bskyAppViewServiceDid,
          },
        }),
      ),
    );
    return results.flatMap((res) => res.data.profiles);
  }

  async searchProfiles(query, { limit = 10, cursor = "", labelers = [] } = {}) {
    const queryParams = { q: query, limit };
    if (cursor) {
      queryParams.cursor = cursor;
    }
    const res = await this.request(`app.bsky.actor.searchActors`, {
      query: queryParams,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async searchProfilesTypeahead(query, { limit = 12, labelers = [] } = {}) {
    const res = await this.request(`app.bsky.actor.searchActorsTypeahead`, {
      query: { q: query, limit },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async searchPosts(
    query,
    { limit = 25, sort = "top", cursor = "", labelers = [] } = {},
  ) {
    const queryParams = { q: query, limit, sort };
    if (cursor) {
      queryParams.cursor = cursor;
    }
    const res = await this.request(`app.bsky.feed.searchPosts`, {
      query: queryParams,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async sendInteractions(interactions, feedProxyUrl) {
    await this.request(`app.bsky.feed.sendInteractions`, {
      method: "POST",
      body: { interactions },
      headers: {
        "atproto-proxy": feedProxyUrl,
      },
      parseJson: false, // third-party feed might not return JSON
    });
  }

  async getAuthorFeed(
    did,
    {
      limit = 31,
      cursor = "",
      filter = "posts_and_author_threads",
      includePins = false,
      labelers = [],
    } = {},
  ) {
    const res = await this.request(`app.bsky.feed.getAuthorFeed`, {
      query: { actor: did, limit, cursor, filter, includePins },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getActorLikes(did, { limit = 31, cursor = "", labelers = [] } = {}) {
    const query = { actor: did, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request(`app.bsky.feed.getActorLikes`, {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getPreferences() {
    const res = await this.request(`app.bsky.actor.getPreferences`); // note - no atproto-proxy for this endpoint
    return res.data.preferences;
  }

  async updatePreferences(preferencesObj) {
    const res = await this.request(`app.bsky.actor.putPreferences`, {
      method: "POST",
      body: { preferences: preferencesObj },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res;
  }

  async getLabelers(labelerDids) {
    const res = await this.request(`app.bsky.labeler.getServices`, {
      query: { dids: labelerDids, detailed: true },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.views;
  }

  async getLabeler(labelerDid) {
    const labelers = await this.getLabelers([labelerDid]);
    return labelers[0];
  }

  async getSession() {
    const res = await this.request("com.atproto.server.getSession", {
      method: "GET",
    });
    return res.data;
  }

  async getNumNotifications() {
    const res = await this.request("app.bsky.notification.getUnreadCount", {
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data.count;
  }

  async getNotifications({ cursor, limit = 31, reasons, labelers = [] } = {}) {
    const query = { cursor: cursor ?? "", limit };
    if (reasons?.length) {
      query.reasons = reasons;
    }
    const res = await this.request("app.bsky.notification.listNotifications", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async markNotificationsAsRead() {
    await this.request("app.bsky.notification.updateSeen", {
      method: "POST",
      body: { seenAt: getCurrentTimestamp() },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
  }

  async listConvos({
    cursor,
    limit = 30,
    readState,
    status,
    labelers = [],
  } = {}) {
    const query = { limit };
    if (cursor) {
      query.cursor = cursor;
    }
    if (readState) {
      query.readState = readState;
    }
    if (status) {
      query.status = status;
    }
    const res = await this.request("chat.bsky.convo.listConvos", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getConvo(convoId, { labelers = [] } = {}) {
    const res = await this.request("chat.bsky.convo.getConvo", {
      query: { convoId },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getConvoMembers(convoId, { cursor, limit = 50, labelers = [] } = {}) {
    const query = { convoId, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("chat.bsky.convo.getConvoMembers", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getMessages(convoId, { cursor, limit = 50, labelers = [] } = {}) {
    const query = { convoId, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("chat.bsky.convo.getMessages", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async sendMessage(convoId, { text, facets, replyTo, embed }) {
    const message = { text, facets };
    if (replyTo) {
      message.replyTo = replyTo;
    }
    if (embed) {
      message.embed = embed;
    }
    const res = await this.request("chat.bsky.convo.sendMessage", {
      method: "POST",
      body: {
        convoId,
        message,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async acceptConvo(convoId) {
    const res = await this.request("chat.bsky.convo.acceptConvo", {
      method: "POST",
      body: {
        convoId,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async leaveConvo(convoId) {
    const res = await this.request("chat.bsky.convo.leaveConvo", {
      method: "POST",
      body: {
        convoId,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getConvoAvailability(memberDids, { labelers = [] } = {}) {
    const res = await this.request("chat.bsky.convo.getConvoAvailability", {
      query: { members: memberDids },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getConvoForMembers(memberDids, { labelers = [] } = {}) {
    const res = await this.request("chat.bsky.convo.getConvoForMembers", {
      query: { members: memberDids },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getChatLogs({ cursor, labelers = [] }) {
    const res = await this.request("chat.bsky.convo.getLog", {
      query: { cursor },
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async markConvoAsRead(convoId) {
    await this.request("chat.bsky.convo.updateRead", {
      method: "POST",
      body: {
        convoId,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
  }

  async getChatUnreadCounts({ includeGroupChats = true } = {}) {
    const res = await this.request("chat.bsky.convo.getUnreadCounts", {
      query: { includeGroupChats },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async addMessageReaction(convoId, messageId, emoji) {
    const res = await this.request("chat.bsky.convo.addReaction", {
      method: "POST",
      body: {
        convoId,
        messageId,
        value: emoji,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data.message;
  }

  async removeMessageReaction(convoId, messageId, emoji) {
    const res = await this.request("chat.bsky.convo.removeReaction", {
      method: "POST",
      body: {
        convoId,
        messageId,
        value: emoji,
      },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data.message;
  }

  async getJoinLinkPreviews(codes) {
    const res = await this.request("chat.bsky.group.getJoinLinkPreviews", {
      query: { codes },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async requestJoinGroupChat(code) {
    const res = await this.request("chat.bsky.group.requestJoin", {
      method: "POST",
      body: { code },
      headers: {
        "atproto-proxy": this.chatAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getLikes(postUri, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { uri: postUri, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.feed.getLikes", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getQuotes(postUri, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { uri: postUri, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.feed.getQuotes", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getRepostedBy(postUri, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { uri: postUri, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.feed.getRepostedBy", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getBookmarks({ limit = 31, cursor, labelers = [] } = {}) {
    const query = { limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.bookmark.getBookmarks", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getFollowers(actor, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { actor, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getFollowers", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getKnownFollowers(actor, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { actor, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getKnownFollowers", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getFollows(actor, { limit = 50, cursor, labelers = [] } = {}) {
    const query = { actor, limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getFollows", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async putActivitySubscription(did, activitySubscription) {
    const res = await this.request(
      "app.bsky.notification.putActivitySubscription",
      {
        method: "POST",
        body: {
          subject: did,
          activitySubscription,
        },
        headers: {
          "atproto-proxy": this.bskyAppViewServiceDid,
        },
      },
    );
    return res.data;
  }

  async muteActor(did) {
    const res = await this.request("app.bsky.graph.muteActor", {
      method: "POST",
      body: {
        actor: did,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res;
  }

  async unmuteActor(did) {
    const res = await this.request("app.bsky.graph.unmuteActor", {
      method: "POST",
      body: {
        actor: did,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res;
  }

  async blockActor(profile) {
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.block",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: profile.did,
        },
      },
    });
    return res.data;
  }

  async getBlocks({ limit = 50, cursor, labelers = [] } = {}) {
    const query = { limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getBlocks", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async getMutes({ limit = 50, cursor, labelers = [] } = {}) {
    const query = { limit };
    if (cursor) {
      query.cursor = cursor;
    }
    const res = await this.request("app.bsky.graph.getMutes", {
      query,
      headers: {
        "atproto-accept-labelers": buildAcceptLabelersHeader(labelers),
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
    });
    return res.data;
  }

  async unblockActor(profile) {
    const block = profile.viewer.blocking;
    const rkey = block.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.block",
        rkey,
      },
    });
    return res.data;
  }

  async muteModList(listUri) {
    const res = await this.request("app.bsky.graph.muteActorList", {
      method: "POST",
      body: {
        list: listUri,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res;
  }

  async unmuteModList(listUri) {
    const res = await this.request("app.bsky.graph.unmuteActorList", {
      method: "POST",
      body: {
        list: listUri,
      },
      headers: {
        "atproto-proxy": this.bskyAppViewServiceDid,
      },
      parseJson: false,
    });
    return res;
  }

  async blockModList(listUri) {
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.listblock",
        record: {
          createdAt: getCurrentTimestamp(),
          subject: listUri,
        },
      },
    });
    return res.data;
  }

  async unblockModList(blockUri) {
    const rkey = blockUri.split("/").pop();
    const res = await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.graph.listblock",
        rkey,
      },
    });
    return res.data;
  }

  async createPost({ text, facets, embed, reply, langs, labels = null }) {
    const record = {
      text,
      facets,
      createdAt: getCurrentTimestamp(),
      langs,
    };
    if (embed) {
      record.embed = embed;
    }
    if (reply) {
      record.reply = reply;
    }
    if (labels) {
      record.labels = labels;
    }
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.post",
        record,
      },
    });
    return res.data;
  }

  // Threadgate and postgate records use the same rkey as the post they gate
  async createThreadgate(postUri, allow) {
    const { rkey } = parseUri(postUri);
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.threadgate",
        rkey,
        record: {
          $type: "app.bsky.feed.threadgate",
          post: postUri,
          allow,
          createdAt: getCurrentTimestamp(),
        },
      },
    });
    return res.data;
  }

  async createPostgate(postUri, embeddingRules) {
    const { rkey } = parseUri(postUri);
    const res = await this.request("com.atproto.repo.createRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.postgate",
        rkey,
        record: {
          $type: "app.bsky.feed.postgate",
          post: postUri,
          embeddingRules,
          createdAt: getCurrentTimestamp(),
        },
      },
    });
    return res.data;
  }

  async deletePost(post) {
    const { rkey } = parseUri(post.uri);
    await this.request("com.atproto.repo.deleteRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.feed.post",
        rkey,
      },
    });
  }

  async uploadBlob(blob) {
    const res = await this.request("com.atproto.repo.uploadBlob", {
      method: "POST",
      headers: {
        "Content-Type": blob.type,
      },
      body: blob,
      stringifyBody: false,
    });
    return res.data.blob;
  }

  async getServiceAuthToken({ aud, lxm, exp }) {
    const res = await this.request("com.atproto.server.getServiceAuth", {
      query: { aud, lxm, exp: exp ?? Math.floor(Date.now() / 1000) + 60 },
    });
    return res.data.token;
  }

  async serviceRequest(
    url,
    { token, method = "GET", query, body, headers = {} } = {},
  ) {
    let queryString = "";
    if (query) {
      queryString = "?" + buildQueryString(query);
    }
    const res = await fetch(`${url}${queryString}`, {
      method,
      headers: {
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...headers,
      },
      body,
    });
    const data = await res.json();
    res.data = data;
    if (!res.ok) {
      throw new ApiError(res);
    }
    return res;
  }

  async getVideoUploadLimits() {
    const token = await this.getServiceAuthToken({
      aud: VIDEO_SERVICE_DID,
      lxm: "app.bsky.video.getUploadLimits",
    });
    const res = await this.serviceRequest(
      `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.getUploadLimits`,
      { token },
    );
    return res.data;
  }

  async uploadVideoBlob(file) {
    const pdsHostname = new URL(this.session.serviceEndpoint).hostname;
    const token = await this.getServiceAuthToken({
      aud: `did:web:${pdsHostname}`,
      lxm: "com.atproto.repo.uploadBlob",
      exp: Math.floor(Date.now() / 1000) + 60 * 30,
    });
    try {
      const res = await this.serviceRequest(
        `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.uploadVideo`,
        {
          token,
          method: "POST",
          query: { did: this.session.did, name: file.name },
          headers: { "Content-Type": file.type },
          body: file,
        },
      );
      return res.data;
    } catch (error) {
      // If the same video has been uploaded before, the service returns 409
      // Treat this as a success
      if (
        error instanceof ApiError &&
        error.data?.error === "already_exists" &&
        error.data.jobId
      ) {
        return error.data;
      }
      throw error;
    }
  }

  async getVideoJobStatus(jobId) {
    const res = await this.serviceRequest(
      `${VIDEO_SERVICE_URL}/xrpc/app.bsky.video.getJobStatus`,
      { query: { jobId } },
    );
    return res.data.jobStatus;
  }

  async getProfileRecord() {
    const res = await this.request("com.atproto.repo.getRecord", {
      query: {
        repo: this.session.did,
        collection: "app.bsky.actor.profile",
        rkey: "self",
      },
    });
    return res.data;
  }

  async putProfileRecord(record, swapRecord) {
    const res = await this.request("com.atproto.repo.putRecord", {
      method: "POST",
      body: {
        repo: this.session.did,
        collection: "app.bsky.actor.profile",
        rkey: "self",
        record: {
          $type: "app.bsky.actor.profile",
          ...record,
        },
        swapRecord: swapRecord ?? null,
      },
    });
    return res.data;
  }

  async createModerationReport({ reasonType, reason, subject, labelerDid }) {
    const body = {
      reasonType,
      subject,
    };
    // Reason is optional
    if (reason) {
      body.reason = reason;
    }
    const res = await this.request("com.atproto.moderation.createReport", {
      method: "POST",
      body,
      headers: {
        "atproto-proxy": `${labelerDid}#atproto_labeler`,
      },
    });
    return res.data;
  }
}
