import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../App";
import type { Session } from "../types";
import * as commands from "../lib/commands";
import * as desktopNotifications from "../lib/desktopNotifications";

// --- Module mocks --------------------------------------------------------------

vi.mock("@tauri-apps/api/app", () => ({
  getVersion: vi.fn().mockResolvedValue("0.0.0"),
}));

vi.mock("@tauri-apps/api/core", () => ({
  Channel: class {
    onmessage: ((event: unknown) => void) | null = null;
  },
}));

vi.mock("@tauri-apps/plugin-opener", () => ({
  openUrl: vi.fn(),
}));

vi.mock("@tauri-apps/plugin-dialog", () => ({
  open: vi.fn(),
}));

vi.mock("../lib/commands", () => ({
  listSessions: vi.fn(),
  listConfigs: vi.fn(),
  createSession: vi.fn(),
  approveSession: vi.fn(),
  getSession: vi.fn(),
  getSessionLog: vi.fn(),
  getSessionPlan: vi.fn(),
  getNewSessionHistorySummary: vi.fn().mockResolvedValue({ recentWorkingDirs: [] }),
  getNewSessionConfigDefaults: vi.fn().mockResolvedValue({
    steps: [],
    defaultSkippedSteps: [],
  }),
  listDirectory: vi.fn(),
  getUpdateReadiness: vi.fn(),
  cleanSessions: vi.fn(),
  deleteSession: vi.fn(),
  runSession: vi.fn(),
  cancelSession: vi.fn(),
  resetSession: vi.fn(),
  respondToOption: vi.fn(),
  runAllSessions: vi.fn(),
  fixSession: vi.fn(),
  askSession: vi.fn(),
}));

vi.mock("../lib/updater", () => ({
  checkForUpdate: vi.fn().mockResolvedValue(null),
  downloadAndInstall: vi.fn(),
}));

vi.mock("../lib/desktopNotifications", () => ({
  notifyDesktop: vi.fn(),
}));

// --- Helpers ------------------------------------------------------------------

function setupNewSessionMocks() {
  vi.clearAllMocks();
  vi.mocked(commands.listSessions).mockResolvedValue([]);
  vi.mocked(commands.listConfigs).mockResolvedValue([]);
  vi.mocked(commands.getSessionLog).mockResolvedValue("");
  vi.mocked(commands.getSessionPlan).mockResolvedValue("");
  vi.mocked(commands.getNewSessionHistorySummary).mockResolvedValue({ recentWorkingDirs: [] });
  vi.mocked(commands.getNewSessionConfigDefaults).mockResolvedValue({
    steps: [],
    defaultSkippedSteps: [],
  });
  vi.mocked(commands.listDirectory).mockResolvedValue([]);
  vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
  vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
}

function makeSession(overrides: Partial<Session> = {}): Session {
  return {
    id: "session-1",
    phase: "Planned",
    configSource: "default.yaml",
    baseDir: "/home/user/project",
    input: "test task",
    createdAt: "2026-01-01T00:00:00Z",
    workspaceMode: "Worktree",
    skippedSteps: [],
    ...overrides,
  };
}

const BUILD_ONLY_STEP = [
  {
    id: "build",
    expandedStepIds: ["build"],
    children: [],
  },
];

const BUILD_AND_REVIEW_STEPS = [
  {
    id: "build",
    expandedStepIds: ["build"],
    children: [],
  },
  {
    id: "review-pass",
    expandedStepIds: ["review-pass/simplify"],
    children: [
      {
        id: "review-pass/simplify",
        expandedStepIds: ["review-pass/simplify"],
        children: [],
      },
    ],
  },
];

// --- New Session draft state persistence -------------------------------------

