// @vitest-environment jsdom

import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppServerClientHandlers, ClientResponseByMethod, TypedClientRequestMethod } from "../../../src/app-server/connection/client";
import type { ClientRequestParams, RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
import type { AppServerStartStructuredTurnOptions } from "../../../src/app-server/services/turns";
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
import type { ServerInitialization } from "../../../src/domain/server/initialization";
import {
  type AppServerSelectionRewriteTransportOptions,
  createAppServerSelectionRewriteTransport,
} from "../../../src/features/selection-rewrite/app-server-transport";
import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
import { canApplySelectionRewrite, type SelectionRewriteState } from "../../../src/features/selection-rewrite/model";
import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output";
import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover.dom";
import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position.dom";
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
import type { SelectionRewriteTransportRequest } from "../../../src/features/selection-rewrite/transport";
import type { ModelListResponse } from "../../../src/generated/app-server/v2/ModelListResponse";
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
import { deferred } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";

type InitializeResponse = ServerInitialization;
type Turn = TurnRecord;
interface TurnStartResponse {
  turn: TurnRecord;
}
type SelectionRewriteClientFactory = NonNullable<AppServerSelectionRewriteTransportOptions["clientFactory"]>;
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;
type SelectionRewriteTestRunOptions = SelectionRewriteTransportRequest & { clientFactory: SelectionRewriteClientFactory };

const selectionRewriteGenerate = vi.fn();

installObsidianDomShims();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

beforeEach(() => {
  document.body.replaceChildren();
  selectionRewriteGenerate.mockReset();
});

afterEach(() => {
  vi.restoreAllMocks();
});

describe("selection selection rewrite output", () => {
  it("parses valid selection rewrite JSON", () => {
    expect(selectionRewriteOutputParseResultFromText('{"replacementText":"rewritten"}').output).toEqual({ replacementText: "rewritten" });
  });

  it("rejects invalid selection rewrite JSON", () => {
    expect(selectionRewriteOutputParseResultFromText("replacementText: rewritten").output).toBeNull();
    expect(selectionRewriteOutputParseResultFromText('{"replacementText":42}').output).toBeNull();
    expect(selectionRewriteOutputParseResultFromText('{"text":"rewritten"}').output).toBeNull();
  });

  it("keeps raw model text when selection rewrite output parsing fails", () => {
    expect(selectionRewriteOutputParseResultFromText("replacementText: final")).toEqual({
      output: null,
      rawText: "replacementText: final",
    });
  });
});

describe("selection rewrite prompt", () => {
  it("always includes note context with the selected text", () => {
    const prompt = buildSelectionRewritePrompt(rewriteState());

    expect(prompt).toContain("Context mode: Selection + note context");
    expect(prompt).toContain("Current note context:");
    expect(prompt).toContain("# Heading");
  });

  it("uses fences that cannot be closed by note code blocks", () => {
    const prompt = buildSelectionRewritePrompt(
      rewriteState({
        originalText: "```ts\nconst value = 1;\n```",
        noteText: "````markdown\n```ts\nconst value = 1;\n```\n````",
      }),
    );

    expect(prompt).toContain("````text\n```ts");
    expect(prompt).toContain("`````text\n````markdown");
  });

  it("centers long note context around the selection", () => {
    const noteText = `START ONLY\n${"filler\n".repeat(4_000)}Near before\nRevise this sentence.\nNear after`;
    const prompt = buildSelectionRewritePrompt(
      rewriteState({
        noteText,
        targetRange: {
          from: { line: 4_002, ch: 0 },
          to: { line: 4_002, ch: 22 },
        },
      }),
    );

    expect(prompt).toContain("Near before");
    expect(prompt).toContain("Near after");
    expect(prompt).not.toContain("START ONLY");
  });
});

describe("selection rewrite diff", () => {
  it("renders replacements in a selection-scoped unified diff", () => {
    const diff = buildSelectionUnifiedDiff("Note.md", "alpha\nbeta", "alpha\ngamma");

    expect(diff).toContain("diff --git a/Note.md b/Note.md");
    expect(diff).toContain("@@ -1,2 +1,2 @@");
    expect(diff).toContain(" alpha");
    expect(diff).toContain("-beta");
    expect(diff).toContain("+gamma");
  });

  it("orders full replacement blocks as removals before additions", () => {
    const diff = buildSelectionUnifiedDiff(
      "Note.md",
      "これはdiffのテストです。\n今日は元気です。\nとても元気です。",
      "これはdiffのてすとです。\nきょうはげんきです。\nとてもげんきです。",
    );

    expect(diff).toContain(
      [
        "-これはdiffのテストです。",
        "-今日は元気です。",
        "-とても元気です。",
        "+これはdiffのてすとです。",
        "+きょうはげんきです。",
        "+とてもげんきです。",
      ].join("\n"),
    );
  });

  it("bounds large selection diffs to a linear prefix and suffix comparison", () => {
    const originalText = [
      "same start",
      ...Array.from({ length: 160 }, (_unused, index) => `old before ${String(index)}`),
      "shared middle",
      ...Array.from({ length: 160 }, (_unused, index) => `old after ${String(index)}`),
      "same end",
    ].join("\n");
    const replacementText = [
      "same start",
      ...Array.from({ length: 160 }, (_unused, index) => `new before ${String(index)}`),
      "shared middle",
      ...Array.from({ length: 160 }, (_unused, index) => `new after ${String(index)}`),
      "same end",
    ].join("\n");

    const diff = buildSelectionUnifiedDiff("Note.md", originalText, replacementText);

    expect(diff).toContain("\n same start\n");
    expect(diff).toContain("\n-old before 0\n");
    expect(diff).toContain("\n-shared middle\n");
    expect(diff).toContain("\n+shared middle\n");
    expect(diff).toContain("\n same end");
    expect(diff).not.toContain("\n shared middle\n");
  });

  it("renders additions and deletions", () => {
    expect(buildSelectionUnifiedDiff("Note.md", "", "added")).toContain("+added");
    expect(buildSelectionUnifiedDiff("Note.md", "removed", "")).toContain("-removed");
  });

  it("renders unchanged text as context", () => {
    expect(buildSelectionUnifiedDiff("Note.md", "same", "same")).toContain(" same");
  });
});

describe("selection rewrite apply guard", () => {
  it("allows apply only when the current range and surrounding note context still match the original selection", () => {
    const state = rewriteState();
    expect(canApplySelectionRewrite({ currentText: "Revise this sentence.", currentNoteText: state.noteText }, state)).toBe(true);
    expect(canApplySelectionRewrite({ currentText: "Changed sentence.", currentNoteText: state.noteText }, state)).toBe(false);
    expect(
      canApplySelectionRewrite(
        {
          currentText: "Revise this sentence.",
          currentNoteText: "# Heading\n\nRevise this sentence.\n\nEdited next paragraph.",
        },
        state,
      ),
    ).toBe(false);
  });
});

describe("selection rewrite positioning", () => {
  it("uses the target view window HTMLElement constructor for editor DOM checks", () => {
    const frame = document.createElement("iframe");
    document.body.appendChild(frame);
    const viewWindow = expectPresent(frame.contentWindow);
    const viewDocument = expectPresent(frame.contentDocument);
    const root = viewDocument.createElement("div");
    viewDocument.body.appendChild(root);
    const editorDom = viewDocument.createElement("div");
    const editor = {
      cm: {
        dom: editorDom,
      },
      getCursor: () => ({ line: 0, ch: 0 }),
      posToOffset: () => 0,
    };

    expect(positionSelectionRewritePopover(root, editor as never, viewWindow, 8)).toBe(false);
  });
});

describe("selection rewrite runner lifecycle", () => {
  it("keeps pre-acknowledgement completion when turn notifications arrive before turn/start resolves", async () => {
    const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => {
      fake.startStructuredTurnImpl = async () => {
        fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"early"}')));
        fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
        return { turn: turn([], { id: "turn", status: "inProgress" }) };
      };
    });

    await expect(runSelectionRewrite(runOptions(clientFactory))).resolves.toEqual({ replacementText: "early" });
    expect(client.current?.disconnected).toBe(true);
  });

  it("ignores notifications outside the active selection rewrite turn", async () => {
    const previews: string[] = [];
    const { clientFactory, client } = fakeSelectionRewriteClientFactory();
    const rewriting = runSelectionRewrite({
      ...runOptions(clientFactory),
      onPreview: (text) => previews.push(text),
    });

    await expectPresent(client.current).structuredTurnStarted;
    await Promise.resolve();

    const fake = expectPresent(client.current);
    fake.emit(agentDeltaNotification("other-thread", "turn", "ignored"));
    fake.emit(completedItemNotification("thread", "other-turn", agentMessage("wrong", '{"replacementText":"wrong"}')));
    fake.emit(agentDeltaNotification("thread", "turn", '{"replacementText":"stream'));
    fake.emit(agentDeltaNotification("thread", "turn", 'ed"}'));
    fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"final"}')));
    fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));

    await expect(rewriting).resolves.toEqual({ replacementText: "final" });
    expect(previews).toEqual(['{"replacementText":"stream', '{"replacementText":"streamed"}']);
  });

  it("passes validated runtime overrides to the structured rewrite turn", async () => {
    const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => {
      fake.modelList = { data: [modelMetadata("gpt-5.4-mini", ["low", "medium", "high"]) as never], nextCursor: null };
      fake.startStructuredTurnImpl = async () => {
        fake.emit(completedItemNotification("thread", "turn", agentMessage("answer", '{"replacementText":"final"}')));
        fake.emit(turnCompletedNotification("thread", turn([], { id: "turn", status: "completed" })));
        return { turn: turn([], { id: "turn", status: "inProgress" }) };
      };
    });

    await expect(
      runSelectionRewrite({
        ...runOptions(clientFactory),
        runtimeSettings: { rewriteSelectionModel: "gpt-5.4-mini", rewriteSelectionEffort: "minimal" },
      }),
    ).resolves.toEqual({ replacementText: "final" });

    expect(client.current?.startStructuredTurnOptions?.runtime).toEqual({ model: "gpt-5.4-mini" });
  });
});

