import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import {
  MockFetch,
  mockWindowLocation,
  restoreWindow,
} from "../testHelpers.js";
import { Api, ApiError } from "/js/api.js";
import { auth } from "/js/auth.js";
import { TokenRefreshError } from "/js/oauth.js";

function createMockSession(mockResponse = {}) {
  let lastFetchOptions = null;
  return {
    serviceEndpoint: "https://test.example.com",
    did: "did:plc:testuser",
    fetch: async (url, options) => {
      lastFetchOptions = { url, options };
      return {
        ok: true,
        json: async () => mockResponse,
      };
    },
    getLastFetchOptions: () => lastFetchOptions,
  };
}

describe("request", () => {
  it("should construct URL with path", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method");

    const { url } = session.getLastFetchOptions();
    assert.deepEqual(url, "https://test.example.com/xrpc/com.example.method");
  });

  it("should append query string to URL", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method", {
      query: { foo: "bar", baz: "qux" },
    });

    const { url } = session.getLastFetchOptions();
    assert.deepEqual(
      url,
      "https://test.example.com/xrpc/com.example.method?foo=bar&baz=qux",
    );
  });

  it("should default to GET method", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method");

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.method, "GET");
  });

  it("should use specified method", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method", { method: "POST" });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.method, "POST");
  });

  it("should include Content-Type header by default", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method");

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.headers["Content-Type"], "application/json");
  });

  it("should merge custom headers", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method", {
      headers: { "X-Custom-Header": "custom-value" },
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.headers["Content-Type"], "application/json");
    assert.deepEqual(options.headers["X-Custom-Header"], "custom-value");
  });

  it("should stringify body by default", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.request("com.example.method", {
      method: "POST",
      body: { key: "value" },
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.body, '{"key":"value"}');
  });

  it("should not stringify body when stringifyBody is false", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const rawBody = new Uint8Array([1, 2, 3]);

    await api.request("com.example.method", {
      method: "POST",
      body: rawBody,
      stringifyBody: false,
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.body, rawBody);
  });

  it("should parse JSON response by default", async () => {
    const session = createMockSession({ result: "success" });
    const api = new Api(session);

    const res = await api.request("com.example.method");

    assert.deepEqual(res.data, { result: "success" });
  });

  it("should not parse JSON when parseJson is false", async () => {
    const session = createMockSession({ result: "success" });
    const api = new Api(session);

    const res = await api.request("com.example.method", { parseJson: false });

    assert.deepEqual(res.data, null);
  });

  it("should throw ApiError when response is not ok", async () => {
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      fetch: async () => ({
        ok: false,
        status: 400,
        statusText: "Bad Request",
        json: async () => ({ error: "InvalidRequest" }),
      }),
    };
    const api = new Api(session);

    let thrownError = null;
    try {
      await api.request("com.example.method");
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError instanceof ApiError);
    assert.deepEqual(thrownError.status, 400);
  });

  it("should expose error data and status text on ApiError for non-200", async () => {
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      fetch: async () => ({
        ok: false,
        status: 500,
        statusText: "Internal Server Error",
        json: async () => ({ error: "InternalServerError", message: "boom" }),
      }),
    };
    const api = new Api(session);

    let thrownError = null;
    try {
      await api.request("com.example.method");
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError instanceof ApiError);
    assert.deepEqual(thrownError.status, 500);
    assert.deepEqual(thrownError.statusText, "Internal Server Error");
    assert.deepEqual(thrownError.data.error, "InternalServerError");
  });

  it("should re-throw non-refresh errors raised during fetch", async () => {
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      fetch: async () => {
        throw new Error("network down");
      },
    };
    const api = new Api(session);

    let thrownError = null;
    try {
      await api.request("com.example.method");
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError !== null);
    assert.deepEqual(thrownError.message, "network down");
  });

  it("should inject atproto-proxy header on AppView-routed requests", async () => {
    const session = createMockSession({
      did: "did:plc:test",
      handle: "test.user",
    });
    const api = new Api(session);

    await api.getProfile("did:plc:test");

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:web:api.bsky.app#bsky_appview",
    );
  });

  it("should use response data set by oauth library without re-reading", async () => {
    const cachedData = { result: "from-oauth-lib" };
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      fetch: async () => ({
        ok: true,
        data: cachedData,
        json: async () => {
          throw new Error("should not be called");
        },
      }),
    };
    const api = new Api(session);

    const res = await api.request("com.example.method");

    assert.deepEqual(res.data, cachedData);
  });
});

describe("token refresh failure", () => {
  const originalLogout = auth.logout;

  beforeEach(() => {
    auth.logout = mock.fn(() => Promise.resolve());
  });

  afterEach(() => {
    auth.logout = originalLogout;
    restoreWindow();
  });

  it("soft-logs-out and redirects to login", async () => {
    const capturedHrefs = mockWindowLocation("");
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      handle: "alice.bsky.social",
      fetch: async () => {
        throw new TokenRefreshError("refresh failed");
      },
    };
    const api = new Api(session);

    // The request never settles after the redirect, so don't await it
    api.request("com.example.method").catch(() => {});
    for (let i = 0; i < 50 && capturedHrefs.length === 0; i++) {
      await Promise.resolve();
    }

    assert.deepEqual(auth.logout.mock.callCount(), 1);
    assert.deepEqual(
      auth.logout.mock.calls[0].arguments[0],
      "did:plc:testuser",
    );
    assert.deepEqual(capturedHrefs.at(-1), "/login");
  });
});

