// @vitest-environment jsdom

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TurnRecord } from "../../../src/app-server/protocol/turn";
import type { ObservedResult } from "../../../src/app-server/query/observed-result";
import type * as ThreadTitleGeneratorModule from "../../../src/app-server/services/thread-title-generation";
import type { Thread } from "../../../src/domain/threads/model";
import { createThreadOperationsTransport, createThreadTitleTransport } from "../../../src/features/threads/app-server/workflow-transports";
import { createThreadNameMutationCoordinator } from "../../../src/features/threads/workflows/thread-name-mutation-coordinator";
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
import { deferred, waitForAsyncWork } from "../../support/async";
import { changeInputValue, installObsidianDomShims } from "../../support/dom";

const connectionMock = vi.hoisted(() => {
  const state = {
    client: null as Record<string, unknown> | null,
    connected: false,
    connectCalls: 0,
    onExit: null as (() => void) | null,
  };

  return {
    state,
    reset(): void {
      state.client = null;
      state.connected = false;
      state.connectCalls = 0;
      state.onExit = null;
    },
  };
});

const namingMock = vi.hoisted(() => ({
  generateThreadTitleWithCodex: vi.fn(),
}));

vi.mock("../../../src/app-server/connection/connection-manager", () => {
  class StaleConnectionError extends Error {}

  class ConnectionManager {
    connect(handlers: { onExit: () => void }): Promise<unknown> {
      connectionMock.state.onExit = handlers.onExit;
      connectionMock.state.connectCalls += 1;
      connectionMock.state.connected = true;
      return Promise.resolve({
        userAgent: "codex-test",
        codexHome: "/tmp/codex",
        platformFamily: "unix",
        platformOs: "macos",
      });
    }

    currentClient(): unknown {
      return connectionMock.state.connected ? connectionMock.state.client : null;
    }

    isConnected(): boolean {
      return connectionMock.state.connected;
    }

    disconnect(): void {
      connectionMock.state.connected = false;
    }
  }

  return { ConnectionManager, StaleConnectionError };
});

vi.mock("../../../src/app-server/services/thread-title-generation", async (importOriginal) => {
  const actual = await importOriginal<typeof ThreadTitleGeneratorModule>();
  return {
    ...actual,
    generateThreadTitleWithCodex: namingMock.generateThreadTitleWithCodex,
  };
});

installObsidianDomShims();