describe("selection rewrite popover", () => {
  it("enables Generate only after the instruction has content", () => {
    const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));

    openPopover(popover);

    const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
    const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
    expect(generate.disabled).toBe(true);

    void act(() => {
      setTextareaValue(instruction, "Make it concise.");
      instruction.dispatchEvent(new Event("input", { bubbles: true }));
    });

    expect(generate.disabled).toBe(false);

    closePopover(popover);
  });

  it("generates from the Enter shortcut, renders a preview diff, and applies from the action shortcut", async () => {
    const editor = editorFixture();
    const onClose = vi.fn();
    selectionRewriteGenerate.mockImplementation(async (options) => {
      options.onPreview?.("Rewritten sentence.");
      return { replacementText: "Rewritten sentence." };
    });
    const popover = new SelectionRewritePopover(
      popoverOptions({ editor: editor.editor, onClose, state: rewriteState({ instruction: "" }) }),
    );

    openPopover(popover);
    const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
    expect(instruction.getAttribute("aria-label")).toBeNull();
    void act(() => {
      setTextareaValue(instruction, "Make it concise.");
      instruction.dispatchEvent(new Event("input", { bubbles: true }));
    });

    await act(async () => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
      await Promise.resolve();
    });

    expect(selectionRewriteGenerate).toHaveBeenCalledOnce();
    expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten sentence.");
    const apply = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]'));
    expect(apply.disabled).toBe(false);

    await act(async () => {
      apply.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
      await Promise.resolve();
    });

    expect(editor.replaceRange).toHaveBeenCalledWith("Rewritten sentence.", { line: 1, ch: 0 }, { line: 1, ch: 22 }, "codex-panel-rewrite");
    expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
    expect(onClose).toHaveBeenCalledOnce();
  });

  it("refuses to apply when the current editor range no longer matches the original selection", async () => {
    const editor = editorFixture({ currentText: "Changed sentence." });
    const popover = new SelectionRewritePopover(
      popoverOptions({
        editor: editor.editor,
        state: rewriteState({ status: "preview", replacementText: "Rewritten sentence." }),
      }),
    );

    openPopover(popover);
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]')).click();
      await Promise.resolve();
    });

    expect(editor.getRange).toHaveBeenCalledWith({ line: 1, ch: 0 }, { line: 1, ch: 22 });
    expect(editor.replaceRange).not.toHaveBeenCalled();
    expect(document.querySelector(".codex-panel-selection-rewrite")).not.toBeNull();
    expect(document.body.textContent).toContain("Selection changed. Generate the rewrite again before applying.");

    closePopover(popover);
  });

  it("unmounts and removes the Preact popover when closed", () => {
    const onClose = vi.fn();
    const popover = new SelectionRewritePopover(popoverOptions({ onClose }));

    openPopover(popover);
    expect(document.querySelector(".codex-panel-selection-rewrite__instruction")).not.toBeNull();
    expect(document.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')).toBeNull();

    closePopover(popover);

    expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
    expect(onClose).toHaveBeenCalledOnce();
  });

  it("closes from outside pointerdown without closing internal controls", () => {
    const onClose = vi.fn();
    const popover = new SelectionRewritePopover(popoverOptions({ onClose }));

    openPopover(popover);
    const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
    void act(() => {
      generate.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
    });

    expect(document.querySelector(".codex-panel-selection-rewrite")).not.toBeNull();
    expect(onClose).not.toHaveBeenCalled();

    void act(() => {
      document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
    });

    expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
    expect(onClose).toHaveBeenCalledOnce();
  });

  it("aborts the active generation when closed and ignores its late result", async () => {
    const rewrite = deferred<{ replacementText: string }>();
    let signal: AbortSignal | undefined;
    selectionRewriteGenerate.mockImplementation((options) => {
      signal = options.signal;
      return rewrite.promise;
    });
    const popover = new SelectionRewritePopover(popoverOptions());

    openPopover(popover);
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await Promise.resolve();
    });

    closePopover(popover);
    expect(signal?.aborted).toBe(true);

    rewrite.resolve({ replacementText: "Late rewrite" });
    await act(async () => {
      await flushPromises();
    });

    expect(document.querySelector(".codex-panel-selection-rewrite")).toBeNull();
    expect(document.body.textContent).not.toContain("Late rewrite");
  });

  it("ignores late generation preview callbacks after close", async () => {
    const rewrite = deferred<{ replacementText: string }>();
    const options = popoverOptions();
    const preview: { current: ((text: string) => void) | null } = { current: null };
    selectionRewriteGenerate.mockImplementation((runOptions) => {
      preview.current = runOptions.onPreview ?? null;
      return rewrite.promise;
    });
    const popover = new SelectionRewritePopover(options);

    openPopover(popover);
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await Promise.resolve();
    });

    closePopover(popover);
    const latePreview = preview.current;
    if (!latePreview) throw new Error("Expected preview callback to be captured");
    latePreview("Late partial");

    expect(options.state.streamText).toBe("");

    rewrite.resolve({ replacementText: "Late rewrite" });
    await act(async () => {
      await flushPromises();
    });
  });

  it("keeps only one generation run active while a rewrite is pending", async () => {
    const rewrite = deferred<{ replacementText: string }>();
    selectionRewriteGenerate.mockReturnValue(rewrite.promise);
    const popover = new SelectionRewritePopover(popoverOptions());

    openPopover(popover);
    const generate = expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]'));
    await act(async () => {
      generate.click();
      generate.click();
      await Promise.resolve();
    });

    expect(selectionRewriteGenerate).toHaveBeenCalledOnce();

    rewrite.resolve({ replacementText: "Rewritten once." });
    await act(async () => {
      await flushPromises();
    });

    expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten once.");
  });

  it("regenerates from a preview with a new instruction", async () => {
    selectionRewriteGenerate
      .mockResolvedValueOnce({ replacementText: "First rewrite." })
      .mockResolvedValueOnce({ replacementText: "Second rewrite." });
    const popover = new SelectionRewritePopover(popoverOptions());

    openPopover(popover);
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await flushPromises();
    });
    const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
    void act(() => {
      setTextareaValue(instruction, "Try another version.");
      instruction.dispatchEvent(new Event("input", { bubbles: true }));
    });
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Regenerate"]')).click();
      await flushPromises();
    });

    expect(selectionRewriteGenerate).toHaveBeenCalledTimes(2);
    expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Second rewrite.");
    closePopover(popover);
  });

  it("retries after a failed generation and applies only the successful replacement", async () => {
    const editor = editorFixture();
    selectionRewriteGenerate
      .mockRejectedValueOnce(new SelectionRewriteOutputError("Invalid rewrite response.", "raw invalid output"))
      .mockResolvedValueOnce({ replacementText: "Recovered rewrite." });
    const popover = new SelectionRewritePopover(popoverOptions({ editor: editor.editor }));

    openPopover(popover);
    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await flushPromises();
    });

    expect(document.body.textContent).toContain("Invalid rewrite response.");
    expect(document.body.textContent).toContain("raw invalid output");

    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await flushPromises();
    });

    expect(selectionRewriteGenerate).toHaveBeenCalledTimes(2);
    expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Recovered rewrite.");

    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Apply"]')).click();
      await Promise.resolve();
    });

    expect(editor.replaceRange).toHaveBeenCalledWith("Recovered rewrite.", { line: 1, ch: 0 }, { line: 1, ch: 22 }, "codex-panel-rewrite");
  });

  it("restores the current draft after accidental instruction history navigation", async () => {
    selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });

    await generateInstructionHistoryItem("History item one.");
    await generateInstructionHistoryItem("History item two.");

    const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
    openPopover(popover);
    const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
    instruction.setSelectionRange(0, 0);

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
    });

    expect(instruction.value).toBe("History item two.");

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
    });

    expect(instruction.value).toBe("Current draft.");

    closePopover(popover);
  });

  it("preserves edited instruction history drafts while navigating history", async () => {
    selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });

    await generateInstructionHistoryItem("History item one.");
    await generateInstructionHistoryItem("History item two.");

    const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "Current draft." }) }));
    openPopover(popover);
    const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));
    instruction.setSelectionRange(0, 0);

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
      setTextareaValue(instruction, "Edited history draft.");
      instruction.dispatchEvent(new Event("input", { bubbles: true }));
      instruction.setSelectionRange(0, 0);
    });

    expect(instruction.value).toBe("Edited history draft.");

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "p" }));
    });

    expect(instruction.value).toBe("History item one.");

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowDown" }));
    });

    expect(instruction.value).toBe("History item two.");

    void act(() => {
      instruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "n" }));
    });

    expect(instruction.value).toBe("Edited history draft.");

    closePopover(popover);
  });

  it("records edited history items as new entries only when generated", async () => {
    selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." });

    await generateInstructionHistoryItem("Rewrite as bullets.");

    const editingPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
    openPopover(editingPopover);
    const editingInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));

    void act(() => {
      editingInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
      setTextareaValue(editingInstruction, "Rewrite as numbered bullets.");
      editingInstruction.dispatchEvent(new Event("input", { bubbles: true }));
    });

    await act(async () => {
      expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
      await flushPromises();
    });

    closePopover(editingPopover);

    const historyPopover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
    openPopover(historyPopover);
    const historyInstruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));

    void act(() => {
      historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
    });

    expect(historyInstruction.value).toBe("Rewrite as numbered bullets.");

    void act(() => {
      historyInstruction.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
    });

    expect(historyInstruction.value).toBe("Rewrite as bullets.");

    closePopover(historyPopover);
  });
});