describe("App: New Session draft state persistence", () => {
  beforeEach(setupNewSessionMocks);

  afterEach(() => {
    cleanup();
  });

  it("preserves Task input when navigating to a session and back to New Session", async () => {
    // Given: sidebar has one existing session
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-1", input: "existing task" }),
    ]);
    render(<App />);
    await waitFor(() => screen.getByText("existing task"));

    // Navigate to New Session and type a task
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    const taskTextarea = screen.getByPlaceholderText("Describe what you want to implement...");
    await userEvent.type(taskTextarea, "my draft task");

    // When: navigate to the existing session, then back to New Session
    await userEvent.click(screen.getByRole("button", { name: /existing task/ }));
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // Then: the typed task is preserved
    expect(
      screen.getByPlaceholderText("Describe what you want to implement...")
    ).toHaveValue("my draft task");
  });

  it("preserves Working Directory input when navigating away and back", async () => {
    // Given: sidebar has one existing session
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-1", input: "existing task" }),
    ]);
    render(<App />);
    await waitFor(() => screen.getByText("existing task"));

    // Navigate to New Session and type a working directory
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    const baseDirInput = screen.getByPlaceholderText("e.g. /Users/you/projects/myapp");
    await userEvent.clear(baseDirInput);
    await userEvent.type(baseDirInput, "/my/project/path");

    // When: navigate away then back
    await userEvent.click(screen.getByRole("button", { name: /existing task/ }));
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // Then: the working directory is preserved
    expect(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
    ).toHaveValue("/my/project/path");
  });

  it("does not overwrite user-typed Working Directory with default loaded from history summary on remount", async () => {
    // Given: persisted history has a specific last working directory
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-1", input: "existing task" }),
    ]);
    vi.mocked(commands.getNewSessionHistorySummary).mockResolvedValue({
      lastWorkingDir: "/from/history",
      recentWorkingDirs: ["/from/history"],
    });
    render(<App />);
    await waitFor(() => screen.getByText("existing task"));

    // Navigate to New Session, type a working directory, then navigate away
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    const baseDirInput = screen.getByPlaceholderText("e.g. /Users/you/projects/myapp");
    await userEvent.clear(baseDirInput);
    await userEvent.type(baseDirInput, "/my/typed/dir");
    await userEvent.click(screen.getByRole("button", { name: /existing task/ }));

    // When: navigate back to New Session (triggers remount, history summary loads again)
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await act(async () => { await new Promise<void>((r) => setTimeout(r, 50)); });

    // Then: the user-typed value is NOT overwritten by the persisted default
    expect(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
    ).toHaveValue("/my/typed/dir");
  });

  it("applies persisted config and working directory defaults when the draft is empty", async () => {
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { name: "team.yaml", path: "/Users/takumi/.cruise/team.yaml" },
    ]);
    vi.mocked(commands.getNewSessionHistorySummary).mockResolvedValue({
      lastRequestedConfigPath: "/Users/takumi/.cruise/team.yaml",
      lastWorkingDir: "/repos/cruise",
      recentWorkingDirs: ["/repos/cruise", "/repos/other"],
    });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    await waitFor(() => {
      expect(screen.getByLabelText("Config")).toHaveValue("/Users/takumi/.cruise/team.yaml");
    });
    expect(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
    ).toHaveValue("/repos/cruise");
    expect(screen.getByRole("button", { name: "/repos/other" })).toBeInTheDocument();
  });

  it("shows recent working directories and lets the user reselect one", async () => {
    vi.mocked(commands.getNewSessionHistorySummary).mockResolvedValue({
      recentWorkingDirs: ["/repos/a", "/repos/b"],
    });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    await userEvent.click(screen.getByRole("button", { name: "/repos/b" }));
    expect(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
    ).toHaveValue("/repos/b");
  });

  it("loads skip-step defaults from the resolved config, including auto mode", async () => {
    vi.mocked(commands.getNewSessionConfigDefaults).mockResolvedValue({
      steps: [
        { id: "write-tests", expandedStepIds: ["write-tests"], children: [] },
        { id: "implement", expandedStepIds: ["implement"], children: [] },
      ],
      defaultSkippedSteps: ["write-tests"],
    });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    const writeTests = await screen.findByRole("checkbox", { name: "write-tests" });
    const implement = screen.getByRole("checkbox", { name: "implement" });
    expect(writeTests).toBeChecked();
    expect(implement).not.toBeChecked();
  });

});

describe("App: New Session skip-step selection", () => {
  beforeEach(setupNewSessionMocks);

  afterEach(() => {
    cleanup();
  });

  it("renders tree steps and applies history-based default skip selections on config refresh", async () => {
    vi.mocked(commands.getNewSessionConfigDefaults)
      .mockResolvedValueOnce({
        steps: BUILD_AND_REVIEW_STEPS,
        defaultSkippedSteps: ["build"],
      })
      .mockResolvedValueOnce({
        steps: BUILD_ONLY_STEP,
        defaultSkippedSteps: [],
      });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    const buildCheckbox = await screen.findByRole("checkbox", { name: "build" });
    expect(buildCheckbox).toBeChecked();

    fireEvent.change(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp"),
      { target: { value: "/tmp/other-repo" } }
    );

    await waitFor(() => {
      expect(commands.getNewSessionConfigDefaults).toHaveBeenLastCalledWith({
        baseDir: "/tmp/other-repo",
        configPath: undefined,
      });
    });
    expect(screen.getByRole("checkbox", { name: "build" })).not.toBeChecked();
  });

  it("does not refetch config steps when baseDir changes under an explicit config", async () => {
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { path: "/tmp/custom.yaml", name: "custom.yaml" },
    ]);
    vi.mocked(commands.getNewSessionConfigDefaults).mockResolvedValue({
      steps: BUILD_ONLY_STEP,
      defaultSkippedSteps: [],
    });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/custom.yaml");
    await waitFor(() => expect(screen.getByLabelText("build")).toBeInTheDocument());

    const callCountBeforeBaseDirEdit =
      vi.mocked(commands.getNewSessionConfigDefaults).mock.calls.length;

    fireEvent.change(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp"),
      { target: { value: "/tmp/new-worktree" } }
    );

    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
      ).toHaveValue("/tmp/new-worktree");
    });
    expect(vi.mocked(commands.getNewSessionConfigDefaults).mock.calls).toHaveLength(
      callCountBeforeBaseDirEdit,
    );
  });
});