describe("CodexThreadsView", () => {
  beforeEach(() => {
    vi.useRealTimers();
    connectionMock.reset();
    namingMock.generateThreadTitleWithCodex.mockReset();
  });

  it("renders thread list from app-server history", async () => {
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
    });
    const host = threadsHost();
    const view = await threadsView(host);

    await view.refresh();

    expect(view.containerEl.textContent).toContain("Thread preview");
  });

  it("ignores stale refresh results after close", async () => {
    let resolveThreads!: (value: unknown) => void;
    const listThreads = vi.fn(
      () =>
        new Promise((resolve) => {
          resolveThreads = resolve;
        }),
    );
    connectionMock.state.client = clientFixture({
      "thread/list": listThreads,
    });
    const view = await threadsView();

    const refresh = view.refresh();
    await waitForAsyncWork(() => {
      expect(listThreads).toHaveBeenCalled();
    });
    await view.onClose();
    resolveThreads({ data: [threadFixture({ id: "thread", preview: "Late thread" })] });
    await refresh;

    expect(view.containerEl.textContent).not.toContain("Late thread");
  });

  it("renders shared thread refresh failures", async () => {
    const listThreads = vi.fn().mockRejectedValue(new Error("Codex app-server stopped."));
    connectionMock.state.client = clientFixture({
      "thread/list": listThreads,
    });
    const view = await threadsView();

    await view.refresh();

    expect(view.containerEl.textContent).toContain("Codex app-server stopped.");
  });

  it("ignores stale refresh results when a newer refresh completes first", async () => {
    let resolveFirst!: (value: unknown) => void;
    let resolveSecond!: (value: unknown) => void;
    const listThreads = vi
      .fn()
      .mockImplementationOnce(
        () =>
          new Promise((resolve) => {
            resolveFirst = resolve;
          }),
      )
      .mockImplementationOnce(
        () =>
          new Promise((resolve) => {
            resolveSecond = resolve;
          }),
      );
    connectionMock.state.client = clientFixture({
      "thread/list": listThreads,
    });
    const view = await threadsView();

    const firstRefresh = view.refresh();
    await waitForAsyncWork(() => {
      expect(listThreads).toHaveBeenCalledTimes(1);
    });
    const secondRefresh = view.refresh();
    await waitForAsyncWork(() => {
      expect(listThreads).toHaveBeenCalledTimes(2);
    });

    resolveSecond({ data: [threadFixture({ id: "second", preview: "Second thread" })] });
    await secondRefresh;
    expect(view.containerEl.textContent).toContain("Second thread");

    resolveFirst({ data: [threadFixture({ id: "first", preview: "First thread" })] });
    await firstRefresh;
    expect(view.containerEl.textContent).toContain("Second thread");
    expect(view.containerEl.textContent).not.toContain("First thread");
  });

  it("opens selected threads through the shared panel selection path", async () => {
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
    });
    const host = threadsHost({
      openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
    });
    const view = await threadsView(host);

    await view.refresh();
    const row = view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row");
    const rowMain = view.containerEl.querySelector<HTMLElement>(".codex-panel-threads__row-main");
    expect(row?.getAttribute("aria-label")).toBeNull();
    rowMain?.click();

    await waitForAsyncWork(() => {
      expect(host.openThreadInAvailableView).toHaveBeenCalledWith("thread");
    });
  });

  it("opens a new panel from the threads view toolbar", async () => {
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
    });
    const host = threadsHost({
      openNewPanel: vi.fn().mockResolvedValue(undefined),
    });
    const view = await threadsView(host);

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Open new panel"]')?.click();

    await waitForAsyncWork(() => {
      expect(host.openNewPanel).toHaveBeenCalledOnce();
    });
    expect(host.openThreadInAvailableView).not.toHaveBeenCalled();
  });

  it("refreshes threads from the threads view toolbar", async () => {
    const listThreads = vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] });
    connectionMock.state.client = clientFixture({ "thread/list": listThreads });
    const view = await threadsView();

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Refresh threads"]')?.click();

    await waitForAsyncWork(() => {
      expect(listThreads).toHaveBeenCalledTimes(2);
    });
  });

  it("loads another thread page only after the user requests it", async () => {
    const first = threadFromRecord(threadFixture({ id: "first", preview: "First page" }));
    const second = threadFromRecord(threadFixture({ id: "second", preview: "Second page" }));
    let hasMore = true;
    const loadMoreActive = vi.fn(async () => {
      hasMore = false;
      return [first, second];
    });
    const view = await threadsView(
      threadsHost({
        threadCatalog: {
          refreshActive: vi.fn(async () => [first]),
          hasMoreActive: vi.fn(() => hasMore),
          loadMoreActive,
        },
      }),
    );

    await view.refresh();
    expect(view.containerEl.textContent).toContain("First page");
    expect(view.containerEl.textContent).not.toContain("Second page");

    view.containerEl.querySelector<HTMLButtonElement>(".codex-panel-threads__load-more")?.click();
    await waitForAsyncWork(() => {
      expect(loadMoreActive).toHaveBeenCalledOnce();
      expect(view.containerEl.textContent).toContain("Second page");
    });
    expect(view.containerEl.querySelector(".codex-panel-threads__load-more")).toBeNull();
  });

  it("refreshes thread lists through the plugin coordinator", async () => {
    const threads = [{ id: "thread", preview: "Thread preview", name: null, archived: false, createdAt: 1, updatedAt: 1 }];
    const refresh = vi.fn().mockResolvedValue(threads);
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn(),
    });
    const host = threadsHost({
      threadCatalog: {
        refreshActive: refresh,
      },
    });
    const view = await threadsView(host);

    await view.refresh();

    expect(refresh).toHaveBeenCalledOnce();
    expect(view.containerEl.textContent).toContain("Thread preview");
  });

  it("renders cached thread lists before refreshing", async () => {
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn(
        () =>
          new Promise(() => {
            // Keep the app-server refresh pending so the cached snapshot remains visible for this assertion.
          }),
      ),
    });
    const view = await threadsView(
      threadsHost({
        threadCatalog: {
          activeSnapshot: vi.fn(() => [threadFixture({ id: "cached", preview: "Cached thread" })]),
        },
      }),
    );

    await view.onOpen();

    expect(view.containerEl.textContent).toContain("Cached thread");
  });

  it("keeps successful empty thread lists as last-known-good observed values", async () => {
    let observedThreads!: (result: ObservedResult<readonly Thread[]>) => void;
    const view = await threadsView(
      threadsHost({
        threadCatalog: {
          refreshActive: vi.fn(
            () =>
              new Promise(() => {
                // Keep the initial refresh pending; this test drives observed query results directly.
              }),
          ),
          observeActive: vi.fn((listener: (result: ObservedResult<readonly Thread[]>) => void) => {
            observedThreads = listener;
            return () => undefined;
          }),
        },
      }),
    );

    await view.onOpen();
    observedThreads(queryResult([]));
    observedThreads(queryResult<readonly Thread[]>(null, new Error("boom")));

    expect(view.containerEl.textContent).toContain("No threads");
    expect(view.containerEl.textContent).not.toContain("boom");
  });

  it("notifies open panels after archiving a thread", async () => {
    const archiveThread = vi.fn().mockResolvedValue({});
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/archive": archiveThread,
    });
    const applyThreadCatalogEvent = vi.fn();
    const closeOpenPanelsForThread = vi.fn();
    const host = threadsHost({
      threadCatalog: { apply: applyThreadCatalogEvent },
      closeOpenPanelsForThread,
    });
    const view = await threadsView(host);

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Archive thread"]')?.click();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Archive thread without saving"]')?.click();

    await waitForAsyncWork(() => {
      expect(archiveThread).toHaveBeenCalledWith({ threadId: "thread" });
      expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
        type: "thread-archived",
        threadId: "thread",
      });
      expect(closeOpenPanelsForThread).toHaveBeenCalledWith("thread");
    });
  });

  it("notifies open panels after renaming a thread", async () => {
    const renameThreadRequest = vi.fn().mockResolvedValue({});
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/name/set": renameThreadRequest,
    });
    const applyThreadCatalogEvent = vi.fn();
    const host = threadsHost({
      threadCatalog: { apply: applyThreadCatalogEvent },
    });
    const view = await threadsView(host);

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
    expect(input).not.toBeNull();
    if (!input) return;
    changeInputValue(input, "  Renamed   thread  ");
    view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.dispatchEvent(new FocusEvent("blur"));

    await waitForAsyncWork(() => {
      expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Renamed thread" });
      expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
        type: "thread-renamed",
        threadId: "thread",
        name: "Renamed thread",
      });
    });
  });

  it("does not clear a newer rename edit when an older save finishes", async () => {
    const saved = deferred<object>();
    const renameThreadRequest = vi.fn(() => saved.promise);
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/name/set": renameThreadRequest,
    });
    const applyThreadCatalogEvent = vi.fn();
    const host = threadsHost({
      threadCatalog: { apply: applyThreadCatalogEvent },
    });
    const view = await threadsView(host);

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    const firstInput = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
    expect(firstInput).not.toBeNull();
    if (!firstInput) return;
    changeInputValue(firstInput, "  Saved   title  ");
    firstInput.dispatchEvent(new FocusEvent("blur"));
    await waitForAsyncWork(() => {
      expect(renameThreadRequest).toHaveBeenCalledWith({ threadId: "thread", name: "Saved title" });
    });

    firstInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    const secondInput = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
    expect(secondInput).not.toBeNull();
    if (!secondInput) return;
    changeInputValue(secondInput, "New draft");

    saved.resolve({});

    await waitForAsyncWork(() => {
      expect(applyThreadCatalogEvent).toHaveBeenCalledWith({
        type: "thread-renamed",
        threadId: "thread",
        name: "Saved title",
      });
      expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("New draft");
    });
  });

  it("clears old rows and suppresses a delayed rename across an app-server context replacement", async () => {
    let codexPath = "codex-a";
    const saved = deferred<object>();
    const renameThreadRequest = vi.fn(() => saved.promise);
    const oldClient = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread-a", preview: "Old thread" })] }),
      "thread/name/set": renameThreadRequest,
    });
    connectionMock.state.client = oldClient;
    const applyThreadCatalogEvent = vi.fn();
    const host = threadsHost({
      settings: {
        archiveExportEnabled: () => DEFAULT_SETTINGS.archiveExportEnabled,
        codexPath: () => codexPath,
        threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
        threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
        archiveExportSettings: () => ({
          archiveExportFolderTemplate: DEFAULT_SETTINGS.archiveExportFolderTemplate,
          archiveExportFilenameTemplate: DEFAULT_SETTINGS.archiveExportFilenameTemplate,
          archiveExportTags: DEFAULT_SETTINGS.archiveExportTags,
        }),
      },
      threadCatalog: { apply: applyThreadCatalogEvent },
    });
    const view = await threadsView(host);
    await view.onOpen();
    await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("Old thread"));
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
    if (!input) throw new Error("Missing rename input");
    changeInputValue(input, "Renamed old thread");
    input.dispatchEvent(new FocusEvent("blur"));
    await waitForAsyncWork(() => expect(renameThreadRequest).toHaveBeenCalledOnce());

    view.prepareAppServerContextChange();
    expect(view.containerEl.textContent).not.toContain("Old thread");
    codexPath = "codex-b";
    connectionMock.state.client = clientFixture({ "thread/list": vi.fn().mockResolvedValue({ data: [] }) });
    view.refreshSettings();
    saved.resolve({});

    await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("No threads"));
    expect(applyThreadCatalogEvent).not.toHaveBeenCalled();
    expect(view.containerEl.textContent).not.toContain("Old thread");
  });

  it("auto-names a thread rename draft from completed history", async () => {
    const threadTurnsList = vi.fn().mockResolvedValue({
      data: [
        turnFixture([
          {
            type: "userMessage",
            id: "u1",
            clientId: null,
            content: [{ type: "text", text: "threads viewのrenameを直したい", text_elements: [] }],
          },
          { type: "agentMessage", id: "a1", text: "rename UIを調整しました。", phase: "final_answer", memoryCitation: null },
        ]),
      ],
      nextCursor: null,
    });
    namingMock.generateThreadTitleWithCodex.mockResolvedValue("Threads rename UI");
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/turns/list": threadTurnsList,
    });
    const view = await threadsView();

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();

    await waitForAsyncWork(() => {
      expect(threadTurnsList).toHaveBeenCalledWith({
        threadId: "thread",
        cursor: null,
        limit: 20,
        sortDirection: "asc",
        itemsView: "full",
      });
      expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
      expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Threads rename UI");
    });
  });

  it("ignores duplicate auto-name starts while a thread rename is already generating", async () => {
    const threadTurnsList = vi.fn().mockResolvedValue({
      data: [
        turnFixture([
          {
            type: "userMessage",
            id: "u1",
            clientId: null,
            content: [{ type: "text", text: "rename stale handling", text_elements: [] }],
          },
          { type: "agentMessage", id: "a1", text: "Handled.", phase: "final_answer", memoryCitation: null },
        ]),
      ],
      nextCursor: null,
    });
    const generatedTitle = deferred<string | null>();
    namingMock.generateThreadTitleWithCodex.mockReturnValue(generatedTitle.promise);
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/turns/list": threadTurnsList,
    });
    const view = await threadsView();

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    const autoName = view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]');
    autoName?.click();
    autoName?.click();

    await waitForAsyncWork(() => {
      expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
    });

    generatedTitle.resolve("Generated title");
    await waitForAsyncWork(() => {
      expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Generated title");
    });
  });

  it("does not remount the threads view when auto-name finishes after close", async () => {
    const generatedTitle = deferred<string | null>();
    namingMock.generateThreadTitleWithCodex.mockReturnValue(generatedTitle.promise);
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/turns/list": vi.fn().mockResolvedValue({
        data: [
          turnFixture([
            { type: "userMessage", id: "u1", clientId: null, content: [{ type: "text", text: "name this", text_elements: [] }] },
            { type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null },
          ]),
        ],
        nextCursor: null,
      }),
    });
    const view = await threadsView();

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
    await waitForAsyncWork(() => {
      expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
    });
    const generationSignal = namingMock.generateThreadTitleWithCodex.mock.calls[0]?.[4]?.signal as AbortSignal | undefined;
    await view.onClose();
    expect(generationSignal?.aborted).toBe(true);
    generatedTitle.resolve("Late title");
    for (let index = 0; index < 10; index += 1) await Promise.resolve();

    expect(view.containerEl.childElementCount).toBe(0);
    expect(view.containerEl.textContent).not.toContain("Late title");
  });

  it("keeps a manually edited rename draft when threads view auto-name finishes later", async () => {
    const threadTurnsList = vi.fn().mockResolvedValue({
      data: [
        turnFixture([
          {
            type: "userMessage",
            id: "u1",
            clientId: null,
            content: [{ type: "text", text: "rename stale handling", text_elements: [] }],
          },
          { type: "agentMessage", id: "a1", text: "Handled.", phase: "final_answer", memoryCitation: null },
        ]),
      ],
      nextCursor: null,
    });
    const generatedTitle = deferred<string | null>();
    namingMock.generateThreadTitleWithCodex.mockReturnValue(generatedTitle.promise);
    connectionMock.state.client = clientFixture({
      "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
      "thread/turns/list": threadTurnsList,
    });
    const view = await threadsView();

    await view.refresh();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
    view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]')?.click();
    await waitForAsyncWork(() => {
      expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
    });

    const input = view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input");
    expect(input).not.toBeNull();
    if (!input) return;
    changeInputValue(input, "Manual title");
    generatedTitle.resolve("Generated title");

    await waitForAsyncWork(() => {
      expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Manual title");
    });
  });
});