function rewriteState(overrides: Partial<SelectionRewriteState> = {}): SelectionRewriteState {
  return {
    filePath: "Note.md",
    targetRange: {
      from: { line: 1, ch: 0 },
      to: { line: 1, ch: 22 },
    },
    originalText: "Revise this sentence.",
    noteText: "# Heading\n\nRevise this sentence.\n\nNext paragraph.",
    instruction: "Make it clearer.",
    status: "editing",
    streamText: "",
    replacementText: null,
    debugText: null,
    ...overrides,
  } as SelectionRewriteState;
}

function openPopover(popover: SelectionRewritePopover): void {
  void act(() => {
    popover.open();
  });
}

function closePopover(popover: SelectionRewritePopover): void {
  void act(() => {
    popover.close();
  });
}

async function generateInstructionHistoryItem(instructionText: string): Promise<void> {
  const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));
  openPopover(popover);
  const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".codex-panel-selection-rewrite__instruction"));

  void act(() => {
    setTextareaValue(instruction, instructionText);
    instruction.dispatchEvent(new Event("input", { bubbles: true }));
  });

  await act(async () => {
    expectPresent(document.querySelector<HTMLButtonElement>('button[aria-label="Generate"]')).click();
    await flushPromises();
  });

  closePopover(popover);
}