describe("service DID in requests", () => {
  it("should use custom bskyAppViewServiceDid in atproto-proxy header", async () => {
    const session = createMockSession({
      did: "did:plc:test",
      handle: "test.user",
    });
    const customDid = "did:web:custom.bsky.app#custom_appview";
    const api = new Api(session, { bskyAppViewServiceDid: customDid });

    await api.getProfile("did:plc:test");

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.headers["atproto-proxy"], customDid);
  });

  it("should use custom chatAppViewServiceDid in atproto-proxy header", async () => {
    const session = createMockSession({ convos: [] });
    const customDid = "did:web:custom.chat#custom_chat";
    const api = new Api(session, { chatAppViewServiceDid: customDid });

    await api.listConvos();

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(options.headers["atproto-proxy"], customDid);
  });
});

describe("createLikeRecord", () => {
  it("should create a like record with correct body", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.feed.like/abc123",
      cid: "cid123",
    });
    const api = new Api(session);
    const post = {
      uri: "at://did:plc:author/app.bsky.feed.post/xyz",
      cid: "postcid",
    };

    await api.createLikeRecord(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.createRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.feed.like");
    assert.deepEqual(body.record.subject.uri, post.uri);
    assert.deepEqual(body.record.subject.cid, post.cid);
  });
});

describe("deleteLikeRecord", () => {
  it("should delete a like record with correct rkey", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const post = {
      viewer: { like: "at://did:plc:testuser/app.bsky.feed.like/likerkey123" },
    };

    await api.deleteLikeRecord(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.deleteRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.feed.like");
    assert.deepEqual(body.rkey, "likerkey123");
  });
});

describe("createRepostRecord", () => {
  it("should create a repost record with correct body", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.feed.repost/abc123",
      cid: "cid123",
    });
    const api = new Api(session);
    const post = {
      uri: "at://did:plc:author/app.bsky.feed.post/xyz",
      cid: "postcid",
    };

    await api.createRepostRecord(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.createRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.feed.repost");
    assert.deepEqual(body.record.subject.uri, post.uri);
    assert.deepEqual(body.record.subject.cid, post.cid);
  });
});

describe("deleteRepostRecord", () => {
  it("should delete a repost record with correct rkey", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const post = {
      viewer: {
        repost: "at://did:plc:testuser/app.bsky.feed.repost/repostrkey123",
      },
    };

    await api.deleteRepostRecord(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.deleteRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.feed.repost");
    assert.deepEqual(body.rkey, "repostrkey123");
  });
});

describe("createBookmark", () => {
  it("should create a bookmark with correct body", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const post = {
      uri: "at://did:plc:author/app.bsky.feed.post/xyz",
      cid: "postcid",
    };

    await api.createBookmark(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.bookmark.createBookmark"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.uri, post.uri);
    assert.deepEqual(body.cid, post.cid);
  });
});

describe("deleteBookmark", () => {
  it("should delete a bookmark with correct body", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const post = { uri: "at://did:plc:author/app.bsky.feed.post/xyz" };

    await api.deleteBookmark(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.bookmark.deleteBookmark"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.uri, post.uri);
  });
});

describe("createFollowRecord", () => {
  it("should create a follow record with correct body", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.graph.follow/abc123",
      cid: "cid123",
    });
    const api = new Api(session);
    const profile = { did: "did:plc:targetuser" };

    await api.createFollowRecord(profile);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.createRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.graph.follow");
    assert.deepEqual(body.record.subject, "did:plc:targetuser");
  });
});

describe("deleteFollowRecord", () => {
  it("should delete a follow record with correct rkey", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const profile = {
      viewer: {
        following: "at://did:plc:testuser/app.bsky.graph.follow/followrkey123",
      },
    };

    await api.deleteFollowRecord(profile);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.deleteRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.graph.follow");
    assert.deepEqual(body.rkey, "followrkey123");
  });
});

describe("getPostThread", () => {
  it("should fetch post thread with correct query params", async () => {
    const session = createMockSession({ thread: { post: {} } });
    const api = new Api(session);

    await api.getPostThread("at://did:plc:author/app.bsky.feed.post/abc");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getPostThread"));
    assert(
      url.includes(
        "uri=at%3A%2F%2Fdid%3Aplc%3Aauthor%2Fapp.bsky.feed.post%2Fabc",
      ),
    );
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ thread: { post: {} } });
    const api = new Api(session);

    await api.getPostThread("at://did:plc:author/app.bsky.feed.post/abc", {
      labelers: ["did:plc:labeler1", "did:plc:labeler2"],
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1, did:plc:labeler2",
    );
  });
});

describe("getPostThreadOther", () => {
  it("should fetch post thread other with correct query params", async () => {
    const session = createMockSession({ thread: [] });
    const api = new Api(session);

    await api.getPostThreadOther("at://did:plc:author/app.bsky.feed.post/abc");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.unspecced.getPostThreadOtherV2"));
    assert(
      url.includes(
        "anchor=at%3A%2F%2Fdid%3Aplc%3Aauthor%2Fapp.bsky.feed.post%2Fabc",
      ),
    );
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ thread: [] });
    const api = new Api(session);

    await api.getPostThreadOther("at://did:plc:author/app.bsky.feed.post/abc", {
      labelers: ["did:plc:labeler1", "did:plc:labeler2"],
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1, did:plc:labeler2",
    );
  });
});