type ThreadRequestHandler = ReturnType<typeof vi.fn<(params: Record<string, unknown>) => unknown>>;

function clientFixture(overrides: Record<string, ThreadRequestHandler> = {}): Record<string, unknown> {
  const handlers: Record<string, ThreadRequestHandler> = {
    "thread/list": vi.fn().mockResolvedValue({ data: [] }),
    "thread/archive": vi.fn().mockResolvedValue({}),
    "thread/name/set": vi.fn().mockResolvedValue({}),
    "thread/turns/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
    ...overrides,
  };
  return {
    request: vi.fn((method: string, params: Record<string, unknown>) => {
      const handler = handlers[method];
      if (!handler) throw new Error(`Unexpected app-server request: ${method}`);
      return handler(params);
    }),
  };
}

function threadsHost(overrides: Record<string, unknown> = {}) {
  const threadCatalogOverrides =
    "threadCatalog" in overrides && typeof overrides["threadCatalog"] === "object" ? overrides["threadCatalog"] : {};
  const hostOverrides = Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== "threadCatalog"));
  const clientAccess = {
    withClient: async <T>(operation: (client: never) => Promise<T>): Promise<T> => {
      const client = connectionMock.state.client;
      if (!client) throw new Error("No current client.");
      return operation(client as never);
    },
  };
  return {
    settings: {
      archiveExportEnabled: () => DEFAULT_SETTINGS.archiveExportEnabled,
      codexPath: () => "codex",
      threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
      threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
      archiveExportSettings: () => ({
        archiveExportFolderTemplate: DEFAULT_SETTINGS.archiveExportFolderTemplate,
        archiveExportFilenameTemplate: DEFAULT_SETTINGS.archiveExportFilenameTemplate,
        archiveExportTags: DEFAULT_SETTINGS.archiveExportTags,
      }),
    },
    vaultPath: "/vault",
    threadNameMutations: createThreadNameMutationCoordinator(),
    threadOperationsTransport: createThreadOperationsTransport(clientAccess),
    threadTitleTransport: createThreadTitleTransport({
      clientAccess,
      codexPath: () => "codex",
      vaultPath: "/vault",
      threadNamingModel: () => DEFAULT_SETTINGS.threadNamingModel,
      threadNamingEffort: () => DEFAULT_SETTINGS.threadNamingEffort,
    }),
    openNewPanel: vi.fn().mockResolvedValue(undefined),
    openThreadInAvailableView: vi.fn().mockResolvedValue(undefined),
    openPanelActivities: vi.fn(() => []),
    closeOpenPanelsForThread: vi.fn(),
    threadCatalog: {
      apply: vi.fn(),
      loadActive: vi.fn(async () => []),
      hasMoreActive: vi.fn(() => false),
      loadMoreActive: vi.fn(async () => []),
      refreshActive: vi.fn(async () => {
        const client = connectionMock.state.client;
        if (!client) return [];
        const request = client["request"] as (
          method: string,
          params: Record<string, unknown>,
        ) => Promise<{
          data: Record<string, unknown>[];
        }>;
        const response = await request("thread/list", { cwd: "/vault", archived: false, cursor: null, limit: 100 });
        return response.data.map(threadFromRecord);
      }),
      activeSnapshot: vi.fn(() => null),
      observeActive: vi.fn(() => () => undefined),
      ...threadCatalogOverrides,
    },
    ...hostOverrides,
  };
}