function popoverOptions(
  overrides: Partial<ConstructorParameters<typeof SelectionRewritePopover>[0]> = {},
): ConstructorParameters<typeof SelectionRewritePopover>[0] {
  return {
    editor: editorFixture().editor,
    runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
    sendShortcut: "enter",
    state: rewriteState(),
    transport: {
      generate: (request) => selectionRewriteGenerate(request),
    },
    viewDocument: document,
    viewWindow: window,
    ...overrides,
  };
}

function editorFixture(options: { currentText?: string; currentNoteText?: string } = {}): {
  editor: ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"];
  getRange: ReturnType<typeof vi.fn>;
  getValue: ReturnType<typeof vi.fn>;
  replaceRange: ReturnType<typeof vi.fn>;
} {
  const getRange = vi.fn(() => options.currentText ?? "Revise this sentence.");
  const getValue = vi.fn(() => options.currentNoteText ?? rewriteState().noteText);
  const replaceRange = vi.fn();
  return {
    editor: {
      getCursor: () => ({ line: 1, ch: 22 }),
      getRange,
      getValue,
      posToOffset: () => 0,
      replaceRange,
    } as unknown as ConstructorParameters<typeof SelectionRewritePopover>[0]["editor"],
    getRange,
    getValue,
    replaceRange,
  };
}