// --- Skip Steps following Config selector changes ----------------------------

describe("App: New Session skip-step selection — Config change", () => {
  beforeEach(setupNewSessionMocks);

  afterEach(() => {
    cleanup();
  });

  it("replaces Skip Steps candidates with the new config's steps when switching explicit configs", async () => {
    // Given: two explicit configs — configA has BUILD_AND_REVIEW_STEPS, configB has BUILD_ONLY_STEP
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { path: "/tmp/configA.yaml", name: "configA.yaml" },
      { path: "/tmp/configB.yaml", name: "configB.yaml" },
    ]);
    vi.mocked(commands.getNewSessionConfigDefaults)
      .mockResolvedValueOnce({ steps: [], defaultSkippedSteps: [] })               // initial auto
      .mockResolvedValueOnce({ steps: BUILD_AND_REVIEW_STEPS, defaultSkippedSteps: ["build"] }) // select configA
      .mockResolvedValueOnce({ steps: BUILD_ONLY_STEP, defaultSkippedSteps: [] }); // select configB

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // When: select configA
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/configA.yaml");

    // Then: configA's steps appear with "build" checked by default
    expect(await screen.findByRole("checkbox", { name: "build" })).toBeChecked();
    expect(screen.getByRole("checkbox", { name: "review-pass" })).toBeInTheDocument();

    // When: switch to configB
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/configB.yaml");

    // Then: only configB's steps are shown — "review-pass" is gone, "build" is unchecked
    await waitFor(() => {
      expect(screen.queryByRole("checkbox", { name: "review-pass" })).not.toBeInTheDocument();
    });
    expect(screen.getByRole("checkbox", { name: "build" })).not.toBeChecked();
    expect(commands.getNewSessionConfigDefaults).toHaveBeenLastCalledWith({
      baseDir: ".",
      configPath: "/tmp/configB.yaml",
    });
  });

  it("updates Skip Steps when switching from auto mode to an explicit config", async () => {
    // Given: auto mode resolves BUILD_AND_REVIEW_STEPS; explicit config resolves BUILD_ONLY_STEP
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { path: "/tmp/custom.yaml", name: "custom.yaml" },
    ]);
    vi.mocked(commands.getNewSessionConfigDefaults)
      .mockResolvedValueOnce({ steps: BUILD_AND_REVIEW_STEPS, defaultSkippedSteps: ["build"] }) // initial auto
      .mockResolvedValueOnce({ steps: BUILD_ONLY_STEP, defaultSkippedSteps: [] });               // select explicit

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // And: auto-mode steps are shown initially
    await screen.findByRole("checkbox", { name: "build" });
    expect(screen.getByRole("checkbox", { name: "review-pass" })).toBeInTheDocument();
    expect(screen.getByRole("checkbox", { name: "build" })).toBeChecked();

    // When: user selects the explicit config
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/custom.yaml");

    // Then: explicit config's steps replace the auto ones — "review-pass" gone, "build" unchecked
    await waitFor(() => {
      expect(screen.queryByRole("checkbox", { name: "review-pass" })).not.toBeInTheDocument();
    });
    expect(screen.getByRole("checkbox", { name: "build" })).not.toBeChecked();
    expect(commands.getNewSessionConfigDefaults).toHaveBeenLastCalledWith({
      baseDir: ".",
      configPath: "/tmp/custom.yaml",
    });
  });

  it("restores auto-resolved Skip Steps when deselecting an explicit config back to Auto", async () => {
    // Given: explicit config has BUILD_ONLY_STEP; auto mode has BUILD_AND_REVIEW_STEPS
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { path: "/tmp/custom.yaml", name: "custom.yaml" },
    ]);
    vi.mocked(commands.getNewSessionConfigDefaults)
      .mockResolvedValueOnce({ steps: [], defaultSkippedSteps: [] })                              // initial auto (no steps)
      .mockResolvedValueOnce({ steps: BUILD_ONLY_STEP, defaultSkippedSteps: [] })                  // select explicit
      .mockResolvedValueOnce({ steps: BUILD_AND_REVIEW_STEPS, defaultSkippedSteps: ["build"] });   // back to auto

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // Select explicit config
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/custom.yaml");
    await screen.findByRole("checkbox", { name: "build" });
    expect(screen.queryByRole("checkbox", { name: "review-pass" })).not.toBeInTheDocument();

    // When: switch back to Auto
    await userEvent.selectOptions(screen.getByLabelText("Config"), "");

    // Then: auto-mode steps appear — "review-pass" is back and "build" is checked by default
    await screen.findByRole("checkbox", { name: "review-pass" });
    expect(screen.getByRole("checkbox", { name: "build" })).toBeChecked();
    expect(commands.getNewSessionConfigDefaults).toHaveBeenLastCalledWith({
      baseDir: ".",
      configPath: undefined,
    });
  });

  it("does not carry over user-checked steps from the previous config when switching configs", async () => {
    // Given: configA has BUILD_AND_REVIEW_STEPS (no defaults); configB has BUILD_ONLY_STEP (no defaults)
    vi.mocked(commands.listConfigs).mockResolvedValue([
      { path: "/tmp/configA.yaml", name: "configA.yaml" },
      { path: "/tmp/configB.yaml", name: "configB.yaml" },
    ]);
    vi.mocked(commands.getNewSessionConfigDefaults)
      .mockResolvedValueOnce({ steps: [], defaultSkippedSteps: [] })                  // initial auto
      .mockResolvedValueOnce({ steps: BUILD_AND_REVIEW_STEPS, defaultSkippedSteps: [] }) // select configA
      .mockResolvedValueOnce({ steps: BUILD_ONLY_STEP, defaultSkippedSteps: [] });       // select configB

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    // Select configA and manually check "build"
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/configA.yaml");
    const buildCheckbox = await screen.findByRole("checkbox", { name: "build" });
    await userEvent.click(buildCheckbox);
    expect(buildCheckbox).toBeChecked();

    // When: switch to configB
    await userEvent.selectOptions(screen.getByLabelText("Config"), "/tmp/configB.yaml");

    // Then: configB's default (unchecked) is applied — the manual check is not carried over
    await waitFor(() => {
      expect(screen.queryByRole("checkbox", { name: "review-pass" })).not.toBeInTheDocument();
    });
    expect(screen.getByRole("checkbox", { name: "build" })).not.toBeChecked();
  });
});