describe("getFeed", () => {
  it("should fetch feed with correct query params", async () => {
    const session = createMockSession({ feed: [], cursor: "nextcursor" });
    const api = new Api(session);

    const result = await api.getFeed(
      "at://did:plc:feed/app.bsky.feed.generator/myfeed",
    );

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getFeed"));
    assert(
      url.includes(
        "feed=at%3A%2F%2Fdid%3Aplc%3Afeed%2Fapp.bsky.feed.generator%2Fmyfeed",
      ),
    );
    assert.deepEqual(result.cursor, "nextcursor");
  });

  it("should use custom limit and cursor", async () => {
    const session = createMockSession({ feed: [] });
    const api = new Api(session);

    await api.getFeed("at://did:plc:feed/app.bsky.feed.generator/myfeed", {
      limit: 50,
      cursor: "somecursor",
    });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("limit=50"));
    assert(url.includes("cursor=somecursor"));
  });
});

describe("getFeedGenerator", () => {
  it("should fetch feed generator and return view", async () => {
    const session = createMockSession({
      view: { uri: "feeduri", displayName: "My Feed" },
    });
    const api = new Api(session);

    const result = await api.getFeedGenerator(
      "at://did:plc:feed/app.bsky.feed.generator/myfeed",
    );

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getFeedGenerator"));
    assert.deepEqual(result.displayName, "My Feed");
  });
});

describe("getFeedGenerators", () => {
  it("should fetch multiple feed generators", async () => {
    const session = createMockSession({
      feeds: [{ uri: "feed1" }, { uri: "feed2" }],
    });
    const api = new Api(session);

    const result = await api.getFeedGenerators(["feed1", "feed2"]);

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getFeedGenerators"));
    assert.deepEqual(result.length, 2);
  });
});

describe("getFollowingFeed", () => {
  it("should fetch timeline", async () => {
    const session = createMockSession({ feed: [], cursor: "nextcursor" });
    const api = new Api(session);

    const result = await api.getFollowingFeed();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getTimeline"));
    assert.deepEqual(result.cursor, "nextcursor");
  });
});

describe("getPosts", () => {
  it("should fetch posts by URIs", async () => {
    const session = createMockSession({
      posts: [{ uri: "post1" }, { uri: "post2" }],
    });
    const api = new Api(session);

    const result = await api.getPosts([
      "at://did:plc:a/app.bsky.feed.post/1",
      "at://did:plc:b/app.bsky.feed.post/2",
    ]);

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getPosts"));
    assert.deepEqual(result.length, 2);
  });
});

describe("getPost", () => {
  it("should fetch single post", async () => {
    const session = createMockSession({
      posts: [{ uri: "at://did:plc:a/app.bsky.feed.post/1", text: "hello" }],
    });
    const api = new Api(session);

    const result = await api.getPost("at://did:plc:a/app.bsky.feed.post/1");

    assert.deepEqual(result.text, "hello");
  });

  it("should throw error when post not found", async () => {
    const session = createMockSession({ posts: [] });
    const api = new Api(session);

    let thrownError = null;
    try {
      await api.getPost("at://did:plc:a/app.bsky.feed.post/notfound");
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError !== null);
    assert(thrownError.message.includes("Post not found"));
  });
});

describe("getRepost", () => {
  it("should fetch repost record", async () => {
    const session = createMockSession({
      uri: "at://did:plc:a/app.bsky.feed.repost/1",
      value: { subject: {} },
    });
    const api = new Api(session);

    const result = await api.getRepost("at://did:plc:a/app.bsky.feed.repost/1");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.getRecord"));
    assert.deepEqual(result.uri, "at://did:plc:a/app.bsky.feed.repost/1");
  });
});

describe("getProfile", () => {
  it("should fetch profile by DID", async () => {
    const session = createMockSession({
      did: "did:plc:test",
      handle: "test.bsky.social",
    });
    const api = new Api(session);

    const result = await api.getProfile("did:plc:test");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.actor.getProfile"));
    assert(url.includes("actor=did%3Aplc%3Atest"));
    assert.deepEqual(result.handle, "test.bsky.social");
  });
});

describe("searchProfiles", () => {
  it("should search profiles with query", async () => {
    const session = createMockSession({
      actors: [{ did: "did:plc:a" }, { did: "did:plc:b" }],
    });
    const api = new Api(session);

    const result = await api.searchProfiles("test");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.actor.searchActors"));
    assert(url.includes("q=test"));
    assert.deepEqual(result.actors.length, 2);
  });
});

