// @vitest-environment happy-dom

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Workspace } from "../types/workspace";
import { useAppStore } from "../stores/useAppStore";

const serviceMocks = vi.hoisted(() => ({
  prepareWorkspaceEnvironment: vi.fn(),
}));
const envServiceMocks = vi.hoisted(() => ({
  reloadEnv: vi.fn(),
  envTargetFromWorkspace: vi.fn((workspaceId: string) => ({
    kind: "workspace",
    workspace_id: workspaceId,
  })),
}));

vi.mock("../services/tauri", () => serviceMocks);
vi.mock("../services/env", () => envServiceMocks);

// The hook subscribes to `workspace_env_progress` Tauri events at
// mount; in vitest there's no Tauri runtime so `listen` would crash
// trying to call into the IPC bridge. Stub it with a no-op that
// resolves to a no-op cleanup function so the effect can run.
vi.mock("@tauri-apps/api/event", () => ({
  listen: vi.fn().mockResolvedValue(() => undefined),
}));

import {
  __TEST__,
  useWorkspaceEnvironmentPreparation,
} from "./useWorkspaceEnvironmentPreparation";

function makeWorkspace(overrides: Partial<Workspace> = {}): Workspace {
  return {
    id: "ws-1",
    repository_id: "repo-1",
    name: "feature",
    branch_name: "james/feature",
    worktree_path: "/tmp/feature",
    status: "Active",
    agent_status: "Idle",
    status_line: "",
    created_at: "1700000000",
    sort_order: 0,
    remote_connection_id: null,
    ...overrides,
  };
}

function Harness() {
  useWorkspaceEnvironmentPreparation();
  return null;
}

let root: Root | null = null;
let container: HTMLDivElement | null = null;

async function renderHarness() {
  container = document.createElement("div");
  document.body.appendChild(container);
  root = createRoot(container);
  await act(async () => {
    root!.render(<Harness />);
  });
}