function setTextareaValue(textarea: HTMLTextAreaElement, value: string): void {
  const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
  if (!descriptor?.set) throw new Error("Expected textarea value setter.");
  const setValue = descriptor.set.bind(textarea) as (nextValue: string) => void;
  setValue(value);
}

function expectPresent<T>(value: T | null | undefined): T {
  if (value === null || value === undefined) throw new Error("Expected value to be present");
  return value;
}

async function flushPromises(): Promise<void> {
  await Promise.resolve();
  await Promise.resolve();
}

function runSelectionRewrite(options: SelectionRewriteTestRunOptions) {
  const { clientFactory, ...request } = options;
  return createAppServerSelectionRewriteTransport({
    codexPath: () => "/bin/codex",
    cwd: "/vault",
    clientFactory,
  }).generate(request);
}

function runOptions(clientFactory: SelectionRewriteClientFactory): SelectionRewriteTestRunOptions {
  return {
    prompt: "Rewrite this.",
    runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
    onActivity: () => undefined,
    onPreview: () => undefined,
    signal: new AbortController().signal,
    clientFactory,
  };
}

function fakeSelectionRewriteClientFactory(configure?: (client: FakeSelectionRewriteClient) => void): {
  clientFactory: SelectionRewriteClientFactory;
  client: { current: FakeSelectionRewriteClient | null };
} {
  const client: { current: FakeSelectionRewriteClient | null } = { current: null };
  return {
    client,
    clientFactory: (_codexPath, _cwd, handlers) => {
      client.current = new FakeSelectionRewriteClient(handlers);
      configure?.(client.current);
      return client.current;
    },
  };
}