// --- Non-blocking session creation -------------------------------------------

/**
 * Set up the createSession mock to support a two-phase emit model:
 *  1. sessionCreated fires immediately after session is persisted - the frontend
 *     should release the New Session form at this point.
 *  2. planGenerated / planFailed fire later, after the form has already been reset.
 *
 * The mock captures the channel reference and returns control handles so tests
 * can fire each event at an explicit moment.
 */
function setupTwoPhaseCreateSession(sessionId = "new-sess-id") {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  let capturedChannel: { onmessage: ((event: any) => void) | null } | null = null;
  let resolveCreate!: (id: string) => void;

  vi.mocked(commands.createSession).mockImplementationOnce(
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (_params: any, channel: any) => {
      capturedChannel = channel;
      return new Promise<string>((resolve) => {
        resolveCreate = resolve;
      });
    }
  );

  return {
    /** Emit sessionCreated - session has been persisted, plan not yet ready. */
    emitSessionCreated(): void {
      capturedChannel!.onmessage?.({ event: "sessionCreated", data: { sessionId } });
    },
    /** Emit planGenerated and resolve the pending createSession promise. */
    emitPlanGenerated(content = "# Plan content"): void {
      capturedChannel!.onmessage?.({ event: "planGenerated", data: { sessionId, content } });
      resolveCreate(sessionId);
    },
    /** Emit planFailed and resolve the pending createSession promise. */
    emitPlanFailed(error = "plan generation failed"): void {
      capturedChannel!.onmessage?.({ event: "planFailed", data: { sessionId, error } });
      resolveCreate(sessionId);
    },
  };
}

