// @vitest-environment jsdom

import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff";
import {
  canApplySelectionRewrite,
  transitionSelectionRewriteState,
  type SelectionRewriteLifecycleEvent,
  type SelectionRewriteState,
} from "../../../src/features/selection-rewrite/model";
import { selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output";
import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover";
import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position";
import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt";
import * as selectionRewriteRunner from "../../../src/features/selection-rewrite/runner";
import { runSelectionRewrite } from "../../../src/features/selection-rewrite/runner";
import type {
  AppServerClient,
  AppServerClientHandlers,
  AppServerStartStructuredTurnOptions,
} from "../../../src/app-server/connection/client";
import type { RequestId, ServerNotification } from "../../../src/app-server/connection/rpc-messages";
import type { TurnItem, TurnRecord } from "../../../src/app-server/protocol/turn";
import type { ModelMetadata, ReasoningEffort } from "../../../src/domain/catalog/metadata";
import type { ServerInitialization } from "../../../src/domain/server/initialization";
import { deferred } from "../../support/async";
import { installObsidianDomShims } from "../../support/dom";

type InitializeResponse = ServerInitialization;
type ModelListResponse = Awaited<ReturnType<AppServerClient["listModels"]>>;
type ThreadStartResponse = Awaited<ReturnType<AppServerClient["startEphemeralThread"]>>;
type Turn = TurnRecord;
type TurnStartResponse = Awaited<ReturnType<AppServerClient["startStructuredTurn"]>>;
type SelectionRewriteClientFactory = NonNullable<Parameters<typeof runSelectionRewrite>[0]["clientFactory"]>;
type SelectionRewriteClient = ReturnType<SelectionRewriteClientFactory>;

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

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

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("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 lifecycle", () => {
  it("transitions through generation and preview states", () => {
    const generating = transitionSelectionRewriteState(rewriteState({ replacementText: "old", debugText: "old debug" }), {
      type: "generation-started",
      instruction: "Shorten it.",
    });

    expect(generating).toMatchObject({
      instruction: "Shorten it.",
      status: "generating",
      streamText: "",
      replacementText: null,
      debugText: null,
    });

    const streaming = transitionSelectionRewriteState(generating, { type: "preview-updated", text: "New" });
    expect(streaming.streamText).toBe("New");

    const preview = transitionSelectionRewriteState(streaming, { type: "generation-succeeded", replacementText: "New text." });
    expect(preview).toMatchObject({
      status: "preview",
      streamText: "",
      replacementText: "New text.",
    });
  });

  it("ignores stale generation callbacks after generation is no longer active", () => {
    const state = rewriteState({ status: "cancelled", streamText: "" });

    expect(transitionSelectionRewriteState(state, { type: "generation-started", instruction: "late" })).toBe(state);
    expect(transitionSelectionRewriteState(state, { type: "preview-updated", text: "late" })).toBe(state);
    expect(transitionSelectionRewriteState(state, { type: "generation-succeeded", replacementText: "late" })).toBe(state);
    expect(transitionSelectionRewriteState(state, { type: "generation-failed", debugText: "late" })).toBe(state);
  });

  it("marks terminal user actions explicitly", () => {
    expect(transitionSelectionRewriteState(rewriteState(), { type: "cancelled" }).status).toBe("cancelled");
    expect(transitionSelectionRewriteState(rewriteState({ replacementText: "New text." }), { type: "applied" }).status).toBe("applied");
  });

  it.each([{ status: "editing-prompt" }, { status: "preview" }, { status: "failed" }] satisfies {
    status: SelectionRewriteState["status"];
  }[])("allows regeneration from $status state", ({ status }) => {
    const next = transitionSelectionRewriteState(selectionRewriteStateWithStatus(status), {
      type: "generation-started",
      instruction: "Try again.",
    });

    expect(next).toMatchObject({
      status: "generating",
      instruction: "Try again.",
      streamText: "",
      replacementText: null,
      debugText: null,
    });
  });

  it.each([
    { status: "editing-prompt", event: previewUpdatedEvent() },
    { status: "editing-prompt", event: generationSucceededEvent() },
    { status: "editing-prompt", event: generationFailedEvent() },
    { status: "preview", event: previewUpdatedEvent() },
    { status: "preview", event: generationSucceededEvent() },
    { status: "preview", event: generationFailedEvent() },
    { status: "failed", event: previewUpdatedEvent() },
    { status: "failed", event: generationSucceededEvent() },
    { status: "failed", event: generationFailedEvent() },
    { status: "cancelled", event: { type: "generation-started", instruction: "late" } },
    { status: "cancelled", event: previewUpdatedEvent() },
    { status: "cancelled", event: generationSucceededEvent() },
    { status: "cancelled", event: generationFailedEvent() },
    { status: "applied", event: { type: "generation-started", instruction: "late" } },
    { status: "applied", event: previewUpdatedEvent() },
    { status: "applied", event: generationSucceededEvent() },
    { status: "applied", event: generationFailedEvent() },
  ] satisfies { status: SelectionRewriteState["status"]; event: SelectionRewriteLifecycleEvent }[])(
    "preserves state identity for ignored transition from $status via $event.type",
    ({ status, event }) => {
      const state = selectionRewriteStateWithStatus(status);

      expect(transitionSelectionRewriteState(state, event)).toBe(state);
    },
  );
});

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();
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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(selectionRewriteRunner.runSelectionRewrite).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;
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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 };
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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 }>();
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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(selectionRewriteRunner.runSelectionRewrite).toHaveBeenCalledOnce();

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

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

  it("restores the current draft after accidental instruction history navigation", async () => {
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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 () => {
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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 () => {
    vi.spyOn(selectionRewriteRunner, "runSelectionRewrite").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-prompt",
    streamText: "",
    replacementText: null,
    debugText: null,
    ...overrides,
  } as SelectionRewriteState;
}

function selectionRewriteStateWithStatus(status: SelectionRewriteState["status"]): SelectionRewriteState {
  switch (status) {
    case "editing-prompt":
      return rewriteState({ status: "editing-prompt", streamText: "", replacementText: null, debugText: null });
    case "generating":
      return rewriteState({ status: "generating", streamText: "draft", replacementText: null, debugText: null });
    case "preview":
      return rewriteState({ status: "preview", streamText: "", replacementText: "Preview text.", debugText: null });
    case "failed":
      return rewriteState({ status: "failed", streamText: "", replacementText: null, debugText: "debug" });
    case "cancelled":
      return rewriteState({ status: "cancelled", streamText: "partial", replacementText: null, debugText: "debug" });
    case "applied":
      return rewriteState({ status: "applied", streamText: "", replacementText: "Applied text.", debugText: null });
  }
}

function previewUpdatedEvent(): SelectionRewriteLifecycleEvent {
  return { type: "preview-updated", text: "late preview" };
}

function generationSucceededEvent(): SelectionRewriteLifecycleEvent {
  return { type: "generation-succeeded", replacementText: "late replacement" };
}

function generationFailedEvent(): SelectionRewriteLifecycleEvent {
  return { type: "generation-failed", debugText: "late debug" };
}

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 {
    codexPath: "/usr/local/bin/codex",
    cwd: "/vault",
    editor: editorFixture().editor,
    runtimeSettings: { rewriteSelectionModel: null, rewriteSelectionEffort: null },
    sendShortcut: "enter",
    state: rewriteState(),
    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 runOptions(clientFactory: SelectionRewriteClientFactory): Parameters<typeof runSelectionRewrite>[0] {
  return {
    codexPath: "/bin/codex",
    cwd: "/vault",
    prompt: "Rewrite this.",
    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;
  }

  async listModels(): Promise<ModelListResponse> {
    return this.modelList;
  }

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

  async startEphemeralThread(): Promise<ThreadStartResponse> {
    return threadStartResponse("thread");
  }

  async startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<TurnStartResponse> {
    this.startStructuredTurnOptions = options;
    this.resolveStructuredTurnStarted();
    return this.startStructuredTurnImpl ? this.startStructuredTurnImpl() : { turn: turn([], { id: "turn", status: "inProgress" }) };
  }

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

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: "none",
  };
}

function thread(id: string): ThreadStartResponse["thread"] {
  return {
    id,
    sessionId: "session",
    forkedFromId: null,
    parentThreadId: null,
    preview: "",
    ephemeral: true,
    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 {
  return {
    method: "item/completed",
    params: { threadId, turnId, item, completedAtMs: 1 },
  };
}

function turnCompletedNotification(threadId: string, completedTurn: Turn): ServerNotification {
  return {
    method: "turn/completed",
    params: { threadId, turn: completedTurn },
  };
}

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"],
    additionalSpeedTiers: [],
    serviceTiers: [],
    defaultServiceTier: null,
    isDefault: false,
  };
}