class FakeSelectionRewriteClient implements SelectionRewriteClient {
  disconnected = false;
  modelList: ModelListResponse = { data: [], nextCursor: null };
  startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
  startStructuredTurnImpl: (() => Promise<TurnStartResponse>) | null = null;
  readonly structuredTurnStarted: Promise<void>;
  private resolveStructuredTurnStarted!: () => void;

  constructor(private readonly handlers: AppServerClientHandlers) {
    this.structuredTurnStarted = new Promise((resolve) => {
      this.resolveStructuredTurnStarted = resolve;
    });
  }

  async connect(): Promise<InitializeResponse> {
    return { codexHome: "/tmp/codex" } as InitializeResponse;
  }

  disconnect(): void {
    this.disconnected = true;
  }

  rejectServerRequest(_requestId: RequestId, _code: number, _message: string): void {}

  async request<M extends TypedClientRequestMethod>(
    method: M,
    params: ClientRequestParams<M>,
    options: { timeoutMs?: number } = {},
  ): Promise<ClientResponseByMethod[M]> {
    void options;
    switch (method) {
      case "model/list":
        return this.modelList as unknown as ClientResponseByMethod[M];
      case "thread/start":
        return threadStartResponse("thread") as unknown as ClientResponseByMethod[M];
      case "turn/start":
        this.startStructuredTurnOptions = structuredTurnOptionsFromParams(params as ClientRequestParams<"turn/start">);
        this.resolveStructuredTurnStarted();
        return (this.startStructuredTurnImpl
          ? await this.startStructuredTurnImpl()
          : { turn: turn([], { id: "turn", status: "inProgress" }) }) as unknown as ClientResponseByMethod[M];
      case "thread/delete":
        return {} as unknown as ClientResponseByMethod[M];
      default:
        throw new Error(`Unexpected app-server request: ${method}`);
    }
  }