describe("App: Non-blocking session creation", () => {
  beforeEach(setupNewSessionMocks);

  afterEach(() => {
    cleanup();
  });

  it("resets task input after sessionCreated, before plan generation resolves", async () => {
    // Given: createSession emits sessionCreated before planGenerated
    const control = setupTwoPhaseCreateSession("sess-early");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "my task"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // When: sessionCreated fires (session is persisted, plan not yet ready)
    await act(async () => {
      control.emitSessionCreated();
    });

    // Then: task input is cleared (form released before plan is ready)
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });

    // Cleanup: resolve the pending createSession so the test does not leak
    await act(async () => {
      control.emitPlanGenerated();
    });
  });

  it("Generate plan button is re-enabled after sessionCreated and typing a new task", async () => {
    // Given: createSession is pending after sessionCreated
    const control = setupTwoPhaseCreateSession("sess-early");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "another task"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // When: sessionCreated fires and the form is released (input cleared)
    await act(async () => {
      control.emitSessionCreated();
    });
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });

    // When: user types a new task
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "next task"
    );

    // Then: Generate plan button is enabled
    expect(screen.getByRole("button", { name: "Generate plan" })).not.toBeDisabled();

    // Cleanup
    await act(async () => {
      control.emitPlanGenerated();
    });
  });

  it("preserves baseDir after sessionCreated clears task-scoped fields", async () => {
    // Given: form has a custom Working Directory before generate is clicked
    const control = setupTwoPhaseCreateSession("sess-early");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    const baseDirInput = screen.getByPlaceholderText("e.g. /Users/you/projects/myapp");
    await userEvent.clear(baseDirInput);
    await userEvent.type(baseDirInput, "/my/repo/path");

    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "first task"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // When: sessionCreated fires
    await act(async () => {
      control.emitSessionCreated();
    });

    // Then: task input is cleared but baseDir is preserved for the next session
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });
    expect(
      screen.getByPlaceholderText("e.g. /Users/you/projects/myapp")
    ).toHaveValue("/my/repo/path");

    // Cleanup
    await act(async () => {
      control.emitPlanGenerated();
    });
  });

  it("refreshes recent working directories after sessionCreated", async () => {
    const control = setupTwoPhaseCreateSession("sess-history");
    vi.mocked(commands.getNewSessionHistorySummary)
      .mockResolvedValueOnce({
        recentWorkingDirs: ["/repos/old"],
      })
      .mockResolvedValueOnce({
        lastWorkingDir: "/repos/new",
        recentWorkingDirs: ["/repos/new", "/repos/old"],
      });

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));

    const baseDirInput = screen.getByPlaceholderText("e.g. /Users/you/projects/myapp");
    await userEvent.clear(baseDirInput);
    await userEvent.type(baseDirInput, "/repos/new");
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "history refresh",
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    await act(async () => {
      control.emitSessionCreated();
    });

    await waitFor(() => {
      expect(screen.getByRole("button", { name: "/repos/new" })).toBeInTheDocument();
    });
    expect(screen.getByRole("button", { name: "/repos/old" })).toBeInTheDocument();

    await act(async () => {
      control.emitPlanGenerated();
    });
  });

  it("late planFailed does not restore old task input after form was released by sessionCreated", async () => {
    // Given: sessionCreated has already reset the form
    const control = setupTwoPhaseCreateSession("sess-fail");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "task that will fail"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    await act(async () => {
      control.emitSessionCreated();
    });
    // Verify form was released
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });

    // When: planFailed fires late (after form was already released)
    await act(async () => {
      control.emitPlanFailed("model error");
    });

    // Then: task input stays empty - old draft must not be restored
    expect(
      screen.getByPlaceholderText("Describe what you want to implement...")
    ).toHaveValue("");
    // And: still on the New Session form so the user can start a fresh session
    expect(screen.getByRole("button", { name: "Generate plan" })).toBeInTheDocument();
  });

  it("late planFailed triggers sidebar refresh after form was released", async () => {
    // Given: form is released by sessionCreated
    const control = setupTwoPhaseCreateSession("sess-fail");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "task that will fail"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    await act(async () => {
      control.emitSessionCreated();
    });
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });

    const callsBeforePlanFailed = vi.mocked(commands.listSessions).mock.calls.length;

    // When: planFailed fires late
    await act(async () => {
      control.emitPlanFailed("model error");
    });

    // Then: sidebar is refreshed so the backend-deleted failed session disappears promptly
    await waitFor(() => {
      expect(vi.mocked(commands.listSessions).mock.calls.length).toBeGreaterThan(
        callsBeforePlanFailed
      );
    });
  });

  it("late planGenerated triggers sidebar refresh without mutating the form", async () => {
    // Given: form is released by sessionCreated; plan arrives later
    const control = setupTwoPhaseCreateSession("sess-async");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "async task"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // sessionCreated: form resets
    await act(async () => {
      control.emitSessionCreated();
    });
    await waitFor(() => {
      expect(
        screen.getByPlaceholderText("Describe what you want to implement...")
      ).toHaveValue("");
    });

    const callsBeforePlanGenerated = vi.mocked(commands.listSessions).mock.calls.length;

    // When: planGenerated fires late
    await act(async () => {
      control.emitPlanGenerated("# Plan content");
    });

    // Then: sidebar is refreshed so planAvailable becomes visible immediately
    await waitFor(() => {
      expect(vi.mocked(commands.listSessions).mock.calls.length).toBeGreaterThan(
        callsBeforePlanGenerated
      );
    });

    // And: form input remains clean (late event must not mutate the draft)
    expect(
      screen.getByPlaceholderText("Describe what you want to implement...")
    ).toHaveValue("");
  });

  it("sidebar is refreshed immediately after sessionCreated without waiting for plan", async () => {
    // Given
    const control = setupTwoPhaseCreateSession("sess-refresh");

    render(<App />);
    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "refresh test task"
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    const callsBeforeSessionCreated = vi.mocked(commands.listSessions).mock.calls.length;

    // When: sessionCreated fires (plan not yet ready)
    await act(async () => {
      control.emitSessionCreated();
    });

    // Then: sidebar refreshes immediately (explicit refresh, not relying on 3-second poll)
    await waitFor(() => {
      expect(vi.mocked(commands.listSessions).mock.calls.length).toBeGreaterThan(
        callsBeforeSessionCreated
      );
    });

    // Cleanup
    await act(async () => {
      control.emitPlanGenerated();
    });
  });
});