async function threadsView(host = threadsHost()) {
  const { CodexThreadsView } = await import("../../../src/features/threads-view/view.obsidian");
  const containerEl = document.createElement("div");
  return new CodexThreadsView(
    {
      app: {
        vault: {
          adapter: {},
        },
      },
      containerEl,
    } as never,
    host,
  );
}

function threadFromRecord(record: Record<string, unknown>): Thread {
  return {
    id: String(record["id"]),
    preview: typeof record["preview"] === "string" ? record["preview"] : "",
    name: typeof record["name"] === "string" ? record["name"] : null,
    archived: false,
    provenance: { kind: "interactive" },
    createdAt: Number(record["createdAt"] ?? 0),
    updatedAt: Number(record["updatedAt"] ?? 0),
  };
}

function queryResult<T>(value: T | null, error: Error | null = null): ObservedResult<T> {
  return {
    value,
    error,
    isFetching: false,
  };
}

function threadFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    id: "thread",
    sessionId: "session",
    forkedFromId: null,
    parentThreadId: null,
    preview: "",
    ephemeral: false,
    modelProvider: "openai",
    createdAt: 1,
    updatedAt: 1,
    status: { type: "idle" },
    path: null,
    cwd: "/vault",
    cliVersion: "0.0.0",
    source: "unknown",
    threadSource: null,
    agentNickname: null,
    agentRole: null,
    gitInfo: null,
    name: null,
    turns: [],
    ...overrides,
  };
}

function turnFixture(items: TurnRecord["items"], overrides: Partial<TurnRecord> = {}): TurnRecord {
  return {
    id: "turn",
    items,
    itemsView: "full",
    status: "completed",
    error: null,
    startedAt: 1,
    completedAt: 2,
    durationMs: 1,
    ...overrides,
  };
}