  emit(notification: ServerNotification): void {
    this.handlers.onNotification(notification);
  }
}

function structuredTurnOptionsFromParams(params: ClientRequestParams<"turn/start">): AppServerStartStructuredTurnOptions {
  const textItem = params.input[0];
  if (!params.cwd || !textItem || textItem.type !== "text" || !params.outputSchema) throw new Error("Expected structured turn params.");
  return {
    threadId: params.threadId,
    cwd: params.cwd,
    text: textItem.text,
    outputSchema: params.outputSchema,
    runtime: {
      ...(params.serviceTier !== undefined ? { serviceTier: params.serviceTier } : {}),
      ...(params.collaborationMode !== undefined && params.collaborationMode !== null
        ? { collaborationMode: params.collaborationMode }
        : {}),
      ...(params.model !== undefined ? { model: params.model } : {}),
      ...(params.effort !== undefined ? { effort: params.effort } : {}),
      ...(params.approvalsReviewer !== undefined ? { approvalsReviewer: params.approvalsReviewer } : {}),
    },
  };
}

function threadStartResponse(threadId: string): ThreadStartResponse {
  return {
    thread: thread(threadId),
    model: "gpt-5.1",
    modelProvider: "openai",
    serviceTier: null,
    approvalPolicy: "never",
    cwd: "/vault",
    runtimeWorkspaceRoots: [],
    instructionSources: [],
    approvalsReviewer: "auto_review",
    activePermissionProfile: null,
    sandbox: { type: "readOnly", networkAccess: false },
    reasoningEffort: null,
    multiAgentMode: "explicitRequestOnly",
  };
}

function thread(id: string): ThreadStartResponse["thread"] {
  return {
    id,
    extra: null,
    sessionId: "session",
    forkedFromId: null,
    parentThreadId: null,
    preview: "",
    ephemeral: true,
    historyMode: "paginated",
    modelProvider: "openai",
    createdAt: 1,
    updatedAt: 1,
    recencyAt: null,
    status: { type: "idle" },
    path: null,
    cwd: "/vault",
    cliVersion: "0.0.0",
    source: "unknown",
    threadSource: null,
    agentNickname: null,
    agentRole: null,
    gitInfo: null,
    name: null,
    turns: [],
  };
}

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

function agentMessage(id: string, text: string): TurnItem {
  return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
}

function agentDeltaNotification(threadId: string, turnId: string, delta: string): ServerNotification {
  return {
    method: "item/agentMessage/delta",
    params: { threadId, turnId, itemId: "agent", delta },
  };
}

function completedItemNotification(threadId: string, turnId: string, item: TurnItem): ServerNotification {
  type ItemCompletedNotification = Extract<ServerNotification, { method: "item/completed" }>;
  return {
    method: "item/completed",
    params: { threadId, turnId, item, completedAtMs: 1 } as unknown as ItemCompletedNotification["params"],
  };
}

function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification {
  type TurnCompletedNotification = Extract<ServerNotification, { method: "turn/completed" }>;
  return {
    method: "turn/completed",
    params: { threadId, turn: completedTurn } as unknown as TurnCompletedNotification["params"],
  };
}

function modelMetadata(name: string, efforts: ReasoningEffort[]): ModelMetadata {
  return {
    id: name,
    model: name,
    displayName: name,
    description: "",
    hidden: false,
    supportedReasoningEfforts: efforts,
    defaultReasoningEffort: efforts[0] ?? "low",
    inputModalities: ["text"],
    serviceTiers: [],
    defaultServiceTier: null,
    isDefault: false,
  };
}