// --- WorkflowRunner tab selection persistence ---------------------------------

describe("App: WorkflowRunner tab selection persistence", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("log line 1");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
  });

  afterEach(() => {
    cleanup();
  });

  it("remembers Plan tab for Session A when switching to Session B and back", async () => {
    // Given: two sessions A and B in the sidebar
    const sessA = makeSession({ id: "sess-a", input: "task A" });
    const sessB = makeSession({ id: "sess-b", input: "task B" });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);

    render(<App />);
    await waitFor(() => screen.getByText("task A"));

    // Select Session A — Plan tab is the default
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));

    // Verify Plan tab is active: Info tab's "Base dir" label is not shown
    await waitFor(() => {
      expect(screen.queryByText("Base dir")).toBeNull();
    });
    expect(screen.getByText("No plan available.")).toBeInTheDocument();

    // Navigate to Session B
    await userEvent.click(screen.getByRole("button", { name: /task B/ }));
    await waitFor(() => screen.getByRole("tab", { name: "Info" }));

    // When: go back to Session A
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));
    await waitFor(() => screen.getByRole("tab", { name: "Plan" }));

    // Then: Session A should still show Plan tab, not Info tab
    // "Base dir" label only appears in the Info tab
    expect(screen.queryByText("Base dir")).toBeNull();
    expect(screen.getByText("No plan available.")).toBeInTheDocument();
  });

  it("remembers Log tab for Session B when switching to Session A and back", async () => {
    // Given: two sessions A and B
    const sessA = makeSession({ id: "sess-a", input: "task A" });
    const sessB = makeSession({ id: "sess-b", input: "task B" });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("log line 1\nlog line 2");

    render(<App />);
    await waitFor(() => screen.getByText("task B"));

    // Select Session B and switch to Log tab
    await userEvent.click(screen.getByRole("button", { name: /task B/ }));
    await waitFor(() => screen.getByRole("tab", { name: "Log" }));
    await userEvent.click(screen.getByRole("tab", { name: "Log" }));

    // Verify Log tab is active: Info tab's "Base dir" label is not shown
    await waitFor(() => {
      expect(screen.queryByText("Base dir")).toBeNull();
    });

    // When: navigate to Session A, then back to Session B
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));
    await userEvent.click(screen.getByRole("button", { name: /task B/ }));

    // Then: Session B should still show Log tab, not Info tab
    await waitFor(() => {
      expect(screen.queryByText("Base dir")).toBeNull();
    });
  });

  it("loads plan content when returning to session with remembered Plan tab", async () => {
    // Given: session A and session B
    const sessA = makeSession({ id: "sess-a", input: "task A", planAvailable: true });
    const sessB = makeSession({ id: "sess-b", input: "task B" });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);
    vi.mocked(commands.getSessionPlan).mockResolvedValue("# Loaded plan");

    render(<App />);
    await waitFor(() => screen.getByText("task A"));

    // Select Session A — Plan tab is the default and triggers initial loadPlan
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));
    await waitFor(() => expect(commands.getSessionPlan).toHaveBeenCalledWith("sess-a"));

    // Reset the call count to track new calls
    vi.mocked(commands.getSessionPlan).mockClear();

    // Navigate to Session B, then back to Session A
    await userEvent.click(screen.getByRole("button", { name: /task B/ }));
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));

    // Then: getSessionPlan is called again to reload plan content on return
    // (Plan tab is still remembered for Session A, so lazy load triggers)
    await waitFor(() => {
      expect(commands.getSessionPlan).toHaveBeenCalledWith("sess-a");
    });
  });
});