describe("searchPosts", () => {
  it("should search posts with query", async () => {
    const session = createMockSession({
      posts: [{ uri: "post1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.searchPosts("hello");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.searchPosts"));
    assert(url.includes("q=hello"));
    assert.deepEqual(result.cursor, "next");
  });

  it("should include sort parameter", async () => {
    const session = createMockSession({ posts: [] });
    const api = new Api(session);

    await api.searchPosts("hello", { sort: "latest" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("sort=latest"));
  });
});

describe("sendInteractions", () => {
  it("should send interactions to feed proxy", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const interactions = [{ uri: "post1", event: "view" }];

    await api.sendInteractions(
      interactions,
      "did:web:feed.example.com#feed_proxy",
    );

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.sendInteractions"));
    assert.deepEqual(options.method, "POST");
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:web:feed.example.com#feed_proxy",
    );
  });
});

describe("getAuthorFeed", () => {
  it("should fetch author feed with filters", async () => {
    const session = createMockSession({ feed: [], cursor: "next" });
    const api = new Api(session);

    await api.getAuthorFeed("did:plc:author", { filter: "posts_no_replies" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getAuthorFeed"));
    assert(url.includes("actor=did%3Aplc%3Aauthor"));
    assert(url.includes("filter=posts_no_replies"));
  });
});

describe("getActorLikes", () => {
  it("should fetch actor likes", async () => {
    const session = createMockSession({ feed: [], cursor: "next" });
    const api = new Api(session);

    await api.getActorLikes("did:plc:user");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getActorLikes"));
    assert(url.includes("actor=did%3Aplc%3Auser"));
  });
});

describe("getPreferences", () => {
  it("should fetch preferences", async () => {
    const session = createMockSession({
      preferences: [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2" }],
    });
    const api = new Api(session);

    const result = await api.getPreferences();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.actor.getPreferences"));
    assert.deepEqual(result.length, 1);
  });
});

describe("updatePreferences", () => {
  it("should update preferences", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const prefs = [
      { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] },
    ];

    await api.updatePreferences(prefs);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.actor.putPreferences"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.preferences, prefs);
  });
});

describe("getLabelers", () => {
  it("should fetch labelers by DIDs", async () => {
    const session = createMockSession({
      views: [{ creator: { did: "did:plc:labeler1" } }],
    });
    const api = new Api(session);

    const result = await api.getLabelers(["did:plc:labeler1"]);

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.labeler.getServices"));
    assert(url.includes("detailed=true"));
    assert.deepEqual(result.length, 1);
  });
});

describe("getLabeler", () => {
  it("should fetch single labeler", async () => {
    const session = createMockSession({
      views: [{ creator: { did: "did:plc:labeler1" } }],
    });
    const api = new Api(session);

    const result = await api.getLabeler("did:plc:labeler1");

    assert.deepEqual(result.creator.did, "did:plc:labeler1");
  });
});

describe("getSession", () => {
  it("should fetch session info", async () => {
    const session = createMockSession({
      did: "did:plc:testuser",
      handle: "test.bsky.social",
    });
    const api = new Api(session);

    const result = await api.getSession();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.server.getSession"));
    assert.deepEqual(result.did, "did:plc:testuser");
  });
});

describe("getNumNotifications", () => {
  it("should fetch unread notification count", async () => {
    const session = createMockSession({ count: 5 });
    const api = new Api(session);

    const result = await api.getNumNotifications();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.notification.getUnreadCount"));
    assert.deepEqual(result, 5);
  });
});

describe("getChatUnreadCounts", () => {
  it("should fetch chat unread counts via the chat proxy", async () => {
    const session = createMockSession({
      unreadAcceptedConvos: 4,
      unreadRequestConvos: 1,
    });
    const api = new Api(session);

    const result = await api.getChatUnreadCounts();

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getUnreadCounts"));
    assert(url.includes("includeGroupChats=true"));
    assert.deepEqual(
      options.headers["atproto-proxy"],
      api.chatAppViewServiceDid,
    );
    assert.deepEqual(result.unreadAcceptedConvos, 4);
    assert.deepEqual(result.unreadRequestConvos, 1);
  });
});

describe("getNotifications", () => {
  it("should fetch notifications", async () => {
    const session = createMockSession({
      notifications: [{ uri: "notif1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getNotifications();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.notification.listNotifications"));
    assert.deepEqual(result.notifications.length, 1);
  });
});

describe("markNotificationsAsRead", () => {
  it("should mark notifications as seen", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.markNotificationsAsRead();

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.notification.updateSeen"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert(body.seenAt !== undefined);
  });
});

describe("listConvos", () => {
  it("should list conversations", async () => {
    const session = createMockSession({
      convos: [{ id: "convo1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.listConvos();

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.listConvos"));
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:web:api.bsky.chat#bsky_chat",
    );
    assert.deepEqual(result.convos.length, 1);
  });

  it("should include readState filter when provided", async () => {
    const session = createMockSession({ convos: [] });
    const api = new Api(session);

    await api.listConvos({ readState: "unread" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("readState=unread"));
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ convos: [] });
    const api = new Api(session);

    await api.listConvos({ labelers: ["did:plc:labeler1"] });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("getConvo", () => {
  it("should fetch conversation by ID", async () => {
    const session = createMockSession({ convo: { id: "convo1", members: [] } });
    const api = new Api(session);

    const result = await api.getConvo("convo1");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getConvo"));
    assert(url.includes("convoId=convo1"));
    assert.deepEqual(result.convo.id, "convo1");
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ convo: { id: "convo1", members: [] } });
    const api = new Api(session);

    await api.getConvo("convo1", { labelers: ["did:plc:labeler1"] });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("getMessages", () => {
  it("should fetch messages for conversation", async () => {
    const session = createMockSession({
      messages: [{ id: "msg1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getMessages("convo1");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getMessages"));
    assert(url.includes("convoId=convo1"));
    assert.deepEqual(result.messages.length, 1);
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ messages: [] });
    const api = new Api(session);

    await api.getMessages("convo1", { labelers: ["did:plc:labeler1"] });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("sendMessage", () => {
  it("should send message to conversation", async () => {
    const session = createMockSession({ id: "msg1", text: "hello" });
    const api = new Api(session);

    await api.sendMessage("convo1", { text: "hello", facets: [] });

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.sendMessage"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
    assert.deepEqual(body.message.text, "hello");
    assert.deepEqual(body.message.embed, undefined);
  });

  it("should include embed when provided", async () => {
    const session = createMockSession({ id: "msg1", text: "hello" });
    const api = new Api(session);

    const embed = {
      $type: "app.bsky.embed.record",
      record: { uri: "at://did:plc:abc/app.bsky.feed.post/3abc", cid: "cid1" },
    };
    await api.sendMessage("convo1", { text: "hello", facets: [], embed });

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert.deepEqual(body.message.embed, embed);
  });

  it("should omit embed when null", async () => {
    const session = createMockSession({ id: "msg1", text: "hello" });
    const api = new Api(session);

    await api.sendMessage("convo1", { text: "hello", facets: [], embed: null });

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert.deepEqual(body.message.embed, undefined);
  });
});

describe("acceptConvo", () => {
  it("should accept conversation", async () => {
    const session = createMockSession({ convo: { id: "convo1" } });
    const api = new Api(session);

    await api.acceptConvo("convo1");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.acceptConvo"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
  });
});

describe("leaveConvo", () => {
  it("should leave conversation", async () => {
    const session = createMockSession({ convo: { id: "convo1" } });
    const api = new Api(session);

    await api.leaveConvo("convo1");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.leaveConvo"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
  });
});

describe("getConvoAvailability", () => {
  it("should check conversation availability", async () => {
    const session = createMockSession({ canChat: true });
    const api = new Api(session);

    const result = await api.getConvoAvailability(["did:plc:member1"]);

    const { url } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getConvoAvailability"));
    assert.deepEqual(result.canChat, true);
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ canChat: true });
    const api = new Api(session);

    await api.getConvoAvailability(["did:plc:member1"], {
      labelers: ["did:plc:labeler1"],
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("getConvoForMembers", () => {
  it("should get or create conversation for members", async () => {
    const session = createMockSession({ convo: { id: "convo1" } });
    const api = new Api(session);

    const result = await api.getConvoForMembers([
      "did:plc:member1",
      "did:plc:member2",
    ]);

    const { url } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getConvoForMembers"));
    assert.deepEqual(result.convo.id, "convo1");
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ convo: { id: "convo1" } });
    const api = new Api(session);

    await api.getConvoForMembers(["did:plc:member1"], {
      labelers: ["did:plc:labeler1"],
    });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("getChatLogs", () => {
  it("should fetch chat logs", async () => {
    const session = createMockSession({ logs: [], cursor: "next" });
    const api = new Api(session);

    const result = await api.getChatLogs({ cursor: "somecursor" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.getLog"));
    assert(url.includes("cursor=somecursor"));
    assert.deepEqual(result.cursor, "next");
  });

  it("should include labelers header when provided", async () => {
    const session = createMockSession({ logs: [] });
    const api = new Api(session);

    await api.getChatLogs({ cursor: "", labelers: ["did:plc:labeler1"] });

    const { options } = session.getLastFetchOptions();
    assert.deepEqual(
      options.headers["atproto-accept-labelers"],
      "did:plc:labeler1",
    );
  });
});

describe("markConvoAsRead", () => {
  it("should mark conversation as read", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.markConvoAsRead("convo1");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.updateRead"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
  });
});

describe("addMessageReaction", () => {
  it("should add reaction to message", async () => {
    const session = createMockSession({
      message: { id: "msg1", reactions: [] },
    });
    const api = new Api(session);

    await api.addMessageReaction("convo1", "msg1", "👍");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.addReaction"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
    assert.deepEqual(body.messageId, "msg1");
    assert.deepEqual(body.value, "👍");
  });
});

describe("removeMessageReaction", () => {
  it("should remove reaction from message", async () => {
    const session = createMockSession({
      message: { id: "msg1", reactions: [] },
    });
    const api = new Api(session);

    await api.removeMessageReaction("convo1", "msg1", "👍");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("chat.bsky.convo.removeReaction"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.convoId, "convo1");
    assert.deepEqual(body.messageId, "msg1");
    assert.deepEqual(body.value, "👍");
  });
});

describe("getLikes", () => {
  it("should fetch likes for a post", async () => {
    const session = createMockSession({
      likes: [{ actor: { did: "did:plc:a" } }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getLikes(
      "at://did:plc:author/app.bsky.feed.post/abc",
    );

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getLikes"));
    assert.deepEqual(result.likes.length, 1);
  });
});

describe("getQuotes", () => {
  it("should fetch quotes for a post", async () => {
    const session = createMockSession({
      posts: [{ uri: "quote1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getQuotes(
      "at://did:plc:author/app.bsky.feed.post/abc",
    );

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getQuotes"));
    assert.deepEqual(result.posts.length, 1);
  });
});

describe("getRepostedBy", () => {
  it("should fetch users who reposted", async () => {
    const session = createMockSession({
      repostedBy: [{ did: "did:plc:a" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getRepostedBy(
      "at://did:plc:author/app.bsky.feed.post/abc",
    );

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getRepostedBy"));
    assert.deepEqual(result.repostedBy.length, 1);
  });
});

describe("getBookmarks", () => {
  it("should fetch bookmarks", async () => {
    const session = createMockSession({
      bookmarks: [{ uri: "post1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getBookmarks();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.bookmark.getBookmarks"));
    assert.deepEqual(result.bookmarks.length, 1);
  });
});

describe("getFollowers", () => {
  it("should fetch followers for actor", async () => {
    const session = createMockSession({
      followers: [{ did: "did:plc:a" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getFollowers("did:plc:user");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.getFollowers"));
    assert(url.includes("actor=did%3Aplc%3Auser"));
    assert.deepEqual(result.followers.length, 1);
  });
});

describe("getFollows", () => {
  it("should fetch follows for actor", async () => {
    const session = createMockSession({
      follows: [{ did: "did:plc:a" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getFollows("did:plc:user");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.getFollows"));
    assert(url.includes("actor=did%3Aplc%3Auser"));
    assert.deepEqual(result.follows.length, 1);
  });
});

describe("getMutes", () => {
  it("should fetch muted accounts", async () => {
    const session = createMockSession({
      mutes: [{ did: "did:plc:a" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getMutes();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.getMutes"));
    assert(url.includes("limit=50"));
    assert.deepEqual(result.mutes.length, 1);
    assert.deepEqual(result.cursor, "next");
  });

  it("should pass cursor when provided", async () => {
    const session = createMockSession({ mutes: [], cursor: "" });
    const api = new Api(session);

    await api.getMutes({ cursor: "abc" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("cursor=abc"));
  });
});

describe("muteActor", () => {
  it("should mute actor", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.muteActor("did:plc:target");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.muteActor"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.actor, "did:plc:target");
  });
});

describe("unmuteActor", () => {
  it("should unmute actor", async () => {
    const session = createMockSession({});
    const api = new Api(session);

    await api.unmuteActor("did:plc:target");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.unmuteActor"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.actor, "did:plc:target");
  });
});

describe("blockActor", () => {
  it("should create block record", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.graph.block/abc",
    });
    const api = new Api(session);
    const profile = { did: "did:plc:target" };

    await api.blockActor(profile);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.createRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.collection, "app.bsky.graph.block");
    assert.deepEqual(body.record.subject, "did:plc:target");
  });
});

describe("unblockActor", () => {
  it("should delete block record", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const profile = {
      viewer: {
        blocking: "at://did:plc:testuser/app.bsky.graph.block/blockrkey123",
      },
    };

    await api.unblockActor(profile);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.deleteRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.collection, "app.bsky.graph.block");
    assert.deepEqual(body.rkey, "blockrkey123");
  });
});

describe("createPost", () => {
  it("should create post with text", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.feed.post/abc",
      cid: "cid123",
    });
    const api = new Api(session);

    await api.createPost({ text: "Hello world", facets: [] });

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.createRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.collection, "app.bsky.feed.post");
    assert.deepEqual(body.record.text, "Hello world");
  });

  it("should include embed when provided", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.feed.post/abc",
      cid: "cid123",
    });
    const api = new Api(session);
    const embed = { $type: "app.bsky.embed.images", images: [] };

    await api.createPost({ text: "Hello", facets: [], embed });

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert.deepEqual(body.record.embed, embed);
  });

  it("should include reply when provided", async () => {
    const session = createMockSession({
      uri: "at://did:plc:testuser/app.bsky.feed.post/abc",
      cid: "cid123",
    });
    const api = new Api(session);
    const reply = {
      root: { uri: "rooturi", cid: "rootcid" },
      parent: { uri: "parenturi", cid: "parentcid" },
    };

    await api.createPost({ text: "Reply", facets: [], reply });

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert.deepEqual(body.record.reply, reply);
  });
});

describe("deletePost", () => {
  it("should delete post by URI", async () => {
    const session = createMockSession({});
    const api = new Api(session);
    const post = {
      uri: "at://did:plc:testuser/app.bsky.feed.post/postrkey123",
    };

    await api.deletePost(post);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.deleteRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.collection, "app.bsky.feed.post");
    assert.deepEqual(body.rkey, "postrkey123");
  });
});

describe("uploadBlob", () => {
  it("should upload blob with correct content type", async () => {
    const session = createMockSession({
      blob: { ref: { $link: "blobref123" }, mimeType: "image/png" },
    });
    const api = new Api(session);
    const blob = new Blob(["test"], { type: "image/png" });

    const result = await api.uploadBlob(blob);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.uploadBlob"));
    assert.deepEqual(options.method, "POST");
    assert.deepEqual(options.headers["Content-Type"], "image/png");
    assert.deepEqual(result.ref.$link, "blobref123");
  });
});

describe("createModerationReport", () => {
  it("should create moderation report", async () => {
    const session = createMockSession({
      id: 123,
      reasonType: "com.atproto.moderation.defs#reasonSpam",
    });
    const api = new Api(session);
    const subject = {
      $type: "com.atproto.repo.strongRef",
      uri: "posturi",
      cid: "postcid",
    };

    await api.createModerationReport({
      reasonType: "com.atproto.moderation.defs#reasonSpam",
      reason: "This is spam",
      subject,
      labelerDid: "did:plc:labeler",
    });

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.moderation.createReport"));
    assert.deepEqual(options.method, "POST");
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:plc:labeler#atproto_labeler",
    );
    const body = JSON.parse(options.body);
    assert.deepEqual(body.reasonType, "com.atproto.moderation.defs#reasonSpam");
    assert.deepEqual(body.reason, "This is spam");
    assert.deepEqual(body.subject, subject);
  });

  it("should not include reason when not provided", async () => {
    const session = createMockSession({ id: 123 });
    const api = new Api(session);
    const subject = {
      $type: "com.atproto.repo.strongRef",
      uri: "posturi",
      cid: "postcid",
    };

    await api.createModerationReport({
      reasonType: "com.atproto.moderation.defs#reasonSpam",
      subject,
      labelerDid: "did:plc:labeler",
    });

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert(body.reason === undefined);
  });
});

describe("getBlocks", () => {
  it("should fetch blocked accounts", async () => {
    const session = createMockSession({
      blocks: [{ did: "did:plc:a" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getBlocks();

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.graph.getBlocks"));
    assert(url.includes("limit=50"));
    assert.deepEqual(result.blocks.length, 1);
    assert.deepEqual(result.cursor, "next");
  });

  it("should pass cursor when provided", async () => {
    const session = createMockSession({ blocks: [], cursor: "" });
    const api = new Api(session);

    await api.getBlocks({ cursor: "abc" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("cursor=abc"));
  });
});

describe("searchFeedGenerators", () => {
  it("should search popular feed generators", async () => {
    const session = createMockSession({
      feeds: [{ uri: "feed1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.searchFeedGenerators("news");

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.unspecced.getPopularFeedGenerators"));
    assert(url.includes("query=news"));
    assert(url.includes("limit=15"));
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:web:api.bsky.app#bsky_appview",
    );
    assert.deepEqual(result.feeds.length, 1);
  });

  it("should pass cursor when provided", async () => {
    const session = createMockSession({ feeds: [] });
    const api = new Api(session);

    await api.searchFeedGenerators("news", { cursor: "abc" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("cursor=abc"));
  });
});

describe("getActorFeeds", () => {
  it("should fetch feeds created by an actor", async () => {
    const session = createMockSession({
      feeds: [{ uri: "feed1" }],
      cursor: "next",
    });
    const api = new Api(session);

    const result = await api.getActorFeeds("did:plc:user");

    const { url } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.feed.getActorFeeds"));
    assert(url.includes("actor=did%3Aplc%3Auser"));
    assert(url.includes("limit=50"));
    assert.deepEqual(result.feeds.length, 1);
  });

  it("should pass cursor when provided", async () => {
    const session = createMockSession({ feeds: [] });
    const api = new Api(session);

    await api.getActorFeeds("did:plc:user", { cursor: "abc" });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("cursor=abc"));
  });
});

describe("getReposts", () => {
  it("should fetch repost records and skip failures", async () => {
    let callCount = 0;
    const session = {
      serviceEndpoint: "https://test.example.com",
      did: "did:plc:testuser",
      fetch: async (url) => {
        callCount += 1;
        if (callCount === 2) {
          return {
            ok: false,
            status: 404,
            statusText: "Not Found",
            json: async () => ({ error: "RecordNotFound" }),
          };
        }
        return {
          ok: true,
          json: async () => ({
            uri: url,
            value: { subject: { uri: "post1", cid: "cid1" } },
          }),
        };
      },
    };
    const api = new Api(session);

    const reposts = await api.getReposts([
      "at://did:plc:a/app.bsky.feed.repost/1",
      "at://did:plc:b/app.bsky.feed.repost/2",
      "at://did:plc:c/app.bsky.feed.repost/3",
    ]);

    assert.deepEqual(reposts.length, 2);
  });
});

describe("putProfileRecord", () => {
  it("should put profile record with $type and null swapRecord by default", async () => {
    const session = createMockSession({ uri: "rec", cid: "cid" });
    const api = new Api(session);

    await api.putProfileRecord({ displayName: "Alice" });

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.repo.putRecord"));
    assert.deepEqual(options.method, "POST");
    const body = JSON.parse(options.body);
    assert.deepEqual(body.repo, "did:plc:testuser");
    assert.deepEqual(body.collection, "app.bsky.actor.profile");
    assert.deepEqual(body.rkey, "self");
    assert.deepEqual(body.record.$type, "app.bsky.actor.profile");
    assert.deepEqual(body.record.displayName, "Alice");
    assert.deepEqual(body.swapRecord, null);
  });

  it("should include swapRecord cid for conditional write", async () => {
    const session = createMockSession({ uri: "rec", cid: "cid" });
    const api = new Api(session);

    await api.putProfileRecord({ displayName: "Bob" }, "previouscid");

    const { options } = session.getLastFetchOptions();
    const body = JSON.parse(options.body);
    assert.deepEqual(body.swapRecord, "previouscid");
  });
});

describe("putActivitySubscription", () => {
  it("should put activity subscription with subject and subscription", async () => {
    const session = createMockSession({ subject: "did:plc:target" });
    const api = new Api(session);
    const activitySubscription = { post: true, reply: false };

    await api.putActivitySubscription("did:plc:target", activitySubscription);

    const { url, options } = session.getLastFetchOptions();
    assert(url.includes("app.bsky.notification.putActivitySubscription"));
    assert.deepEqual(options.method, "POST");
    assert.deepEqual(
      options.headers["atproto-proxy"],
      "did:web:api.bsky.app#bsky_appview",
    );
    const body = JSON.parse(options.body);
    assert.deepEqual(body.subject, "did:plc:target");
    assert.deepEqual(body.activitySubscription, activitySubscription);
  });
});

describe("getServiceAuthToken", () => {
  it("should fetch service auth token with aud and lxm", async () => {
    const session = createMockSession({ token: "service-token-123" });
    const api = new Api(session);

    const token = await api.getServiceAuthToken({
      aud: "did:web:video.bsky.app",
      lxm: "app.bsky.video.getUploadLimits",
    });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("com.atproto.server.getServiceAuth"));
    assert(url.includes("aud=did%3Aweb%3Avideo.bsky.app"));
    assert(url.includes("lxm=app.bsky.video.getUploadLimits"));
    assert(url.includes("exp="));
    assert.deepEqual(token, "service-token-123");
  });

  it("should use provided exp value", async () => {
    const session = createMockSession({ token: "service-token-123" });
    const api = new Api(session);

    await api.getServiceAuthToken({
      aud: "did:web:video.bsky.app",
      lxm: "com.atproto.repo.uploadBlob",
      exp: 1234567890,
    });

    const { url } = session.getLastFetchOptions();
    assert(url.includes("exp=1234567890"));
  });
});

describe("serviceRequest", () => {
  let originalFetch = null;
  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("should send request with bearer token to external service", async () => {
    const mockFetch = new MockFetch();
    mockFetch.__interceptJson("https://video.example.com", { ok: true });
    globalThis.fetch = mockFetch;
    const api = new Api(createMockSession({}));

    await api.serviceRequest("https://video.example.com/xrpc/some.method", {
      token: "abc-token",
      query: { foo: "bar" },
    });

    const lastCall = mockFetch.calls[0];
    assert.deepEqual(
      lastCall.url,
      "https://video.example.com/xrpc/some.method?foo=bar",
    );
    assert.deepEqual(
      lastCall.options.headers.Authorization,
      "Bearer abc-token",
    );
    assert.deepEqual(lastCall.options.method, "GET");
  });

  it("should omit Authorization when no token is provided", async () => {
    const mockFetch = new MockFetch();
    mockFetch.__interceptJson("https://video.example.com", { ok: true });
    globalThis.fetch = mockFetch;
    const api = new Api(createMockSession({}));

    await api.serviceRequest("https://video.example.com/xrpc/some.method");

    const lastCall = mockFetch.calls[0];
    assert(lastCall.options.headers.Authorization === undefined);
  });

  it("should throw ApiError when response is not ok", async () => {
    const mockFetch = new MockFetch();
    mockFetch.__intercept("https://video.example.com", async () => ({
      ok: false,
      status: 400,
      statusText: "Bad Request",
      json: async () => ({ error: "BadRequest" }),
    }));
    globalThis.fetch = mockFetch;
    const api = new Api(createMockSession({}));

    let thrownError = null;
    try {
      await api.serviceRequest("https://video.example.com/xrpc/some.method", {
        token: "abc",
      });
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError instanceof ApiError);
    assert.deepEqual(thrownError.status, 400);
  });
});

describe("getVideoUploadLimits", () => {
  let originalFetch = null;
  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("should fetch upload limits using a service auth token", async () => {
    const session = createMockSession({ token: "video-token" });
    const mockFetch = new MockFetch();
    mockFetch.__interceptJson("https://video.bsky.app", {
      canUpload: true,
      remainingDailyVideos: 5,
    });
    globalThis.fetch = mockFetch;
    const api = new Api(session);

    const result = await api.getVideoUploadLimits();

    const videoCall = mockFetch.calls[0];
    assert(videoCall.url.includes("app.bsky.video.getUploadLimits"));
    assert.deepEqual(
      videoCall.options.headers.Authorization,
      "Bearer video-token",
    );
    assert.deepEqual(result.canUpload, true);
    assert.deepEqual(result.remainingDailyVideos, 5);
  });
});

describe("getVideoJobStatus", () => {
  let originalFetch = null;
  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("should fetch job status by jobId", async () => {
    const mockFetch = new MockFetch();
    mockFetch.__interceptJson("https://video.bsky.app", {
      jobStatus: { jobId: "job1", state: "JOB_STATE_COMPLETED" },
    });
    globalThis.fetch = mockFetch;
    const api = new Api(createMockSession({}));

    const result = await api.getVideoJobStatus("job1");

    const videoCall = mockFetch.calls[0];
    assert(videoCall.url.includes("app.bsky.video.getJobStatus"));
    assert(videoCall.url.includes("jobId=job1"));
    assert.deepEqual(result.jobId, "job1");
    assert.deepEqual(result.state, "JOB_STATE_COMPLETED");
  });
});

describe("uploadVideoBlob", () => {
  let originalFetch = null;
  beforeEach(() => {
    originalFetch = globalThis.fetch;
  });
  afterEach(() => {
    globalThis.fetch = originalFetch;
  });

  it("should upload video using service auth token and return job data", async () => {
    const session = createMockSession({ token: "upload-token" });
    const mockFetch = new MockFetch();
    mockFetch.__interceptJson("https://video.bsky.app", {
      jobId: "job1",
      state: "JOB_STATE_CREATED",
    });
    globalThis.fetch = mockFetch;
    const api = new Api(session);
    const file = new Blob(["video-bytes"], { type: "video/mp4" });
    file.name = "clip.mp4";

    const result = await api.uploadVideoBlob(file);

    const videoCall = mockFetch.calls[0];
    assert(videoCall.url.includes("app.bsky.video.uploadVideo"));
    assert(videoCall.url.includes("did=did%3Aplc%3Atestuser"));
    assert(videoCall.url.includes("name=clip.mp4"));
    assert.deepEqual(videoCall.options.method, "POST");
    assert.deepEqual(
      videoCall.options.headers.Authorization,
      "Bearer upload-token",
    );
    assert.deepEqual(videoCall.options.headers["Content-Type"], "video/mp4");
    assert.deepEqual(videoCall.options.body, file);
    assert.deepEqual(result.jobId, "job1");
  });

  it("should treat already_exists 409 as success and return existing job", async () => {
    const session = createMockSession({ token: "upload-token" });
    const mockFetch = new MockFetch();
    mockFetch.__intercept("https://video.bsky.app", async () => ({
      ok: false,
      status: 409,
      statusText: "Conflict",
      json: async () => ({ error: "already_exists", jobId: "existing-job" }),
    }));
    globalThis.fetch = mockFetch;
    const api = new Api(session);
    const file = new Blob(["video"], { type: "video/mp4" });
    file.name = "clip.mp4";

    const result = await api.uploadVideoBlob(file);

    assert.deepEqual(result.error, "already_exists");
    assert.deepEqual(result.jobId, "existing-job");
  });

  it("should re-throw non-409 errors", async () => {
    const session = createMockSession({ token: "upload-token" });
    const mockFetch = new MockFetch();
    mockFetch.__intercept("https://video.bsky.app", async () => ({
      ok: false,
      status: 500,
      statusText: "Server Error",
      json: async () => ({ error: "InternalError" }),
    }));
    globalThis.fetch = mockFetch;
    const api = new Api(session);
    const file = new Blob(["video"], { type: "video/mp4" });
    file.name = "clip.mp4";

    let thrownError = null;
    try {
      await api.uploadVideoBlob(file);
    } catch (e) {
      thrownError = e;
    }

    assert(thrownError instanceof ApiError);
    assert.deepEqual(thrownError.status, 500);
  });
});