describe("useWorkspaceEnvironmentPreparation", () => {
  beforeEach(() => {
    document.body.innerHTML = "";
    serviceMocks.prepareWorkspaceEnvironment.mockReset();
    serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue(undefined);
    envServiceMocks.reloadEnv.mockReset();
    envServiceMocks.reloadEnv.mockResolvedValue(undefined);
    envServiceMocks.envTargetFromWorkspace.mockClear();
    useAppStore.setState({
      selectedWorkspaceId: null,
      workspaces: [],
      workspaceEnvironment: {},
      workspaceEnvironmentRetryNonce: {},
      activeModal: null,
      modalData: {},
      toasts: [],
    });
  });

  afterEach(async () => {
    if (root) {
      await act(async () => {
        root!.unmount();
      });
    }
    root = null;
    container?.remove();
    container = null;
  });

  it("prepares env providers when an existing local workspace is selected", async () => {
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledWith("ws-1");
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("opens the trust modal from the prepare response even if the event was missed", async () => {
    serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue({
      workspace_id: "ws-1",
      repo_id: "repo-1",
      plugins: [
        {
          plugin_name: "env-direnv",
          message: ".envrc is blocked.",
          config_path: "/tmp/feature/.envrc",
          error_excerpt: "direnv: error /tmp/feature/.envrc is blocked",
        },
      ],
    });
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    expect(useAppStore.getState().activeModal).toBe("envTrust");
    expect(useAppStore.getState().modalData).toMatchObject({
      workspace_id: "ws-1",
      repo_id: "repo-1",
      plugins: [{ plugin_name: "env-direnv" }],
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("does not reopen the same trust modal after Decide later and workspace reselection", async () => {
    const payload = {
      workspace_id: "ws-1",
      repo_id: "repo-1",
      plugins: [
        {
          plugin_name: "env-direnv",
          message: ".envrc is blocked.",
          config_path: "/tmp/feature/.envrc",
          error_excerpt: "direnv: error /tmp/feature/.envrc is blocked",
        },
      ],
    };
    serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue(payload);
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });
    expect(useAppStore.getState().activeModal).toBe("envTrust");

    act(() => {
      useAppStore.getState().closeModal();
      useAppStore.setState({ selectedWorkspaceId: null });
    });
    expect(useAppStore.getState().activeModal).toBeNull();

    act(() => {
      useAppStore.setState({ selectedWorkspaceId: "ws-1" });
    });
    await act(async () => {
      await Promise.resolve();
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledTimes(2);
    expect(useAppStore.getState().activeModal).toBeNull();
  });

  it("does not run local env providers for remote workspaces", async () => {
    useAppStore.setState({
      selectedWorkspaceId: "ws-remote",
      workspaces: [
        makeWorkspace({
          id: "ws-remote",
          remote_connection_id: "remote-1",
        }),
      ],
    });

    await renderHarness();

    expect(serviceMocks.prepareWorkspaceEnvironment).not.toHaveBeenCalled();
    expect(useAppStore.getState().workspaceEnvironment["ws-remote"]).toEqual({
      status: "ready",
    });
  });

  it("waits for hydration before preparing env when remote_connection_id is undefined", async () => {
    // Regression pin for the fork "Preparing the workspace…" hang:
    // the Rust `Workspace` model has no `remote_connection_id` field,
    // so a workspace surfaced into the store directly from a Tauri
    // command response (rather than through the `workspaces-changed`
    // listener that stamps it) lands with `remote_connection_id`
    // missing entirely.  The selected-workspace effect deliberately
    // treats `undefined` as "not yet hydrated" and bails, because
    // running `prepare_workspace_environment` against a workspace
    // whose remote-vs-local classification is unknown could spawn a
    // local resolve for what's actually a remote row.  Fix sites
    // (the fork command's backend event emission, the ChatPanel
    // handleFork stamp) are responsible for guaranteeing this field
    // is `null` (local) or a string (remote) before selection.
    //
    // Cast through `Partial` so the test can express the wire shape
    // — `remote_connection_id` simply missing — without the test
    // helper's default `null` masking it.
    const wsWithoutStamp = {
      ...makeWorkspace(),
    } as Partial<Workspace> as Workspace;
    delete (wsWithoutStamp as Partial<Workspace>).remote_connection_id;

    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [wsWithoutStamp],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).not.toHaveBeenCalled();

    // Once the workspace gets stamped (e.g. via the `workspaces-changed`
    // event handler), the selector reads `null` and the effect re-fires.
    act(() => {
      useAppStore
        .getState()
        .updateWorkspace("ws-1", { remote_connection_id: null });
    });
    await act(async () => {
      await Promise.resolve();
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledWith("ws-1");
  });

  it("does not restart env preparation when unrelated workspace fields update", async () => {
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    act(() => {
      useAppStore
        .getState()
        .updateWorkspace("ws-1", { status_line: "agent is still running" });
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledTimes(1);
  });

  it("delays the 'preparing' spinner and keeps it across a mid-flight selection change", async () => {
    // Issue #888 hot-cache fix: the workspace is NOT flipped to
    // "preparing" synchronously on selection — a cached resolve would
    // return first and the spinner would never have been warranted.
    // Only once the spinner delay elapses with the resolve still in
    // flight does the status become "preparing".
    //
    // This also pins the original mid-flight contract: once
    // "preparing", a selection change runs cleanup (which does NOT
    // mutate status) and sets the `cancelled` guard, so a late stale
    // resolution can't silently flip the abandoned workspace to
    // "ready". The Complete progress event is the lifecycle-
    // independent recovery path — see the "recovers from a dropped
    // Tauri response" test below.
    vi.useFakeTimers();
    try {
      let resolvePreparation!: () => void;
      serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
        new Promise<void>((resolve) => {
          resolvePreparation = resolve;
        }),
      );
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
      });

      await renderHarness();
      // No synchronous "preparing" — that was the flicker bug.
      expect(
        useAppStore.getState().workspaceEnvironment["ws-1"],
      ).toBeUndefined();

      // Spinner delay elapses, resolve still pending → "preparing".
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 50,
        );
      });
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "preparing",
      });

      act(() => {
        useAppStore.setState({ selectedWorkspaceId: null });
      });

      // Cleanup ran but does NOT touch status — the in-flight prep
      // promise is still pending. The user-visible recovery for a
      // mid-flight navigate-away lives in the Complete progress
      // event (Rust-side Drop fires it regardless of where the
      // resolve was initiated), not in this hook's cleanup.
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "preparing",
      });

      await act(async () => {
        resolvePreparation();
        await Promise.resolve();
      });

      // The `.then` checks `cancelled` (set true by cleanup) and
      // returns early — status is NOT silently flipped to "ready"
      // for a workspace whose effect has torn down. The Complete
      // progress event remains the lifecycle-independent recovery
      // path for any workspace genuinely stuck at "preparing".
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "preparing",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("times out an existing warmup-driven preparing state even when selected prep is skipped", async () => {
    vi.useFakeTimers();
    try {
      const startedAt = Date.now() - 1000;
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
        workspaceEnvironment: {
          "ws-1": { status: "preparing", started_at: startedAt },
        },
      });

      await renderHarness();
      expect(serviceMocks.prepareWorkspaceEnvironment).not.toHaveBeenCalled();

      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.ENV_PREPARATION_TIMEOUT_MS - 1000,
        );
      });

      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "error",
        error: __TEST__.envPreparationTimeoutMessage(),
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("marks a stalled env preparation as error after the hard timeout", async () => {
    vi.useFakeTimers();
    try {
      serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
        new Promise<void>(() => undefined),
      );
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
      });

      await renderHarness();
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 50,
        );
      });
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
        "preparing",
      );

      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.ENV_PREPARATION_TIMEOUT_MS,
        );
      });

      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "error",
        error: __TEST__.envPreparationTimeoutMessage(),
      });
      expect(useAppStore.getState().toasts.at(-1)?.message).toBe(
        __TEST__.envPreparationTimeoutMessage(),
      );
    } finally {
      vi.useRealTimers();
    }
  });

  it("does not flash 'preparing' on a hot-cache resolve that beats the spinner delay", async () => {
    // The core issue #888 acceptance: re-selecting an already-loaded
    // workspace must not show "Preparing direnv…". The resolve is
    // awaited first; the cached round-trip settles well within the
    // spinner delay, the timer is cleared, and the status goes
    // straight to "ready" — never through "preparing".
    vi.useFakeTimers();
    try {
      serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue(undefined);
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
        workspaceEnvironment: { "ws-1": { status: "ready" } },
      });

      await renderHarness();
      // Flush the resolved IPC promise's microtasks WITHOUT advancing
      // wall-clock past the spinner delay.
      await act(async () => {
        await Promise.resolve();
        await Promise.resolve();
      });

      // Straight to "ready", no intermediate "preparing".
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "ready",
      });

      // The pending spinner timer was cleared by the resolve, so
      // crossing the delay cannot retroactively flip it.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 500,
        );
      });
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "ready",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("cancels the pending spinner when selection changes before the delay elapses", async () => {
    // If the user switches away inside the spinner-delay window, the
    // timer must be cleared on cleanup — otherwise the workspace they
    // already left would flip to "preparing" after the fact.
    vi.useFakeTimers();
    try {
      serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
        new Promise<void>(() => undefined),
      );
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
      });

      await renderHarness();
      expect(
        useAppStore.getState().workspaceEnvironment["ws-1"],
      ).toBeUndefined();

      // Switch away before the spinner delay elapses.
      act(() => {
        useAppStore.setState({ selectedWorkspaceId: null });
      });

      // Crossing the delay must not resurrect the spinner.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 500,
        );
      });
      expect(
        useAppStore.getState().workspaceEnvironment["ws-1"],
      ).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  /**
   * Build a fresh listen-callback capture and a thin emitter helper.
   * The harness mocks `@tauri-apps/api/event::listen` to a no-op, so
   * we install our own capture per test to fire synthetic
   * `workspace_env_progress` events into the hook.
   */
  async function withCapturedProgressListener(): Promise<{
    fire: (payload: {
      workspace_id: string;
      resolve_id?: string;
      plugin: string;
      phase: "started" | "finished" | "complete";
      elapsed_ms: number;
      ok?: boolean;
    }) => void;
  }> {
    const listeners: Array<(event: { payload: unknown }) => void> = [];
    const eventMod = await import("@tauri-apps/api/event");
    vi.mocked(eventMod.listen).mockImplementation((_name, cb) => {
      listeners.push(cb as (event: { payload: unknown }) => void);
      return Promise.resolve(() => undefined);
    });
    return {
      fire: (payload) => {
        act(() => {
          for (const cb of listeners) {
            cb({ payload });
          }
        });
      },
    };
  }

  it("ignores stale progress events from a timed-out resolve after a retry starts", async () => {
    useAppStore.setState({
      selectedWorkspaceId: null,
      workspaces: [makeWorkspace()],
      workspaceEnvironment: { "ws-1": { status: "error", error: "timed out" } },
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    fire({
      workspace_id: "ws-1",
      resolve_id: "retry",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
      "preparing",
    );

    fire({
      workspace_id: "ws-1",
      resolve_id: "old",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
      "preparing",
    );

    fire({
      workspace_id: "ws-1",
      resolve_id: "retry",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("reruns preparation through the hook when retry is requested", async () => {
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
      workspaceEnvironment: { "ws-1": { status: "error", error: "timed out" } },
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
      await Promise.resolve();
    });

    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledTimes(1);

    act(() => {
      useAppStore.getState().retryWorkspaceEnvironment("ws-1");
    });
    await act(async () => {
      await Promise.resolve();
      await Promise.resolve();
    });

    expect(envServiceMocks.envTargetFromWorkspace).toHaveBeenCalledWith("ws-1");
    expect(envServiceMocks.reloadEnv).toHaveBeenCalledWith({
      kind: "workspace",
      workspace_id: "ws-1",
    });
    expect(serviceMocks.prepareWorkspaceEnvironment).toHaveBeenCalledTimes(2);
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("keeps superseded resolve ids stale after the retry completes", async () => {
    useAppStore.setState({
      selectedWorkspaceId: null,
      workspaces: [makeWorkspace()],
      workspaceEnvironment: { "ws-1": { status: "ready" } },
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    fire({
      workspace_id: "ws-1",
      resolve_id: "old",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });
    fire({
      workspace_id: "ws-1",
      resolve_id: "retry",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });
    fire({
      workspace_id: "ws-1",
      resolve_id: "retry",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });

    useAppStore.getState().setWorkspaceEnvironment("ws-1", "preparing");
    fire({
      workspace_id: "ws-1",
      resolve_id: "old",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "preparing",
    });
  });

  it("recovers from a dropped Tauri response when a 'complete' progress event arrives", async () => {
    // The Windows regression we're guarding against: WebView2
    // occasionally drops the response message for a short Tauri
    // async command, so the JS-side `invoke()` promise from
    // `prepareWorkspaceEnvironment` never settles. The backend has
    // long since finished, though — Drop on `TauriEnvProgressSink`
    // emits a `complete` progress event as the resolve loop tears
    // down. This test mimics that event and pins the recovery: any
    // workspace still showing `"preparing"` flips to `"ready"`.
    //
    // With the issue #888 spinner delay the workspace reaches
    // "preparing" via the delay timer (the resolve never settles), and
    // the `complete` event then flips it to "ready" exactly as before.
    vi.useFakeTimers();
    try {
      serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
        new Promise<void>(() => undefined),
      );
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
      });

      const { fire } = await withCapturedProgressListener();

      await renderHarness();
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 50,
        );
      });

      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "preparing",
      });

      // Fire the `complete` event the Rust-side sink would emit at the
      // end of every resolve, regardless of which Tauri command
      // initiated it.
      fire({
        workspace_id: "ws-1",
        plugin: "",
        phase: "complete",
        elapsed_ms: 0,
      });

      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "ready",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("does not re-flip a completed workspace to 'preparing' after a dropped Tauri response", async () => {
    // Regression for the Copilot review finding on PR #912: on a
    // dropped Tauri response the prep promise never settles, so
    // `.then` / `.catch` never run `clearPreparingTimer()`. The
    // backend still emits `complete`, which finalizes the workspace —
    // and must ALSO cancel the pending spinner-delay timer. Otherwise
    // the orphaned timer fires 150ms later, sees a non-"preparing"
    // status, and drags the finished workspace back to "preparing":
    // a stuck, blocked composer.
    vi.useFakeTimers();
    try {
      serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
        new Promise<void>(() => undefined),
      );
      useAppStore.setState({
        selectedWorkspaceId: "ws-1",
        workspaces: [makeWorkspace()],
      });

      const { fire } = await withCapturedProgressListener();

      await renderHarness();

      // A cache-miss resolve completing entirely within the spinner
      // delay window: started → preparing, complete → ready.
      fire({
        workspace_id: "ws-1",
        plugin: "env-direnv",
        phase: "started",
        elapsed_ms: 0,
      });
      fire({
        workspace_id: "ws-1",
        plugin: "env-direnv",
        phase: "finished",
        elapsed_ms: 5,
        ok: true,
      });
      fire({
        workspace_id: "ws-1",
        plugin: "",
        phase: "complete",
        elapsed_ms: 0,
      });
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "ready",
      });

      // `complete` must have cancelled the spinner-delay timer —
      // crossing the delay must NOT regress the finished workspace.
      await act(async () => {
        await vi.advanceTimersByTimeAsync(
          __TEST__.PREPARING_SPINNER_DELAY_MS + 500,
        );
      });
      expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
        status: "ready",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  it("transitions to 'error' on 'complete' when any plugin reported failure (dropped Err response recovery)", async () => {
    // The Windows IPC-drop variant where the bug bites hardest: the
    // backend prep returned Err (e.g. direnv .envrc blocked), but
    // WebView2 dropped the response so `.catch` never fired. Without
    // failure tracking, the Complete handler would silently mark
    // "ready" — hiding the trust error from the user. With failure
    // tracking, the Complete handler sees that a plugin emitted
    // `finished { ok: false }` during this resolve and transitions
    // to "error" with a synthetic message pointing at the env panel
    // (where the per-plugin error text is surfaced).
    serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
      new Promise<void>(() => undefined),
    );
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    // Started → Finished with ok=false → Complete. Mirrors what the
    // Rust sink emits when a plugin's detect/export errors and the
    // dispatcher records the failure but the command's Tauri
    // response is dropped en route to the webview.
    fire({
      workspace_id: "ws-1",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "env-direnv",
      phase: "finished",
      elapsed_ms: 8,
      ok: false,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    const env = useAppStore.getState().workspaceEnvironment["ws-1"];
    expect(env?.status).toBe("error");
    expect(env?.error).toMatch(/environment provider reported errors/i);
  });

  it("clears per-workspace failure tracking after Complete so a fresh resolve isn't poisoned", async () => {
    // The failure flag is reset on every Complete so a subsequent
    // resolve starts with a clean slate. Without the reset, a
    // workspace that ever saw `ok: false` would stay "stuck error"
    // forever even after the user fixed the underlying issue
    // (e.g. ran `direnv allow`) and the next resolve succeeded.
    serviceMocks.prepareWorkspaceEnvironment.mockReturnValue(
      new Promise<void>(() => undefined),
    );
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    // Resolve #1: a plugin failed.
    fire({
      workspace_id: "ws-1",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "env-direnv",
      phase: "finished",
      elapsed_ms: 8,
      ok: false,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
      "error",
    );

    // Reset to "preparing" so the next Complete has a status to act on
    // (mirroring what a fresh selectWorkspace → prep call would do).
    useAppStore
      .getState()
      .setWorkspaceEnvironment("ws-1", "preparing");

    // Resolve #2: same workspace, this time all plugins succeed.
    // The failure tracking from resolve #1 MUST NOT leak in.
    fire({
      workspace_id: "ws-1",
      plugin: "env-dotenv",
      phase: "started",
      elapsed_ms: 0,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "env-dotenv",
      phase: "finished",
      elapsed_ms: 4,
      ok: true,
    });
    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("does not regress a ready workspace when a stray 'complete' event arrives", async () => {
    // The Complete event is best-effort — if it arrives for a
    // workspace whose status has already been set to "ready" by a
    // prior `.then`, it MUST NOT silently revert. This pin matters
    // because the Drop-emitted Complete and the Tauri command's own
    // `.then` race naturally; on a healthy IPC channel the `.then`
    // wins, and Complete then arrives as a no-op terminator.
    serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue(undefined);
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    // `.then` ran first → status is ready before Complete arrives.
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });

    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("does not override an 'error' workspace when 'complete' arrives", async () => {
    // The error case has the same race risk as the ready case: a
    // backend resolve that fails (e.g. direnv blocked) sets status
    // to "error" via the prep `.catch`; the sink's Drop fires
    // Complete immediately after, and that terminator must NOT
    // silently overwrite the error the user needs to see.
    serviceMocks.prepareWorkspaceEnvironment.mockRejectedValue(
      new Error("direnv blocked"),
    );
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toMatchObject({
      status: "error",
    });

    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toMatchObject({
      status: "error",
    });
  });

  it("handles a full Started → Finished → Complete sequence from a non-prep path (e.g. spawn_pty)", async () => {
    // The original Windows bug: `spawn_pty` runs its own env resolve
    // that emits Started/Finished progress events (flipping status
    // to "preparing" via `setWorkspaceEnvironmentProgress`), but no
    // dedicated Tauri-command `.then` exists on the JS side to
    // finalize. The Complete event from Drop on the sink is now the
    // authoritative finalizer for these paths; this test exercises
    // the full sequence without the prep hook ever firing.
    useAppStore.setState({
      selectedWorkspaceId: null, // prep effect skipped — no selected workspace
      workspaces: [makeWorkspace()],
      workspaceEnvironment: { "ws-1": { status: "ready" } },
    });

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    // Started: status flips to "preparing", current_plugin set.
    fire({
      workspace_id: "ws-1",
      plugin: "env-dotenv",
      phase: "started",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toMatchObject({
      status: "preparing",
      current_plugin: "env-dotenv",
    });

    // Finished: current_plugin cleared, status stays "preparing"
    // (more plugins may be coming).
    fire({
      workspace_id: "ws-1",
      plugin: "env-dotenv",
      phase: "finished",
      elapsed_ms: 12,
      ok: true,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toMatchObject({
      status: "preparing",
    });
    expect(
      useAppStore.getState().workspaceEnvironment["ws-1"]?.current_plugin,
    ).toBeUndefined();

    // Complete: the resolve loop is done. Transition out of
    // progress-induced "preparing" back to "ready".
    fire({
      workspace_id: "ws-1",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "ready",
    });
  });

  it("routes progress for a non-selected workspace and still finalizes via Complete", async () => {
    // The sidebar shows "preparing" badges for every workspace
    // resolving env, not just the selected one. This pin matters
    // because background paths (repo warmup, a different
    // workspace's PTY spawn) emit progress that the listener must
    // route by workspace_id, and Complete must finalize the
    // intended workspace — not silently target the active one.
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [
        makeWorkspace({ id: "ws-1" }),
        makeWorkspace({ id: "ws-2", name: "other" }),
      ],
      workspaceEnvironment: { "ws-1": { status: "ready" } },
    });
    serviceMocks.prepareWorkspaceEnvironment.mockResolvedValue(undefined);

    const { fire } = await withCapturedProgressListener();

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    fire({
      workspace_id: "ws-2",
      plugin: "env-direnv",
      phase: "started",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-2"]).toMatchObject({
      status: "preparing",
      current_plugin: "env-direnv",
    });
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
      "ready",
    );

    fire({
      workspace_id: "ws-2",
      plugin: "",
      phase: "complete",
      elapsed_ms: 0,
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-2"]).toEqual({
      status: "ready",
    });
    // The selected workspace's status is untouched by the other ws's
    // progress stream.
    expect(useAppStore.getState().workspaceEnvironment["ws-1"]?.status).toBe(
      "ready",
    );
  });

  it("marks the workspace as errored when env preparation fails", async () => {
    serviceMocks.prepareWorkspaceEnvironment.mockRejectedValue(
      new Error("direnv blocked"),
    );
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    expect(useAppStore.getState().workspaceEnvironment["ws-1"]).toEqual({
      status: "error",
      error: "Error: direnv blocked",
    });
    expect(useAppStore.getState().toasts.at(-1)?.message).toBe(
      "Workspace environment failed: Error: direnv blocked",
    );
  });

  it("drops a ghost workspace from the store when prep reports 'Workspace not found'", async () => {
    // The backend's `prepare_workspace_environment` returns the bare
    // string "Workspace not found" (via `resolve_target_from_db`) when the
    // selected workspace id no longer has a DB row — i.e. it was cleaned
    // up but its sidebar row lingered. Instead of stranding an
    // unactionable error toast next to the ghost, the hook removes it.
    serviceMocks.prepareWorkspaceEnvironment.mockRejectedValue(
      "Workspace not found",
    );
    useAppStore.setState({
      selectedWorkspaceId: "ws-1",
      workspaces: [makeWorkspace()],
    });

    await renderHarness();
    await act(async () => {
      await Promise.resolve();
    });

    const state = useAppStore.getState();
    expect(state.workspaces.find((w) => w.id === "ws-1")).toBeUndefined();
    // `removeWorkspace` also clears the selection and the env entry.
    expect(state.selectedWorkspaceId).toBeNull();
    expect(state.workspaceEnvironment["ws-1"]).toBeUndefined();
    // The toast is informational, not the old "Workspace environment
    // failed: …" dead end.
    expect(state.toasts.at(-1)?.message).toBe(
      "Workspace no longer exists — removed from the sidebar.",
    );
  });
});