// --- Approval-ready notification transitions ----------------------------------

describe("App: Approval-ready notification transitions", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
  });

  afterEach(() => {
    cleanup();
  });

  it("emits plan-ready toast when session transitions to approval-ready after planGenerated", async () => {
    // Given: no sessions in initial sidebar, then plan becomes available
    const control = setupTwoPhaseCreateSession("sess-plan-ready");

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: "+ New" }));

    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "needs approval",
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // sessionCreated: session appears in sidebar but plan not yet ready (Planning in UI)
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-plan-ready", phase: "Awaiting Approval", planAvailable: false }),
    ]);
    await act(async () => { control.emitSessionCreated(); });
    await waitFor(() => {
      expect(screen.getByPlaceholderText("Describe what you want to implement...")).toHaveValue("");
    });

    // When: planGenerated fires → session becomes approval-ready (planAvailable: true)
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-plan-ready", phase: "Awaiting Approval", planAvailable: true }),
    ]);
    await act(async () => { control.emitPlanGenerated(); });

    // Then: plan-ready toast appears (transition detected by snapshot detector)
    await waitFor(() => expect(screen.getByText("Plan ready")).toBeInTheDocument());
  });

  it("does not emit plan-ready notification at sessionCreated when plan is not yet available", async () => {
    // Given: session becomes visible after sessionCreated but is still in Planning state
    const control = setupTwoPhaseCreateSession("sess-planning");

    render(<App />);
    await waitFor(() => screen.getByRole("button", { name: "+ New" }));

    await userEvent.click(screen.getByRole("button", { name: "+ New" }));
    await userEvent.type(
      screen.getByPlaceholderText("Describe what you want to implement..."),
      "planning task",
    );
    await userEvent.click(screen.getByRole("button", { name: "Generate plan" }));

    // When: sessionCreated fires → session has planAvailable: false (Planning in UI)
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-planning", phase: "Awaiting Approval", planAvailable: false }),
    ]);
    await act(async () => { control.emitSessionCreated(); });
    await waitFor(() => {
      expect(screen.getByPlaceholderText("Describe what you want to implement...")).toHaveValue("");
    });

    // Then: no plan-ready toast (session not approval-ready yet)
    expect(screen.queryByText("Plan ready")).not.toBeInTheDocument();

    // Cleanup: resolve pending createSession
    await act(async () => { control.emitPlanFailed(); });
  });

  it("does not emit plan-ready notification for sessions already approval-ready on app startup", async () => {
    // Given: app starts with a pre-existing approval-ready session
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "existing-approved", phase: "Awaiting Approval", planAvailable: true }),
    ]);

    render(<App />);
    // Wait for initial load to complete (session should appear in sidebar)
    await waitFor(() => screen.getByText("test task"));
    await act(async () => { await new Promise<void>((r) => setTimeout(r, 20)); });

    // Then: no plan-ready toast (startup suppression: first snapshot is never notified)
    expect(screen.queryByText("Plan ready")).not.toBeInTheDocument();
    expect(vi.mocked(desktopNotifications.notifyDesktop)).not.toHaveBeenCalledWith(
      expect.anything(),
      expect.stringContaining("Plan ready"),
    );
  });
});

// --- Plan tab as default and plan-availability gating -------------------------

