import { test, expect } from "../../base.js";
import { login } from "../../helpers.js";
import { MockServer } from "../../mockServer.js";
import { createPost } from "../../../shared/factories.js";

const postUri = "at://did:plc:author1/app.bsky.feed.post/abc123";

const mainPost = createPost({
  uri: postUri,
  text: "This is the main post",
  authorHandle: "author1.bsky.social",
  authorDisplayName: "Author One",
});

// Build a thread with a deep parent chain so the large (main) post starts
// below the fold and loading it triggers the pin-under-header scroll.
async function setupDeepParentThread(page, { delayMs = null } = {}) {
  const NUM_PARENTS = 12;
  const parents = Array.from({ length: NUM_PARENTS }).map((_, index) =>
    createPost({
      uri: `at://did:plc:parent${index}/app.bsky.feed.post/parent${index}`,
      text: `Parent post number ${index}`,
      authorHandle: `parent${index}.bsky.social`,
      authorDisplayName: `Parent ${index}`,
    }),
  );
  const rootParent = parents[0];
  const immediateParent = parents[NUM_PARENTS - 1];

  const childPost = createPost({
    uri: postUri,
    text: "This is the main post we should scroll to",
    authorHandle: "author1.bsky.social",
    authorDisplayName: "Author One",
    reply: {
      parent: { uri: immediateParent.uri, cid: immediateParent.cid },
      root: { uri: rootParent.uri, cid: rootParent.cid },
    },
  });

  // Nest the parents so the root ends up deepest in the `parent` chain.
  let parentNode = null;
  for (const parentPost of parents) {
    parentNode = {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: parentPost,
      parent: parentNode,
      replies: [],
    };
  }

  const mockServer = new MockServer();
  mockServer.addPosts([childPost, ...parents]);
  mockServer.setPostThread(
    postUri,
    {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: childPost,
      parent: parentNode,
      replies: [],
    },
    delayMs === null ? undefined : { delayMs },
  );
  await mockServer.setup(page);
}