describe("App: Plan tab as default and plan-availability gating", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(commands.listSessions).mockResolvedValue([]);
    vi.mocked(commands.listConfigs).mockResolvedValue([]);
    vi.mocked(commands.getSessionLog).mockResolvedValue("");
    vi.mocked(commands.getSessionPlan).mockResolvedValue("");
    vi.mocked(commands.listDirectory).mockResolvedValue([]);
    vi.mocked(commands.getUpdateReadiness).mockResolvedValue({ canAutoUpdate: true });
    vi.mocked(commands.cleanSessions).mockResolvedValue({ deleted: 0, skipped: 0 });
  });

  afterEach(() => {
    cleanup();
  });

  it("defaults to Plan tab when first opening a session with planAvailable: true", async () => {
    // Given: a session that has a plan available
    const sess = makeSession({ id: "sess-plan", planAvailable: true });
    vi.mocked(commands.listSessions).mockResolvedValue([sess]);
    vi.mocked(commands.getSessionPlan).mockResolvedValue("# My Plan");

    render(<App />);
    await waitFor(() => screen.getByText("test task"));

    // When: open the session for the first time (no remembered tab)
    await userEvent.click(screen.getByRole("button", { name: /test task/ }));

    // Then: Plan tab is active by default — Info tab's "Base dir" label is not visible
    await waitFor(() => {
      expect(screen.queryByText("Base dir")).toBeNull();
    });
    // And: getSessionPlan was called because planAvailable is true
    await waitFor(() => {
      expect(commands.getSessionPlan).toHaveBeenCalledWith("sess-plan");
    });
  });

  it("does not call getSessionPlan when opening a session with planAvailable: false", async () => {
    // Given: a session whose plan is not yet ready
    const sess = makeSession({ id: "sess-no-plan", planAvailable: false });
    vi.mocked(commands.listSessions).mockResolvedValue([sess]);

    render(<App />);
    await waitFor(() => screen.getByText("test task"));

    // When: open the session (should default to Plan tab, but plan is not available)
    await userEvent.click(screen.getByRole("button", { name: /test task/ }));

    // Then: Plan tab is active (no "Base dir") and shows the empty-state text
    await waitFor(() => {
      expect(screen.queryByText("Base dir")).toBeNull();
    });
    await waitFor(() => {
      expect(screen.getByText("No plan available.")).toBeInTheDocument();
    });
    // And: getSessionPlan was NOT called — plan is not available yet
    expect(commands.getSessionPlan).not.toHaveBeenCalled();
  });

  it("auto-loads plan when open session transitions from planAvailable: false to planAvailable: true", async () => {
    // Given: session starts without a plan; Plan tab is the default
    const sessV1 = makeSession({ id: "sess-late-plan", planAvailable: false });
    vi.mocked(commands.listSessions).mockResolvedValue([sessV1]);
    vi.mocked(commands.getSessionPlan).mockResolvedValue("# Late plan");

    render(<App />);
    await waitFor(() => screen.getByText("test task"));

    // Select the session — Plan tab is shown but plan is not loaded
    await userEvent.click(screen.getByRole("button", { name: /test task/ }));
    await waitFor(() => screen.getByText("No plan available."));

    // Confirm that no plan fetch has occurred yet
    expect(commands.getSessionPlan).not.toHaveBeenCalled();

    // When: sidebar poll sees the session transition to planAvailable: true
    vi.mocked(commands.listSessions).mockResolvedValue([
      makeSession({ id: "sess-late-plan", planAvailable: true }),
    ]);
    // Trigger a silent sidebar reload via visibilitychange (same mechanism as the 3s poll)
    await act(async () => {
      document.dispatchEvent(new Event("visibilitychange"));
    });

    // Then: the plan is fetched automatically without any user interaction
    await waitFor(() => {
      expect(commands.getSessionPlan).toHaveBeenCalledWith("sess-late-plan");
    });
  });

  it("remembered non-Plan tab persists over the Plan default after navigation", async () => {
    // Given: two sessions both with plans available
    const sessA = makeSession({ id: "sess-a", input: "task A", planAvailable: true });
    const sessB = makeSession({ id: "sess-b", input: "task B", planAvailable: true });
    vi.mocked(commands.listSessions).mockResolvedValue([sessA, sessB]);

    render(<App />);
    await waitFor(() => screen.getByText("task A"));

    // Open session A — Plan tab is the default
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));
    await waitFor(() => screen.getByRole("tab", { name: "Plan" }));

    // Switch to Log tab for session A (overrides the default)
    await userEvent.click(screen.getByRole("tab", { name: "Log" }));
    await waitFor(() => {
      expect(screen.getByRole("tab", { name: "Log" })).toHaveAttribute("aria-selected", "true");
    });

    // Navigate to session B, then back to session A
    await userEvent.click(screen.getByRole("button", { name: /task B/ }));
    await userEvent.click(screen.getByRole("button", { name: /task A/ }));

    // Then: Log tab is still active for session A — the remembered tab wins over the default
    await waitFor(() => {
      expect(screen.getByRole("tab", { name: "Log" })).toHaveAttribute("aria-selected", "true");
    });
    expect(screen.getByRole("tab", { name: "Plan" })).toHaveAttribute("aria-selected", "false");
  });
});