test.describe("Post thread view", () => {
  test("should display header and the main post", async ({ page }) => {
    const mockServer = new MockServer();
    mockServer.addPosts([mainPost]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="header-title"]')).toContainText(
      "Post",
      { timeout: 10000 },
    );

    await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
      timeout: 10000,
    });
    await expect(view).toContainText("This is the main post");
  });

  test("should display parent post in thread context", async ({ page }) => {
    const parentPost = createPost({
      uri: "at://did:plc:parent1/app.bsky.feed.post/parent1",
      text: "This is the parent post",
      authorHandle: "parent1.bsky.social",
      authorDisplayName: "Parent Author",
    });

    const childPost = createPost({
      uri: postUri,
      text: "This is a reply",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      reply: {
        parent: { uri: parentPost.uri, cid: parentPost.cid },
        root: { uri: parentPost.uri, cid: parentPost.cid },
      },
    });

    const mockServer = new MockServer();
    mockServer.addPosts([childPost, parentPost]);
    mockServer.setPostThread(postUri, {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: childPost,
      parent: {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: parentPost,
        parent: null,
        replies: [],
      },
      replies: [],
    });
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view).toContainText("This is the parent post", {
      timeout: 10000,
    });
    await expect(view).toContainText("This is a reply");
    await expect(view.locator('[data-testid="large-post"]')).toBeVisible();
    // Parent shown as small post above the main post
    await expect(view.locator('[data-testid="small-post"]')).toHaveCount(1, {
      timeout: 10000,
    });
  });

  test("should scroll the main post into view on load when it has parents", async ({
    page,
  }) => {
    await setupDeepParentThread(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    // The page should have scrolled down past the parents...
    await expect
      .poll(() => page.evaluate(() => window.scrollY), { timeout: 10000 })
      .toBeGreaterThan(0);

    // ...landing the main post just below the header rather than off-screen.
    const headerHeight = await view
      .locator("header")
      .evaluate((el) => el.offsetHeight);
    const postTop = await largePost.evaluate(
      (el) => el.getBoundingClientRect().top,
    );
    expect(postTop).toBeGreaterThanOrEqual(headerHeight - 8);
    expect(postTop).toBeLessThanOrEqual(headerHeight + 8);
  });

  test("should not add scroll runway below a thread without parents", async ({
    page,
  }) => {
    const mockServer = new MockServer();
    mockServer.addPosts([mainPost]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
      timeout: 10000,
    });

    // Nothing above the main post means no pinning scroll is needed, so the
    // page should not be scrollable beyond its natural content.
    const { scrollHeight, innerHeight } = await page.evaluate(() => ({
      scrollHeight: document.documentElement.scrollHeight,
      innerHeight: window.innerHeight,
    }));
    expect(scrollHeight - innerHeight).toBeLessThanOrEqual(1);
  });

  test("should pin the main post under the header even if a programmatic scroll happens while loading", async ({
    page,
  }) => {
    // Delay the thread response so the skeleton is shown long enough to
    // scroll before the full thread (with parents) loads.
    await setupDeepParentThread(page, { delayMs: 1500 });

    await login(page);
    // A short viewport guarantees the loading skeleton is tall enough to scroll.
    await page.setViewportSize({ width: 400, height: 200 });
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");

    // Wait until the loading skeleton has rendered (the router resets scroll to
    // the top as part of navigation, so we must scroll after that happens),
    // then scroll down while the full thread is still loading. window.scrollTo
    // fires no input events, mirroring iOS Safari's async history scroll
    // restore - the case the pin exists to override.
    await expect(
      view.locator('[data-testid="post-skeleton"]').first(),
    ).toBeVisible();
    await expect(view.locator('[data-testid="large-post"]')).toHaveCount(0);
    const extraScroll = 80;
    await page.evaluate((y) => window.scrollTo(0, y), extraScroll);
    const actualExtraScroll = await page.evaluate(() => window.scrollY);
    expect(actualExtraScroll).toBeGreaterThan(0);

    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    // The stray scroll is overridden, not preserved - the main post pins to
    // just below the header once the full thread (with parents) loads in.
    const headerHeight = await view
      .locator("header")
      .evaluate((el) => el.offsetHeight);
    await expect
      .poll(() => largePost.evaluate((el) => el.getBoundingClientRect().top), {
        timeout: 10000,
      })
      .toBeLessThanOrEqual(headerHeight + 8);
    const postTop = await largePost.evaluate(
      (el) => el.getBoundingClientRect().top,
    );
    expect(postTop).toBeGreaterThanOrEqual(headerHeight - 8);
  });

  test("should not pin the main post when the user scrolls while loading", async ({
    page,
  }) => {
    await setupDeepParentThread(page, { delayMs: 1500 });

    await login(page);
    await page.setViewportSize({ width: 400, height: 200 });
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");

    // Scroll with real wheel input while the thread is still loading - unlike
    // a programmatic scroll, this marks the user as having taken over.
    await expect(
      view.locator('[data-testid="post-skeleton"]').first(),
    ).toBeVisible();
    await expect(view.locator('[data-testid="large-post"]')).toHaveCount(0);
    await page.mouse.move(200, 100);
    await page.mouse.wheel(0, 80);
    await expect
      .poll(() => page.evaluate(() => window.scrollY))
      .toBeGreaterThan(0);
    const userScrollY = await page.evaluate(() => window.scrollY);

    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    // Give a would-be pin (1000ms duration) time to fire, then check the
    // user's scroll position was left alone.
    await page.waitForTimeout(1200);
    const scrollYAfterLoad = await page.evaluate(() => window.scrollY);
    expect(Math.abs(scrollYAfterLoad - userScrollY)).toBeLessThanOrEqual(1);
    const headerHeight = await view
      .locator("header")
      .evaluate((el) => el.offsetHeight);
    const postTop = await largePost.evaluate(
      (el) => el.getBoundingClientRect().top,
    );
    expect(postTop).toBeGreaterThan(headerHeight + 8);
  });

  test("should show 'Load parent post' link when reply ref is broken", async ({
    page,
  }) => {
    const parentUri = "at://did:plc:parentbroken/app.bsky.feed.post/parent1";

    // Child post has reply refs but the thread tree omits the parent
    const childPost = createPost({
      uri: postUri,
      text: "Reply with broken parent ref",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      reply: {
        parent: { uri: parentUri, cid: "bafyparentbroken" },
        root: { uri: parentUri, cid: "bafyparentbroken" },
      },
    });

    const mockServer = new MockServer();
    mockServer.addPosts([childPost]);
    mockServer.setPostThread(postUri, {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: childPost,
      parent: null,
      replies: [],
    });
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
      timeout: 10000,
    });

    const loadParentLink = view.locator(".load-more-link a", {
      hasText: "Load parent post",
    });
    await expect(loadParentLink).toBeVisible();
    await expect(loadParentLink).toHaveAttribute(
      "href",
      "/profile/did:plc:parentbroken/post/parent1",
    );
  });

  test("should display replies", async ({ page }) => {
    const postWithReplies = createPost({
      uri: postUri,
      text: "Post with replies",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      replyCount: 2,
    });

    const reply1 = createPost({
      uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
      text: "First reply",
      authorHandle: "replier1.bsky.social",
      authorDisplayName: "Replier One",
      likeCount: 10,
    });

    const reply2 = createPost({
      uri: "at://did:plc:replier2/app.bsky.feed.post/reply2",
      text: "Second reply",
      authorHandle: "replier2.bsky.social",
      authorDisplayName: "Replier Two",
      likeCount: 5,
    });

    const mockServer = new MockServer();
    mockServer.addPosts([postWithReplies, reply1, reply2]);
    mockServer.setPostThread(postUri, {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: postWithReplies,
      parent: null,
      replies: [
        {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: reply1,
          replies: [],
        },
        {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: reply2,
          replies: [],
        },
      ],
    });
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
      timeout: 10000,
    });
    await expect(view).toContainText("First reply", { timeout: 10000 });
    await expect(view).toContainText("Second reply");
  });

  test("should display reply prompt for authenticated user", async ({
    page,
  }) => {
    const mockServer = new MockServer();
    mockServer.addPosts([mainPost]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator(".post-thread-reply-prompt")).toContainText(
      "Write your reply",
      { timeout: 10000 },
    );
  });

  test("should display post action counts on the main post", async ({
    page,
  }) => {
    const postWithCounts = createPost({
      uri: postUri,
      text: "Popular post",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      likeCount: 42,
      repostCount: 10,
      quoteCount: 3,
    });

    const mockServer = new MockServer();
    mockServer.addPosts([postWithCounts]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });
    await expect(largePost).toContainText("42");
    await expect(largePost).toContainText("likes");
    await expect(largePost).toContainText("10");
    await expect(largePost).toContainText("reposts");
  });

  test("should like the main post when clicking the like button", async ({
    page,
  }) => {
    const post = createPost({
      uri: postUri,
      text: "Post to like",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      likeCount: 3,
    });

    const mockServer = new MockServer();
    mockServer.addPosts([post]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    await largePost.locator('[data-testid="like-button"]').click();

    await expect(
      largePost.locator('[data-testid="like-button"].active'),
    ).toBeVisible({
      timeout: 10000,
    });
  });

  test("should repost the main post when clicking repost in the context menu", async ({
    page,
  }) => {
    const post = createPost({
      uri: postUri,
      text: "Post to repost",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      repostCount: 2,
    });

    const mockServer = new MockServer();
    mockServer.addPosts([post]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    await largePost.locator('[data-testid="repost-button"]').click();
    await page.locator('[data-testid="menu-action-repost"]').click();

    await expect(
      largePost.locator('[data-testid="repost-button"].reposted'),
    ).toBeVisible({ timeout: 10000 });
  });

  test("should bookmark the main post when clicking the bookmark button", async ({
    page,
  }) => {
    const post = createPost({
      uri: postUri,
      text: "Post to bookmark",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
    });

    const mockServer = new MockServer();
    mockServer.addPosts([post]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    const largePost = view.locator('[data-testid="large-post"]');
    await expect(largePost).toBeVisible({ timeout: 10000 });

    await largePost.locator('[data-testid="bookmark-button"]').click();

    await expect(
      largePost.locator('[data-testid="bookmark-button"].active'),
    ).toBeVisible({ timeout: 10000 });
  });

  test("should navigate to reply thread when clicking a reply", async ({
    page,
  }) => {
    const postWithReplies = createPost({
      uri: postUri,
      text: "Main post",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
      replyCount: 1,
    });

    const reply = createPost({
      uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
      text: "A reply to click",
      authorHandle: "replier1.bsky.social",
      authorDisplayName: "Replier One",
    });

    const mockServer = new MockServer();
    mockServer.addPosts([postWithReplies, reply]);
    mockServer.setPostThread(postUri, {
      $type: "app.bsky.feed.defs#threadViewPost",
      post: postWithReplies,
      parent: null,
      replies: [
        {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: reply,
          replies: [],
        },
      ],
    });
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view).toContainText("A reply to click", { timeout: 10000 });

    await view.locator('[data-testid="small-post"]').click();

    await expect(page).toHaveURL(
      /\/profile\/replier1\.bsky\.social\/post\/reply1/,
      {
        timeout: 10000,
      },
    );
  });

  test("should post a reply via the reply prompt", async ({ page }) => {
    const post = createPost({
      uri: postUri,
      text: "Post to reply to",
      authorHandle: "author1.bsky.social",
      authorDisplayName: "Author One",
    });

    const mockServer = new MockServer();
    mockServer.addPosts([post]);
    await mockServer.setup(page);

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator(".post-thread-reply-prompt")).toContainText(
      "Write your reply",
      { timeout: 10000 },
    );

    // Click the reply prompt to open the composer
    await view.locator(".post-thread-reply-prompt").click();

    // Type into the composer's rich text input
    const composer = page.locator("post-composer");
    await expect(composer.locator("dialog")).toBeVisible({ timeout: 10000 });
    await composer
      .locator("rich-text-input [contenteditable]")
      .fill("My reply text");

    // Click the Reply button to send
    await composer.locator('[data-testid="composer-submit-button"]').click();

    // The composer should close and the reply should appear in the thread
    await expect(composer.locator("dialog")).not.toBeVisible({
      timeout: 10000,
    });
    await expect(view).toContainText("My reply text", { timeout: 10000 });
  });

  test("should display 'Post not found' for 404 errors", async ({ page }) => {
    const mockServer = new MockServer();
    mockServer.addPosts([mainPost]);
    await mockServer.setup(page);

    // Override getPostThread to return 400 NotFound
    await page.route("**/xrpc/app.bsky.feed.getPostThread*", (route) =>
      route.fulfill({
        status: 400,
        contentType: "application/json",
        body: JSON.stringify({ error: "NotFound", message: "Post not found" }),
      }),
    );

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="post-not-found"]')).toBeVisible({
      timeout: 10000,
    });
  });

  test("should display 'Error loading thread' for server errors", async ({
    page,
  }) => {
    const mockServer = new MockServer();
    mockServer.addPosts([mainPost]);
    await mockServer.setup(page);

    // Override getPostThread to return 500
    await page.route("**/xrpc/app.bsky.feed.getPostThread*", (route) =>
      route.fulfill({
        status: 500,
        contentType: "application/json",
        body: JSON.stringify({ error: "InternalServerError" }),
      }),
    );

    await login(page);
    await page.goto("/profile/author1.bsky.social/post/abc123");

    const view = page.locator("#post-detail-view");
    await expect(view.locator('[data-testid="thread-error"]')).toBeVisible({
      timeout: 10000,
    });
  });

  test.describe("Logged-out behavior", () => {
    test("should render thread and replies publicly", async ({ page }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "This is a public post",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 1,
      });

      const reply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "A public reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, reply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: reply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
        timeout: 10000,
      });
      await expect(view).toContainText("This is a public post");
      await expect(view).toContainText("A public reply", { timeout: 10000 });
    });

    test("should hide reply composer and interaction buttons", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
        timeout: 10000,
      });

      // Reply composer should be hidden for logged-out users
      await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible();
    });

    test("should show lock message for !no-unauthenticated anchor post", async ({
      page,
    }) => {
      const restrictedPost = createPost({
        uri: postUri,
        text: "This post is restricted",
        authorHandle: "restricted.bsky.social",
        authorDisplayName: "Restricted Author",
      });
      restrictedPost.author.labels = [
        { val: "!no-unauthenticated", src: restrictedPost.author.did },
      ];

      const mockServer = new MockServer();
      mockServer.addPosts([restrictedPost]);
      await mockServer.setup(page);

      await page.goto("/profile/restricted.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText(
        "This author has chosen to make their posts visible only to people who are signed in.",
        { timeout: 10000 },
      );
      await expect(view).not.toContainText("This post is restricted");
    });

    test("should show lock message for !no-unauthenticated parent post", async ({
      page,
    }) => {
      const restrictedParent = createPost({
        uri: "at://did:plc:restricted/app.bsky.feed.post/parent1",
        text: "Restricted parent",
        authorHandle: "restricted.bsky.social",
        authorDisplayName: "Restricted Author",
      });
      restrictedParent.author.labels = [
        { val: "!no-unauthenticated", src: restrictedParent.author.did },
      ];

      const childPost = createPost({
        uri: postUri,
        text: "This is a reply to restricted parent",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        reply: {
          parent: { uri: restrictedParent.uri, cid: restrictedParent.cid },
          root: { uri: restrictedParent.uri, cid: restrictedParent.cid },
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([childPost, restrictedParent]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: childPost,
        parent: {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: restrictedParent,
          parent: null,
          replies: [],
        },
        replies: [],
      });
      await mockServer.setup(page);

      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText(
        "This author has chosen to make their posts visible only to people who are signed in.",
        { timeout: 10000 },
      );
      await expect(view).not.toContainText("Restricted parent");
      await expect(view).toContainText("This is a reply to restricted parent");
    });

    test("should hide !no-unauthenticated replies but show other replies when logged out", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with restricted reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal visible reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const restrictedReply = createPost({
        uri: "at://did:plc:restricted/app.bsky.feed.post/reply2",
        text: "This reply should be hidden",
        authorHandle: "restricted.bsky.social",
        authorDisplayName: "Restricted Author",
      });
      restrictedReply.author.labels = [
        { val: "!no-unauthenticated", src: restrictedReply.author.did },
      ];

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, restrictedReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: restrictedReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view.locator('[data-testid="large-post"]')).toBeVisible({
        timeout: 10000,
      });
      await expect(view).toContainText("Normal visible reply", {
        timeout: 10000,
      });
      await expect(view).not.toContainText("This reply should be hidden");
    });
  });

  test.describe("Hidden replies section", () => {
    test("should place muted account replies in the hidden section", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with muted reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const mutedReply = createPost({
        uri: "at://did:plc:muted1/app.bsky.feed.post/mutedreply",
        text: "Reply from muted user",
        authorHandle: "muted1.bsky.social",
        authorDisplayName: "Muted User",
      });
      mutedReply.author.viewer.muted = true;

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, mutedReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: mutedReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("Normal reply", { timeout: 10000 });

      // Muted reply should not be in the main reply chains
      const replyChains = view.locator(".post-thread-reply-chains");
      await expect(replyChains).not.toContainText("Reply from muted user");

      // Hidden section should be present with the toggle button
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection).toBeVisible();
      await expect(
        hiddenSection.locator(".hidden-replies-button"),
      ).toContainText("Show more replies");
    });

    test("should place content-labeled replies in the hidden section", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with labeled reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const labeledReply = createPost({
        uri: "at://did:plc:labeled1/app.bsky.feed.post/labeledreply",
        text: "Labeled reply content",
        authorHandle: "labeled1.bsky.social",
        authorDisplayName: "Labeled User",
        labels: [
          {
            val: "!warn",
            src: "did:plc:ar7c4by46qjdydhdevvrndac",
            uri: "at://did:plc:labeled1/app.bsky.feed.post/labeledreply",
            cts: "2025-01-01T00:00:00.000Z",
          },
        ],
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, labeledReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: labeledReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("Normal reply", { timeout: 10000 });

      // Labeled reply should not be in the main reply chains
      const replyChains = view.locator(".post-thread-reply-chains");
      await expect(replyChains).not.toContainText("Labeled reply content");

      // Hidden section should be present
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection).toBeVisible();
    });

    test("should reveal hidden replies when clicking 'Show more replies'", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with hidden replies",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const mutedReply = createPost({
        uri: "at://did:plc:muted1/app.bsky.feed.post/mutedreply",
        text: "Reply from muted user",
        authorHandle: "muted1.bsky.social",
        authorDisplayName: "Muted User",
      });
      mutedReply.author.viewer.muted = true;

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, mutedReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: mutedReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection.locator(".hidden-replies-button")).toBeVisible(
        { timeout: 10000 },
      );

      // Hidden content should not be visible initially
      await expect(hiddenSection.locator(".toggle-content")).not.toBeVisible();

      // Click to expand
      await hiddenSection.locator(".hidden-replies-button").click();

      // The toggle content should now be visible with the muted reply
      await expect(hiddenSection.locator(".toggle-content")).toBeVisible();
      await expect(hiddenSection).toContainText("Reply from muted user");

      // The button should be hidden after expanding
      await expect(
        hiddenSection.locator(".hidden-replies-button"),
      ).not.toBeVisible();
    });

    test("should not show hidden section when all replies are normal", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with normal replies only",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 1,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("Normal reply", { timeout: 10000 });

      // Hidden section should not exist
      await expect(view.locator("hidden-replies-section")).not.toBeAttached();
    });

    test("should place sentiment-hidden replies in the hidden section", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with sentiment-hidden reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const hiddenReply = createPost({
        uri: "at://did:plc:replier2/app.bsky.feed.post/hiddenreply",
        text: "Sentiment-hidden reply content",
        authorHandle: "replier2.bsky.social",
        authorDisplayName: "Replier Two",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, hiddenReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: hiddenReply,
            replies: [],
          },
        ],
      });
      mockServer.setPostThreadOther(postUri, [{ uri: hiddenReply.uri }]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("Normal reply", { timeout: 10000 });

      // Sentiment-hidden reply should not be in the main reply chains
      const replyChains = view.locator(".post-thread-reply-chains");
      await expect(replyChains).not.toContainText(
        "Sentiment-hidden reply content",
      );

      // Hidden section should be present
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection).toBeVisible();
      await expect(
        hiddenSection.locator(".hidden-replies-button"),
      ).toContainText("Show more replies");
    });

    test("should not affect replies when getPostThreadOtherV2 returns empty", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with all normal replies",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const reply1 = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "First normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const reply2 = createPost({
        uri: "at://did:plc:replier2/app.bsky.feed.post/reply2",
        text: "Second normal reply",
        authorHandle: "replier2.bsky.social",
        authorDisplayName: "Replier Two",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, reply1, reply2]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: reply1,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: reply2,
            replies: [],
          },
        ],
      });
      // Empty postThreadOther — no replies hidden by sentiment
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("First normal reply", {
        timeout: 10000,
      });
      await expect(view).toContainText("Second normal reply");

      // No hidden section
      await expect(view.locator("hidden-replies-section")).not.toBeAttached();
    });

    test("should reveal sentiment-hidden replies when clicking 'Show more replies'", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with sentiment-hidden reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const hiddenReply = createPost({
        uri: "at://did:plc:replier2/app.bsky.feed.post/hiddenreply",
        text: "Sentiment-hidden reply revealed",
        authorHandle: "replier2.bsky.social",
        authorDisplayName: "Replier Two",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, hiddenReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: hiddenReply,
            replies: [],
          },
        ],
      });
      mockServer.setPostThreadOther(postUri, [{ uri: hiddenReply.uri }]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection.locator(".hidden-replies-button")).toBeVisible(
        { timeout: 10000 },
      );

      // Click to expand
      await hiddenSection.locator(".hidden-replies-button").click();

      // The hidden reply should now be visible
      await expect(hiddenSection.locator(".toggle-content")).toBeVisible();
      await expect(hiddenSection).toContainText(
        "Sentiment-hidden reply revealed",
      );
    });

    test("should place muted word replies in the hidden section (via getPreferences)", async ({
      page,
    }) => {
      const postWithReplies = createPost({
        uri: postUri,
        text: "Post with muted word reply",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        replyCount: 2,
      });

      const normalReply = createPost({
        uri: "at://did:plc:replier1/app.bsky.feed.post/reply1",
        text: "Normal reply",
        authorHandle: "replier1.bsky.social",
        authorDisplayName: "Replier One",
      });

      const mutedWordReply = createPost({
        uri: "at://did:plc:replier2/app.bsky.feed.post/mutedwordreply",
        text: "This contains spoilers for the movie",
        authorHandle: "replier2.bsky.social",
        authorDisplayName: "Replier Two",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([postWithReplies, normalReply, mutedWordReply]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: postWithReplies,
        parent: null,
        replies: [
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: normalReply,
            replies: [],
          },
          {
            $type: "app.bsky.feed.defs#threadViewPost",
            post: mutedWordReply,
            replies: [],
          },
        ],
      });
      await mockServer.setup(page);

      // Override preferences to include muted words
      await page.route("**/xrpc/app.bsky.actor.getPreferences*", (route) =>
        route.fulfill({
          status: 200,
          contentType: "application/json",
          body: JSON.stringify({
            preferences: [
              {
                $type: "app.bsky.actor.defs#savedFeedsPrefV2",
                items: [],
              },
              {
                $type: "app.bsky.actor.defs#mutedWordsPref",
                items: [
                  {
                    value: "spoilers",
                    targets: ["content"],
                    actorTarget: "all",
                  },
                ],
              },
            ],
          }),
        }),
      );

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(view).toContainText("Normal reply", { timeout: 10000 });

      // Muted word reply should not be in the main reply chains
      const replyChains = view.locator(".post-thread-reply-chains");
      await expect(replyChains).not.toContainText("spoilers");

      // Hidden section should be present
      const hiddenSection = view.locator("hidden-replies-section");
      await expect(hiddenSection).toBeVisible();
    });
  });

  test.describe("Muted parents", () => {
    test("should expand muted parent when clicking the toggle", async ({
      page,
    }) => {
      const parentPost = createPost({
        uri: "at://did:plc:mutedparent/app.bsky.feed.post/parent1",
        text: "This is a muted parent post",
        authorHandle: "mutedparent.bsky.social",
        authorDisplayName: "Muted Parent",
      });
      parentPost.author.viewer.muted = true;

      const childPost = createPost({
        uri: postUri,
        text: "Reply to muted parent",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        reply: {
          parent: { uri: parentPost.uri, cid: parentPost.cid },
          root: { uri: parentPost.uri, cid: parentPost.cid },
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([childPost, parentPost]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: childPost,
        parent: {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: parentPost,
          parent: null,
          replies: [],
        },
        replies: [],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const toggle = view.locator("muted-parent-toggle");
      await expect(toggle).toBeVisible({ timeout: 10000 });

      // Content should be hidden initially
      await expect(toggle.locator(".toggle-content")).toBeHidden();

      // Click to expand
      await toggle.locator(".muted-parent-toggle-button").click();
      await expect(toggle.locator(".toggle-content")).toBeVisible();
      await expect(toggle).toContainText("This is a muted parent post");
    });

    test("should show 'Hidden by muted word' label for parent with viewer.hasMutedWord", async ({
      page,
    }) => {
      const parentPost = createPost({
        uri: "at://did:plc:mutedwordparent/app.bsky.feed.post/parent1",
        text: "Parent with muted word",
        authorHandle: "mutedwordparent.bsky.social",
        authorDisplayName: "Muted Word Parent",
        viewer: { hasMutedWord: true },
      });

      const childPost = createPost({
        uri: postUri,
        text: "Reply to muted word parent",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        reply: {
          parent: { uri: parentPost.uri, cid: parentPost.cid },
          root: { uri: parentPost.uri, cid: parentPost.cid },
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([childPost, parentPost]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: childPost,
        parent: {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: parentPost,
          parent: null,
          replies: [],
        },
        replies: [],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const toggle = view.locator("muted-parent-toggle");
      await expect(toggle).toBeVisible({ timeout: 10000 });
      await expect(toggle).toContainText("Hidden by muted word");
    });

    test("should show 'Post hidden by you' label for parent with viewer.isHidden", async ({
      page,
    }) => {
      const parentPost = createPost({
        uri: "at://did:plc:hiddenparent/app.bsky.feed.post/parent1",
        text: "Parent hidden by user",
        authorHandle: "hiddenparent.bsky.social",
        authorDisplayName: "Hidden Parent",
        viewer: { isHidden: true },
      });

      const childPost = createPost({
        uri: postUri,
        text: "Reply to hidden parent",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        reply: {
          parent: { uri: parentPost.uri, cid: parentPost.cid },
          root: { uri: parentPost.uri, cid: parentPost.cid },
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([childPost, parentPost]);
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#threadViewPost",
        post: childPost,
        parent: {
          $type: "app.bsky.feed.defs#threadViewPost",
          post: parentPost,
          parent: null,
          replies: [],
        },
        replies: [],
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const toggle = view.locator("muted-parent-toggle");
      await expect(toggle).toBeVisible({ timeout: 10000 });
      await expect(toggle).toContainText("Post hidden by you");
    });
  });

  test.describe("Translate post", () => {
    test("should open Google Translate in a new tab with the post text", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();

      const translateItem = page.locator(
        '[data-testid="menu-action-post-translate"]',
      );
      await expect(translateItem).toBeVisible();

      const popupPromise = page.waitForEvent("popup");
      await translateItem.click();
      const popup = await popupPromise;

      const url = new URL(popup.url());
      expect(url.origin + url.pathname).toBe("https://translate.google.com/");
      expect(url.searchParams.get("sl")).toBe("auto");
      expect(url.searchParams.get("tl")).toBeTruthy();
      expect(url.searchParams.get("text")).toBe("This is the main post");
    });

    test("should not show 'Translate post' for posts with no text", async ({
      page,
    }) => {
      const emptyTextPost = createPost({
        uri: postUri,
        text: "",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([emptyTextPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();

      await expect(
        page.locator('[data-testid="menu-action-post-translate"]'),
      ).toHaveCount(0);
    });
  });

  test.describe("Moderation actions", () => {
    test("should show 'Hide post for me' in context menu for non-user posts", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      // Open context menu
      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator('[data-testid="menu-action-post-hide"]'),
      ).toBeVisible();
    });

    test("should show 'Mute account' for non-muted authors", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator(
          '[data-testid="menu-action-post-mute"][data-teststate="unmuted"]',
        ),
      ).toBeVisible();
    });

    test("should show 'Unmute account' for muted authors", async ({ page }) => {
      const mutedPost = createPost({
        uri: postUri,
        text: "Post by muted author",
        authorHandle: "mutedauthor.bsky.social",
        authorDisplayName: "Muted Author",
      });
      mutedPost.author.viewer.muted = true;

      const mockServer = new MockServer();
      mockServer.addPosts([mutedPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/mutedauthor.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator(
          '[data-testid="menu-action-post-mute"][data-teststate="muted"]',
        ),
      ).toBeVisible();
    });

    test("should show 'Block account' for non-blocked authors", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator(
          '[data-testid="menu-action-post-block"][data-teststate="not-blocking"]',
        ),
      ).toBeVisible();
    });

    test("should show 'Unblock account' for blocked authors", async ({
      page,
    }) => {
      const blockedPost = createPost({
        uri: postUri,
        text: "Post by blocked author",
        authorHandle: "blockedauthor.bsky.social",
        authorDisplayName: "Blocked Author",
      });
      blockedPost.author.viewer.blocking =
        "at://did:plc:testuser123/app.bsky.graph.block/existing-block";

      const mockServer = new MockServer();
      mockServer.addPosts([blockedPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/blockedauthor.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator(
          '[data-testid="menu-action-post-block"][data-teststate="blocking"]',
        ),
      ).toBeVisible();
    });

    test("should show 'Report post' in context menu", async ({ page }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();
      await expect(
        page.locator('[data-testid="menu-action-post-report"]'),
      ).toBeVisible();
    });

    test("should not show moderation actions on current user's own posts", async ({
      page,
    }) => {
      const ownPost = createPost({
        uri: "at://did:plc:testuser123/app.bsky.feed.post/abc123",
        text: "My own post",
        authorHandle: "testuser.bsky.social",
        authorDisplayName: "Test User",
      });

      const mockServer = new MockServer();
      mockServer.addPosts([ownPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/testuser.bsky.social/post/abc123");

      const view = page.locator("#post-detail-view");
      const largePost = view.locator('[data-testid="large-post"]');
      await expect(largePost).toBeVisible({ timeout: 10000 });

      await largePost
        .locator(".post-action-button", { hasText: "..." })
        .click();

      // Moderation actions should not be present on own post
      await expect(
        page.locator('[data-testid="menu-action-post-mute"]'),
      ).not.toBeAttached();
      await expect(
        page.locator('[data-testid="menu-action-post-block"]'),
      ).not.toBeAttached();
      await expect(
        page.locator('[data-testid="menu-action-post-report"]'),
      ).not.toBeAttached();

      // Delete post should be available on own post
      await expect(
        page.locator('[data-testid="menu-action-post-delete"]'),
      ).toBeVisible();
    });
  });

  test.describe("threadgate badge", () => {
    test("shows 'Everybody can reply' when post has no threadgate", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.addPosts([mainPost]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");
      await expect(page.locator('[data-testid="large-post"]')).toBeVisible({
        timeout: 10000,
      });
      const badge = page.locator('[data-testid="who-can-reply-badge"]');
      await expect(badge).toHaveText("Everybody can reply");
      await expect(badge.locator(".globe-icon")).toBeVisible();
    });

    test("shows 'Replies disabled' and modal for empty allow", async ({
      page,
    }) => {
      const post = createPost({
        uri: postUri,
        text: "Locked post",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        threadgate: {
          uri: postUri.replace("feed.post", "feed.threadgate"),
          cid: "bafyt1",
          record: { allow: [] },
          lists: [],
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([post]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const badge = page.locator('[data-testid="who-can-reply-badge"]');
      await expect(badge).toHaveText("Replies disabled", { timeout: 10000 });

      await badge.click();
      const modal = page.locator('[data-testid="who-can-reply-modal"]');
      await expect(modal).toContainText("Replies to this post are disabled.");

      await modal.locator(".primary-button").click();
      await expect(modal).not.toBeAttached();
    });

    test("shows 'Some people can reply' and lists rules in modal", async ({
      page,
    }) => {
      const listUri = "at://did:plc:author1/app.bsky.graph.list/list1";
      const post = createPost({
        uri: postUri,
        text: "Restricted post",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        threadgate: {
          uri: postUri.replace("feed.post", "feed.threadgate"),
          cid: "bafyt2",
          record: {
            allow: [
              { $type: "app.bsky.feed.threadgate#mentionRule" },
              { $type: "app.bsky.feed.threadgate#followingRule" },
              { $type: "app.bsky.feed.threadgate#listRule", list: listUri },
            ],
          },
          lists: [
            {
              uri: listUri,
              cid: "bafyl1",
              name: "Cool people",
              purpose: "app.bsky.graph.defs#curatelist",
            },
          ],
        },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([post]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const badge = page.locator('[data-testid="who-can-reply-badge"]');
      await expect(badge).toHaveText("Some people can reply", {
        timeout: 10000,
      });

      await badge.click();
      const modal = page.locator('[data-testid="who-can-reply-modal"]');
      await expect(modal).toContainText("mentioned users");
      await expect(modal).toContainText(
        "users followed by @author1.bsky.social",
      );
      await expect(modal).toContainText("Cool people members");
    });

    test("shows quote-disabled note in modal when embeddingDisabled", async ({
      page,
    }) => {
      const post = createPost({
        uri: postUri,
        text: "No quotes please",
        authorHandle: "author1.bsky.social",
        authorDisplayName: "Author One",
        viewer: { embeddingDisabled: true },
      });

      const mockServer = new MockServer();
      mockServer.addPosts([post]);
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/author1.bsky.social/post/abc123");

      const badge = page.locator('[data-testid="who-can-reply-badge"]');
      await expect(badge).toHaveText("Everybody can reply", { timeout: 10000 });

      await badge.click();
      const modal = page.locator('[data-testid="who-can-reply-modal"]');
      await expect(modal).toContainText("Everybody can reply to this post.");
      await expect(modal).toContainText(
        "No one but the author can quote this post.",
      );
    });
  });

  test.describe("Tombstone thread root", () => {
    test("should render without crashing when thread root is a notFoundPost", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#notFoundPost",
        uri: postUri,
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/did:plc:author1/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(
        view.locator('[data-testid="post-tombstone-not-found"]'),
      ).toBeVisible({ timeout: 10000 });
      // The view should NOT fall into the generic thread-error template
      await expect(
        view.locator('[data-testid="thread-error"]'),
      ).not.toBeAttached();
      // No reply prompt should be shown for a tombstone root
      await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible();
    });

    test("should render without crashing when thread root is a blockedPost", async ({
      page,
    }) => {
      const mockServer = new MockServer();
      mockServer.setPostThread(postUri, {
        $type: "app.bsky.feed.defs#blockedPost",
        uri: postUri,
        author: {
          did: "did:plc:blockedauthor",
          viewer: {
            blocking:
              "at://did:plc:testuser123/app.bsky.graph.block/existing-block",
          },
        },
      });
      await mockServer.setup(page);

      await login(page);
      await page.goto("/profile/did:plc:author1/post/abc123");

      const view = page.locator("#post-detail-view");
      await expect(
        view.locator('[data-testid="post-tombstone-blocked"]'),
      ).toBeVisible({ timeout: 10000 });
      await expect(
        view.locator('[data-testid="thread-error"]'),
      ).not.toBeAttached();
      await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible();
    });
  });
});
