// @vitest-environment node
/**
 * Integration tests for GitWorktreeWorkspaceModule through the Dispatcher.
 *
 * Tests verify the full pipeline: dispatcher -> operation -> hook handlers ->
 * GitWorktreeProvider calls + internal state management.
 *
 * Uses minimal test operations that exercise specific hook points, with
 * mock GitWorktreeProvider and PathProvider dependencies.
 */

import { createMockDispatcher } from "../intents/lib/dispatcher.test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Dispatcher } from "../intents/lib/dispatcher";
import { createMinimalOperation } from "../intents/lib/operation.test-utils";

import type { Operation, OperationContext } from "../intents/lib/operation";
import type { Intent, DomainEvent } from "../intents/lib/types";
import type { GitWorktreeProvider } from "../boundaries/platform/git-worktree-provider";
import type { PathProvider } from "../boundaries/platform/path-provider";
import { createMockPathProvider } from "../boundaries/platform/path-provider.test-utils";
import type { Workspace } from "../boundaries/platform/git-types";
import { OPEN_PROJECT_OPERATION_ID } from "../intents/open-project";
import type { DiscoverHookResult } from "../intents/open-project";
import { CLOSE_PROJECT_OPERATION_ID } from "../intents/close-project";
import { OPEN_WORKSPACE_OPERATION_ID } from "../intents/open-workspace";
import type { OpenWorkspaceIntent } from "../intents/open-workspace";
import type { CreateHookResult } from "../intents/open-workspace";
import { GET_PROJECT_BASES_OPERATION_ID } from "../intents/get-project-bases";
import type { ListBasesHookResult } from "../intents/get-project-bases";
import { DELETE_WORKSPACE_OPERATION_ID } from "../intents/delete-workspace";
import type { DeleteWorkspaceIntent, DeletePipelineHookInput } from "../intents/delete-workspace";
import type { DeleteHookResult, PreflightHookResult } from "../intents/delete-workspace";
import { GET_WORKSPACE_STATUS_OPERATION_ID } from "../intents/get-workspace-status";
import type { GetStatusHookInput, GetStatusHookResult } from "../intents/get-workspace-status";
import { RESOLVE_WORKSPACE_OPERATION_ID } from "../intents/resolve-workspace";
import {
  SWITCH_WORKSPACE_OPERATION_ID,
  type FindCandidatesHookResult,
} from "../intents/switch-workspace";
import {
  LIST_PROJECTS_OPERATION_ID,
  type ListWorkspacesHookResult,
} from "../intents/list-projects";
import { EVENT_METADATA_CHANGED, type MetadataChangedEvent } from "../intents/set-metadata";
import type { ProjectId, WorkspaceName } from "../shared/api/types";
import { createGitWorktreeWorkspaceModule } from "./git-worktree-workspace-module";
import { SILENT_LOGGER } from "../boundaries/platform/logging";
import { Path } from "../utils/path/path";

// =============================================================================
// Mock Dependencies
// =============================================================================

function createMockGitWorktreeProvider() {
  return {
    registerProject: vi.fn(),
    unregisterProject: vi.fn(),
    discover: vi.fn().mockResolvedValue([]),
    createWorkspace: vi.fn(),
    removeWorkspace: vi.fn().mockResolvedValue({ workspaceRemoved: true, baseDeleted: false }),
    isDirty: vi.fn().mockResolvedValue(false),
    countUnmergedCommits: vi.fn().mockResolvedValue(0),
    listBases: vi.fn().mockResolvedValue([]),
    defaultBase: vi.fn().mockResolvedValue(undefined),
    updateBases: vi.fn().mockResolvedValue(undefined),
    cleanupOrphanedWorkspaces: vi.fn().mockResolvedValue({ removedCount: 0, failedPaths: [] }),
    validateRepository: vi.fn().mockResolvedValue(undefined),
    ensureWorkspaceRegistered: vi.fn(),
  };
}

// =============================================================================
// Minimal Test Operations
// =============================================================================

const openProjectOperation = createMinimalOperation<Intent, DiscoverHookResult>(
  OPEN_PROJECT_OPERATION_ID,
  "discover",
  {
    hookContext: (ctx) => ({
      intent: ctx.intent,
      projectPath: (ctx.intent.payload as { projectPath: string }).projectPath,
    }),
  }
);

const closeProjectOperation = createMinimalOperation<Intent, Record<string, never>>(
  CLOSE_PROJECT_OPERATION_ID,
  "close",
  {
    hookContext: (ctx) => ({
      intent: ctx.intent,
      projectPath: (ctx.intent.payload as { projectPath: string }).projectPath,
      removeLocalRepo: false,
    }),
  }
);

const openWorkspaceOperation = createMinimalOperation<OpenWorkspaceIntent, CreateHookResult>(
  OPEN_WORKSPACE_OPERATION_ID,
  "create",
  {
    hookContext: (ctx) => ({
      intent: ctx.intent,
      projectPath: (ctx.intent.payload as { projectPath?: string }).projectPath ?? "",
    }),
  }
);

/** Preflight result from the delete-workspace preflight hook. */
interface PreflightResult {
  readonly isDirty?: boolean;
  readonly unmergedCommits?: number;
  readonly error?: string;
}

/**
 * Preflight-only operation: dispatches workspace:resolve then runs "preflight" hook point.
 */
class MinimalPreflightOperation implements Operation<DeleteWorkspaceIntent, PreflightResult> {
  readonly id = DELETE_WORKSPACE_OPERATION_ID;

  async execute(ctx: OperationContext<DeleteWorkspaceIntent>): Promise<PreflightResult> {
    const { payload } = ctx.intent;

    let resolvedProjectPath = "";
    try {
      const resolved = (await ctx.dispatch({
        type: "workspace:resolve",
        payload: { workspacePath: payload.workspacePath },
      } as Intent)) as ResolveResult;
      resolvedProjectPath = resolved.projectPath ?? "";
    } catch {
      // Workspace not found
    }

    const preflightInput: DeletePipelineHookInput = {
      intent: ctx.intent,
      projectPath: resolvedProjectPath,
      workspacePath: payload.workspacePath,
      active: false,
    };
    const { results, errors } = await ctx.hooks.collect<PreflightHookResult>(
      "preflight",
      preflightInput
    );
    if (errors.length > 0) return { error: errors[0]!.message };
    return results[0] ?? {};
  }
}

/** Extended delete result that includes the resolved path and possible error. */
interface DeleteResult extends DeleteHookResult {
  readonly resolvedPath?: string;
}

/**
 * Delete-workspace operation: dispatches workspace:resolve then runs "delete" hook point.
 */
class MinimalDeleteWorkspaceOperation implements Operation<DeleteWorkspaceIntent, DeleteResult> {
  readonly id = DELETE_WORKSPACE_OPERATION_ID;

  async execute(ctx: OperationContext<DeleteWorkspaceIntent>): Promise<DeleteResult> {
    const { payload } = ctx.intent;

    // Dispatch workspace:resolve (matching real operation)
    let resolvedProjectPath = "";
    try {
      const resolved = (await ctx.dispatch({
        type: "workspace:resolve",
        payload: { workspacePath: payload.workspacePath },
      } as Intent)) as ResolveResult;
      resolvedProjectPath = resolved.projectPath ?? "";
    } catch {
      // Workspace not found — continue with empty projectPath
    }

    // delete (enriched with both paths, matching real operation's DeletePipelineHookInput)
    const deleteInput: DeletePipelineHookInput = {
      intent: ctx.intent,
      projectPath: resolvedProjectPath,
      workspacePath: payload.workspacePath,
      active: false,
    };
    const { results: deleteResults, errors: deleteErrors } =
      await ctx.hooks.collect<DeleteHookResult>("delete", deleteInput);
    if (deleteErrors.length > 0 && !payload.force) throw deleteErrors[0]!;

    return {
      ...deleteResults[0],
      ...(resolvedProjectPath !== "" && { resolvedPath: payload.workspacePath }),
    };
  }
}

/** Result from workspace path resolution (reverse lookup: workspacePath → projectPath + workspaceName). */
interface ResolveResult {
  readonly projectPath?: string | undefined;
  readonly workspaceName?: string | undefined;
}

/**
 * Resolve-workspace operation: runs "resolve" hook point.
 *
 * Uses RESOLVE_WORKSPACE_OPERATION_ID because the module registers its
 * resolve hook under that operation. Accepts workspacePath and reverse-looks
 * up projectPath + workspaceName.
 */
class MinimalResolveWorkspaceOperation implements Operation<Intent, ResolveResult> {
  readonly id = RESOLVE_WORKSPACE_OPERATION_ID;

  async execute(ctx: OperationContext<Intent>): Promise<ResolveResult> {
    const payload = ctx.intent.payload as { workspacePath: string };
    const resolveInput = {
      intent: ctx.intent,
      workspacePath: payload.workspacePath,
    };
    const { results: resolveResults } = await ctx.hooks.collect<{
      projectPath?: string;
      workspaceName?: string;
    }>("resolve", resolveInput);
    const projectPath = resolveResults[0]?.projectPath;
    const workspaceName = resolveResults[0]?.workspaceName;
    return projectPath ? { projectPath, workspaceName } : {};
  }
}

/** Result from get-project-bases list + refresh dispatch. */
interface GetProjectBasesTestResult {
  readonly bases?: readonly { name: string; isRemote: boolean }[];
  readonly defaultBaseBranch?: string;
  readonly refreshed?: boolean;
}

/**
 * Minimal get-project-bases operation: calls "list" hook, then optionally "refresh".
 * The intent payload controls which hooks to run via a `hookPoint` field.
 */
class MinimalGetProjectBasesOperation implements Operation<Intent, GetProjectBasesTestResult> {
  readonly id = GET_PROJECT_BASES_OPERATION_ID;

  async execute(ctx: OperationContext<Intent>): Promise<GetProjectBasesTestResult> {
    const payload = ctx.intent.payload as {
      projectPath: string;
      hookPoint?: "list" | "refresh";
    };
    const hookCtx = { intent: ctx.intent, projectPath: payload.projectPath };

    if (payload.hookPoint === "refresh") {
      const { errors } = await ctx.hooks.collect("refresh", hookCtx);
      if (errors.length > 0) throw errors[0]!;
      return { refreshed: true };
    }

    // Default: list
    const { results, errors } = await ctx.hooks.collect<ListBasesHookResult>("list", hookCtx);
    if (errors.length > 0) throw errors[0]!;
    return results[0] ?? {};
  }
}

/** Result from get-workspace-status: resolve-workspace + get. */
interface GetStatusResult {
  readonly isDirty?: boolean;
  readonly unmergedCommits?: number;
}

/**
 * Get-workspace-status operation: dispatches workspace:resolve then runs "get" hook point.
 * Mirrors the real GetWorkspaceStatusOperation.
 */
class MinimalGetStatusOperation implements Operation<Intent, GetStatusResult> {
  readonly id = GET_WORKSPACE_STATUS_OPERATION_ID;

  async execute(ctx: OperationContext<Intent>): Promise<GetStatusResult> {
    const payload = ctx.intent.payload as { workspacePath: string };

    // Dispatch workspace:resolve (matching real operation)
    const resolved = (await ctx.dispatch({
      type: "workspace:resolve",
      payload: { workspacePath: payload.workspacePath },
    } as Intent)) as ResolveResult;
    if (!resolved.projectPath) {
      throw new Error(`Workspace not found: ${payload.workspacePath}`);
    }

    // get
    const getInput: GetStatusHookInput = {
      intent: ctx.intent,
      workspacePath: payload.workspacePath,
    };
    const { results, errors } = await ctx.hooks.collect<GetStatusHookResult>("get", getInput);
    if (errors.length > 0) throw errors[0]!;

    let isDirty = false;
    let unmergedCommits = 0;
    for (const result of results) {
      if (result.isDirty) isDirty = true;
      if (result.unmergedCommits !== undefined && result.unmergedCommits > unmergedCommits) {
        unmergedCommits = result.unmergedCommits;
      }
    }
    return { isDirty, unmergedCommits };
  }
}

/**
 * Emit-event operation: emits a domain event from within an operation context.
 * Used to trigger event subscriptions registered by modules.
 */
class MinimalEmitEventOperation implements Operation<Intent, void> {
  readonly id = "emit-event";

  async execute(ctx: OperationContext<Intent>): Promise<void> {
    const event = ctx.intent.payload as DomainEvent;
    ctx.emit(event);
  }
}

const switchWorkspaceOperation = createMinimalOperation<Intent, FindCandidatesHookResult>(
  SWITCH_WORKSPACE_OPERATION_ID,
  "find-candidates"
);

const listProjectsOperation = createMinimalOperation<Intent, ListWorkspacesHookResult>(
  LIST_PROJECTS_OPERATION_ID,
  "list-workspaces"
);

// =============================================================================
// Test Setup
// =============================================================================

interface TestSetup {
  dispatcher: Dispatcher;
  provider: ReturnType<typeof createMockGitWorktreeProvider>;
  pathProvider: PathProvider;
}

function createTestSetup(): TestSetup {
  const provider = createMockGitWorktreeProvider();
  const pathProvider = createMockPathProvider({
    getProjectWorkspacesDir: () => new Path("/workspaces"),
  });

  const dispatcher = createMockDispatcher();

  // Register operations
  dispatcher.registerOperation("project:open", openProjectOperation);
  dispatcher.registerOperation("project:close", closeProjectOperation);
  dispatcher.registerOperation("workspace:open", openWorkspaceOperation);
  dispatcher.registerOperation("workspace:delete", new MinimalDeleteWorkspaceOperation());
  dispatcher.registerOperation("workspace:resolve", new MinimalResolveWorkspaceOperation());
  dispatcher.registerOperation("project:get-bases", new MinimalGetProjectBasesOperation());
  dispatcher.registerOperation("workspace:get-status", new MinimalGetStatusOperation());
  dispatcher.registerOperation("emit-event", new MinimalEmitEventOperation());
  dispatcher.registerOperation("workspace:switch", switchWorkspaceOperation);
  dispatcher.registerOperation("project:list", listProjectsOperation);

  // Wire the module under test
  const module = createGitWorktreeWorkspaceModule(
    provider as unknown as GitWorktreeProvider,
    pathProvider,
    SILENT_LOGGER
  );
  dispatcher.registerModule(module);

  return { dispatcher, provider, pathProvider };
}

function createPreflightTestSetup(): TestSetup {
  const provider = createMockGitWorktreeProvider();
  const pathProvider = createMockPathProvider({
    getProjectWorkspacesDir: () => new Path("/workspaces"),
  });

  const dispatcher = createMockDispatcher();

  dispatcher.registerOperation("project:open", openProjectOperation);
  dispatcher.registerOperation("workspace:delete", new MinimalPreflightOperation());
  dispatcher.registerOperation("workspace:resolve", new MinimalResolveWorkspaceOperation());

  const module = createGitWorktreeWorkspaceModule(
    provider as unknown as GitWorktreeProvider,
    pathProvider,
    SILENT_LOGGER
  );
  dispatcher.registerModule(module);

  return { dispatcher, provider, pathProvider };
}

// =============================================================================
// Test Helpers
// =============================================================================

function makeWorkspace(name: string, projectPath: string): Workspace {
  return {
    name,
    path: new Path(`${projectPath}/.worktrees/${name}`),
    branch: name,
    metadata: { base: "origin/main" },
  };
}

// Typed dispatch helpers to avoid casting at every call site

async function dispatchOpenProject(
  dispatcher: Dispatcher,
  projectPath: string
): Promise<DiscoverHookResult> {
  return (await dispatcher.dispatch({
    type: "project:open",
    payload: { projectPath },
  } as Intent)) as DiscoverHookResult;
}

async function dispatchCloseProject(dispatcher: Dispatcher, projectPath: string): Promise<void> {
  await dispatcher.dispatch({
    type: "project:close",
    payload: { projectPath },
  } as Intent);
}

async function dispatchResolveWorkspace(
  dispatcher: Dispatcher,
  workspacePath: string
): Promise<ResolveResult> {
  return (await dispatcher.dispatch({
    type: "workspace:resolve",
    payload: { workspacePath },
  } as Intent)) as ResolveResult;
}

async function dispatchListBases(
  dispatcher: Dispatcher,
  projectPath: string
): Promise<GetProjectBasesTestResult> {
  return (await dispatcher.dispatch({
    type: "project:get-bases",
    payload: { projectPath },
  } as Intent)) as GetProjectBasesTestResult;
}

async function dispatchRefreshBases(dispatcher: Dispatcher, projectPath: string): Promise<void> {
  await dispatcher.dispatch({
    type: "project:get-bases",
    payload: { projectPath, hookPoint: "refresh" },
  } as Intent);
}

async function dispatchGetStatus(
  dispatcher: Dispatcher,
  workspacePath: string
): Promise<GetStatusResult> {
  return (await dispatcher.dispatch({
    type: "workspace:get-status",
    payload: { workspacePath },
  } as Intent)) as GetStatusResult;
}

async function dispatchCreateWorkspace(
  dispatcher: Dispatcher,
  intent: OpenWorkspaceIntent
): Promise<CreateHookResult> {
  // Cast through Intent to bypass phantom type inference (test operation returns CreateHookResult)
  return (await dispatcher.dispatch(intent as unknown as Intent)) as CreateHookResult;
}

async function dispatchDeleteWorkspace(
  dispatcher: Dispatcher,
  intent: DeleteWorkspaceIntent
): Promise<DeleteResult> {
  // Cast through Intent to bypass phantom type inference (test operation returns DeleteResult)
  return (await dispatcher.dispatch(intent as unknown as Intent)) as DeleteResult;
}

async function dispatchFindCandidates(dispatcher: Dispatcher): Promise<FindCandidatesHookResult> {
  return (await dispatcher.dispatch({
    type: "workspace:switch",
    payload: {},
  } as Intent)) as FindCandidatesHookResult;
}

async function dispatchListWorkspaces(dispatcher: Dispatcher): Promise<ListWorkspacesHookResult> {
  return (await dispatcher.dispatch({
    type: "project:list",
    payload: {},
  } as Intent)) as ListWorkspacesHookResult;
}

async function dispatchPreflight(
  dispatcher: Dispatcher,
  workspacePath: string
): Promise<PreflightResult> {
  const intent: DeleteWorkspaceIntent = {
    type: "workspace:delete",
    payload: { workspacePath, keepBranch: false, force: false, removeWorktree: true },
  };
  return (await dispatcher.dispatch(intent as unknown as Intent)) as PreflightResult;
}

// =============================================================================
// Tests
// =============================================================================

describe("GitWorktreeWorkspaceModule Integration", () => {
  let setup: TestSetup;

  beforeEach(() => {
    setup = createTestSetup();
  });

  // ---------------------------------------------------------------------------
  // open-project -> discover
  // ---------------------------------------------------------------------------

  describe("open-project -> discover", () => {
    it("registers project, discovers workspaces, returns them", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws1 = makeWorkspace("feature-1", projectPath);
      const ws2 = makeWorkspace("feature-2", projectPath);
      provider.discover.mockResolvedValue([ws1, ws2]);

      const result = await dispatchOpenProject(dispatcher, projectPath);

      expect(provider.registerProject).toHaveBeenCalledWith(
        new Path(projectPath),
        new Path("/workspaces")
      );
      expect(provider.discover).toHaveBeenCalledWith(new Path(projectPath));
      expect(result.workspaces).toHaveLength(2);
      expect(result.workspaces[0]!.name).toBe("feature-1");
      expect(result.workspaces[1]!.name).toBe("feature-2");
    });

    it("calls fire-and-forget cleanupOrphanedWorkspaces", async () => {
      const { dispatcher, provider } = setup;

      await dispatchOpenProject(dispatcher, "/projects/my-app");

      expect(provider.cleanupOrphanedWorkspaces).toHaveBeenCalledWith(new Path("/projects/my-app"));
    });
  });

  // ---------------------------------------------------------------------------
  // close-project -> close
  // ---------------------------------------------------------------------------

  describe("close-project -> close", () => {
    it("unregisters project and clears state", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      // Open first
      const ws = makeWorkspace("feature-1", projectPath);
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      // Close
      await dispatchCloseProject(dispatcher, projectPath);

      expect(provider.unregisterProject).toHaveBeenCalledWith(new Path(projectPath));

      // Subsequent resolve should return empty (no projectPath)
      const resolveResult = await dispatchResolveWorkspace(
        dispatcher,
        `${projectPath}/.worktrees/feature-1`
      );
      expect(resolveResult.projectPath).toBeUndefined();
    });
  });

  // ---------------------------------------------------------------------------
  // open-workspace -> create
  // ---------------------------------------------------------------------------

  describe("open-workspace -> create", () => {
    describe("new workspace", () => {
      it("calls createWorkspace and updates state", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        // Open project first
        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        // Mock createWorkspace
        const createdWs: Workspace = {
          name: "new-feature",
          path: new Path("/workspaces/new-feature"),
          branch: "new-feature",
          metadata: { base: "origin/main" },
        };
        provider.createWorkspace.mockResolvedValue(createdWs);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "new-feature",
            base: "origin/main",
            projectPath,
          },
        };

        const result = await dispatchCreateWorkspace(dispatcher, createIntent);

        expect(provider.createWorkspace).toHaveBeenCalledWith(
          new Path(projectPath),
          "new-feature",
          "origin/main",
          undefined
        );
        expect(result.workspacePath).toBe("/workspaces/new-feature");
        expect(result.branch).toBe("new-feature");

        // Verify state was updated (resolve should find it)
        const resolveResult = await dispatchResolveWorkspace(dispatcher, "/workspaces/new-feature");
        expect(resolveResult.projectPath).toBe(projectPath);
      });
    });

    describe("existing workspace", () => {
      it("skips provider call and updates state", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        // Open project first
        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "existing-ws",
            base: "origin/main",
            projectPath,
            existingWorkspace: {
              path: "/workspaces/existing-ws",
              name: "existing-ws",
              branch: "existing-ws",
              metadata: { base: "origin/main" },
            },
          },
        };

        const result = await dispatchCreateWorkspace(dispatcher, createIntent);

        expect(provider.createWorkspace).not.toHaveBeenCalled();
        expect(result.workspacePath).toBe("/workspaces/existing-ws");
        expect(result.branch).toBe("existing-ws");

        // Verify state was updated
        const resolveResult = await dispatchResolveWorkspace(dispatcher, "/workspaces/existing-ws");
        expect(resolveResult.projectPath).toBe(projectPath);
      });
    });

    describe("base resolution", () => {
      it("resolves default base via defaultBase() when base is omitted", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        provider.defaultBase.mockResolvedValue("origin/main");

        const createdWs: Workspace = {
          name: "auto-base",
          path: new Path("/workspaces/auto-base"),
          branch: "auto-base",
          metadata: { base: "origin/main" },
        };
        provider.createWorkspace.mockResolvedValue(createdWs);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "auto-base",
            projectPath,
          },
        };

        const result = await dispatchCreateWorkspace(dispatcher, createIntent);

        expect(provider.defaultBase).toHaveBeenCalledWith(new Path(projectPath));
        expect(provider.createWorkspace).toHaveBeenCalledWith(
          new Path(projectPath),
          "auto-base",
          "origin/main",
          undefined
        );
        expect(result.resolvedBase).toBe("origin/main");
      });

      it("throws when base is omitted and no default branch exists", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        provider.defaultBase.mockResolvedValue(undefined);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "no-base",
            projectPath,
          },
        };

        await expect(dispatchCreateWorkspace(dispatcher, createIntent)).rejects.toThrow(
          "No base branch specified and no default branch found"
        );
      });

      it("includes base branch in error when createWorkspace fails", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        provider.createWorkspace.mockRejectedValue(
          new Error("Failed to create branch focus-test-1: fatal: not a valid object name: 'main'")
        );

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "focus-test-1",
            base: "main",
            projectPath,
          },
        };

        await expect(dispatchCreateWorkspace(dispatcher, createIntent)).rejects.toThrow(
          "(base: 'main')"
        );
      });

      it("uses explicit base without calling defaultBase()", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        const createdWs: Workspace = {
          name: "explicit-base",
          path: new Path("/workspaces/explicit-base"),
          branch: "explicit-base",
          metadata: { base: "develop" },
        };
        provider.createWorkspace.mockResolvedValue(createdWs);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "explicit-base",
            base: "develop",
            projectPath,
          },
        };

        const result = await dispatchCreateWorkspace(dispatcher, createIntent);

        // defaultBase should not be called when base is explicitly provided
        // (the mock's default return is undefined, so if it were called and used, createWorkspace would fail)
        expect(provider.createWorkspace).toHaveBeenCalledWith(
          new Path(projectPath),
          "explicit-base",
          "develop",
          undefined
        );
        expect(result.resolvedBase).toBe("develop");
      });

      it("returns resolvedBase for existing workspace path", async () => {
        const { dispatcher, provider } = setup;
        const projectPath = "/projects/my-app";

        provider.discover.mockResolvedValue([]);
        await dispatchOpenProject(dispatcher, projectPath);

        const createIntent: OpenWorkspaceIntent = {
          type: "workspace:open",
          payload: {
            workspaceName: "existing-ws",
            base: "origin/main",
            projectPath,
            existingWorkspace: {
              path: "/workspaces/existing-ws",
              name: "existing-ws",
              branch: "existing-ws",
              metadata: { base: "origin/main" },
            },
          },
        };

        const result = await dispatchCreateWorkspace(dispatcher, createIntent);
        expect(result.resolvedBase).toBe("origin/main");
      });
    });
  });

  // ---------------------------------------------------------------------------
  // delete-workspace
  // ---------------------------------------------------------------------------

  describe("delete-workspace", () => {
    async function setupWithWorkspace(
      d: Dispatcher,
      p: ReturnType<typeof createMockGitWorktreeProvider>
    ) {
      const projectPath = "/projects/my-app";
      const ws = makeWorkspace("feature-1", projectPath);
      p.discover.mockResolvedValue([ws]);

      await dispatchOpenProject(d, projectPath);

      return { projectPath, ws };
    }

    describe("resolve-workspace", () => {
      it("returns projectPath for known workspace", async () => {
        const { dispatcher, provider } = setup;
        const { projectPath } = await setupWithWorkspace(dispatcher, provider);

        const result = await dispatchResolveWorkspace(
          dispatcher,
          `${projectPath}/.worktrees/feature-1`
        );
        expect(result.projectPath).toBe(projectPath);
      });

      it("returns empty for unknown workspace", async () => {
        const { dispatcher, provider } = setup;
        await setupWithWorkspace(dispatcher, provider);

        const result = await dispatchResolveWorkspace(dispatcher, "/nonexistent/path");
        expect(result.projectPath).toBeUndefined();
      });
    });

    describe("delete", () => {
      it("calls removeWorkspace when removeWorktree=true", async () => {
        const { dispatcher, provider } = setup;
        const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

        const deleteIntent: DeleteWorkspaceIntent = {
          type: "workspace:delete",
          payload: {
            workspacePath: ws.path.toString(),
            keepBranch: false,
            force: false,
            removeWorktree: true,
          },
        };

        await dispatchDeleteWorkspace(dispatcher, deleteIntent);

        expect(provider.removeWorkspace).toHaveBeenCalledWith(new Path(projectPath), ws.path, true);
      });

      it("does not call removeWorkspace when removeWorktree=false", async () => {
        const { dispatcher, provider } = setup;
        const { ws } = await setupWithWorkspace(dispatcher, provider);

        const deleteIntent: DeleteWorkspaceIntent = {
          type: "workspace:delete",
          payload: {
            workspacePath: ws.path.toString(),
            keepBranch: true,
            force: false,
            removeWorktree: false,
          },
        };

        await dispatchDeleteWorkspace(dispatcher, deleteIntent);

        expect(provider.removeWorkspace).not.toHaveBeenCalled();
      });

      it("clears workspace from state after deletion", async () => {
        const { dispatcher, provider } = setup;
        const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

        const deleteIntent: DeleteWorkspaceIntent = {
          type: "workspace:delete",
          payload: {
            workspacePath: ws.path.toString(),
            keepBranch: false,
            force: false,
            removeWorktree: true,
          },
        };

        await dispatchDeleteWorkspace(dispatcher, deleteIntent);

        // Workspace should no longer be resolvable
        const resolveResult = await dispatchResolveWorkspace(
          dispatcher,
          `${projectPath}/.worktrees/feature-1`
        );
        expect(resolveResult.projectPath).toBeUndefined();
      });
    });

    describe("force mode", () => {
      it("catches error, unregisters workspace, and returns error", async () => {
        const { dispatcher, provider } = setup;
        const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

        provider.removeWorkspace.mockRejectedValue(new Error("git error"));

        const deleteIntent: DeleteWorkspaceIntent = {
          type: "workspace:delete",
          payload: {
            workspacePath: ws.path.toString(),
            keepBranch: false,
            force: true,
            removeWorktree: true,
          },
        };

        const result = await dispatchDeleteWorkspace(dispatcher, deleteIntent);

        expect(result.error).toBe("git error");

        // Workspace should still be unregistered from state
        const resolveResult = await dispatchResolveWorkspace(
          dispatcher,
          `${projectPath}/.worktrees/feature-1`
        );
        expect(resolveResult.projectPath).toBeUndefined();
      });
    });
  });

  // ---------------------------------------------------------------------------
  // get-project-bases -> list
  // ---------------------------------------------------------------------------

  describe("get-project-bases -> list", () => {
    it("returns bases and defaultBaseBranch from provider", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      provider.listBases.mockResolvedValue([
        { name: "origin/main", isRemote: true, base: "origin/main" },
        { name: "main", isRemote: false, base: "origin/main" },
      ]);
      provider.defaultBase.mockResolvedValue("origin/main");

      const result = await dispatchListBases(dispatcher, projectPath);

      expect(provider.listBases).toHaveBeenCalledWith(new Path(projectPath));
      expect(provider.defaultBase).toHaveBeenCalledWith(new Path(projectPath));
      expect(result.bases).toHaveLength(2);
      expect(result.defaultBaseBranch).toBe("origin/main");
    });
  });

  // ---------------------------------------------------------------------------
  // get-project-bases -> refresh
  // ---------------------------------------------------------------------------

  describe("get-project-bases -> refresh", () => {
    it("calls updateBases on provider", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      await dispatchRefreshBases(dispatcher, projectPath);

      expect(provider.updateBases).toHaveBeenCalledWith(new Path(projectPath));
    });
  });

  // ---------------------------------------------------------------------------
  // get-workspace-status -> get
  // ---------------------------------------------------------------------------

  describe("get-workspace-status -> get", () => {
    it("calls isDirty and returns result", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      provider.isDirty.mockResolvedValue(true);

      const result = await dispatchGetStatus(dispatcher, ws.path.toString());

      expect(provider.isDirty).toHaveBeenCalledWith(ws.path);
      expect(result.isDirty).toBe(true);
    });

    it("returns isDirty=false when workspace is clean", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      provider.isDirty.mockResolvedValue(false);

      const result = await dispatchGetStatus(dispatcher, ws.path.toString());

      expect(result.isDirty).toBe(false);
    });
  });

  // ---------------------------------------------------------------------------
  // delete-workspace -> preflight
  // ---------------------------------------------------------------------------

  describe("delete-workspace -> preflight", () => {
    it("returns isDirty and unmergedCommits from provider", async () => {
      const preflightSetup = createPreflightTestSetup();
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      preflightSetup.provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(preflightSetup.dispatcher, projectPath);

      preflightSetup.provider.isDirty.mockResolvedValue(true);
      preflightSetup.provider.countUnmergedCommits.mockResolvedValue(3);

      const result = await dispatchPreflight(preflightSetup.dispatcher, ws.path.toString());

      expect(result.isDirty).toBe(true);
      expect(result.unmergedCommits).toBe(3);
    });

    it("returns error when provider throws", async () => {
      const preflightSetup = createPreflightTestSetup();
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      preflightSetup.provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(preflightSetup.dispatcher, projectPath);

      preflightSetup.provider.isDirty.mockRejectedValue(new Error("git failed"));

      const result = await dispatchPreflight(preflightSetup.dispatcher, ws.path.toString());

      expect(result.error).toBe("git failed");
    });
  });

  // ---------------------------------------------------------------------------
  // deletionPending preservation
  // ---------------------------------------------------------------------------

  describe("deletionPending preservation", () => {
    async function setupWithWorkspace(
      d: Dispatcher,
      p: ReturnType<typeof createMockGitWorktreeProvider>
    ) {
      const projectPath = "/projects/my-app";
      const ws = makeWorkspace("feature-1", projectPath);
      p.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(d, projectPath);
      return { projectPath, ws };
    }

    it("re-discovery preserves deletion-pending workspace", async () => {
      const { dispatcher, provider } = setup;
      const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete fails (non-force) — workspace enters deletionPending
      provider.removeWorkspace.mockRejectedValue(new Error("EBUSY"));
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: false,
          removeWorktree: true,
        },
      };
      const deleteResult = await dispatchDeleteWorkspace(dispatcher, deleteIntent);
      expect(deleteResult.error).toBe("EBUSY");

      // Re-discover project — git no longer lists the workspace
      provider.discover.mockResolvedValue([]);
      await dispatchOpenProject(dispatcher, projectPath);

      // Workspace should still be resolvable via deletionPending
      const resolveResult = await dispatchResolveWorkspace(dispatcher, ws.path.toString());
      expect(resolveResult.projectPath).toBe(projectPath);
    });

    it("successful delete clears deletionPending", async () => {
      const { dispatcher, provider } = setup;
      const { ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete succeeds
      provider.removeWorkspace.mockResolvedValue(undefined);
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: false,
          removeWorktree: true,
        },
      };
      await dispatchDeleteWorkspace(dispatcher, deleteIntent);

      // Workspace should not be resolvable
      const resolveResult = await dispatchResolveWorkspace(dispatcher, ws.path.toString());
      expect(resolveResult.projectPath).toBeUndefined();
    });

    it("force delete clears deletionPending", async () => {
      const { dispatcher, provider } = setup;
      const { ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete fails but force=true — dismissed from both maps
      provider.removeWorkspace.mockRejectedValue(new Error("EBUSY"));
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: true,
          removeWorktree: true,
        },
      };
      await dispatchDeleteWorkspace(dispatcher, deleteIntent);

      // Re-discover with empty list
      provider.discover.mockResolvedValue([]);
      await dispatchOpenProject(dispatcher, "/projects/my-app");

      // Workspace should not be resolvable (force cleared deletionPending)
      const resolveResult = await dispatchResolveWorkspace(dispatcher, ws.path.toString());
      expect(resolveResult.projectPath).toBeUndefined();
    });

    it("list-workspaces merges deletion-pending entries", async () => {
      const { dispatcher, provider } = setup;
      const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete fails (non-force) — workspace enters deletionPending
      provider.removeWorkspace.mockRejectedValue(new Error("EBUSY"));
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: false,
          removeWorktree: true,
        },
      };
      await dispatchDeleteWorkspace(dispatcher, deleteIntent);

      // Re-discover — git no longer lists the workspace, but a new one exists
      const ws2 = makeWorkspace("feature-2", projectPath);
      provider.discover.mockResolvedValue([ws2]);
      await dispatchOpenProject(dispatcher, projectPath);

      // list-workspaces should include both: feature-2 from git + feature-1 from deletionPending
      const listResult = await dispatchListWorkspaces(dispatcher);
      const entry = listResult.entries!.find((e) => e.projectPath === projectPath);
      const names = entry!.workspaces.map((w) => w.name);
      expect(names).toContain("feature-1");
      expect(names).toContain("feature-2");
    });

    it("find-candidates merges deletion-pending entries", async () => {
      const { dispatcher, provider } = setup;
      const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete fails (non-force)
      provider.removeWorkspace.mockRejectedValue(new Error("EBUSY"));
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: false,
          removeWorktree: true,
        },
      };
      await dispatchDeleteWorkspace(dispatcher, deleteIntent);

      // Re-discover without the deleted workspace
      provider.discover.mockResolvedValue([]);
      await dispatchOpenProject(dispatcher, projectPath);

      // find-candidates should still include the deletion-pending workspace
      const result = await dispatchFindCandidates(dispatcher);
      const paths = result.candidates!.map((c) => c.workspacePath);
      expect(paths).toContain(ws.path.toString());
    });

    it("close-project clears deletionPending entries for that project", async () => {
      const { dispatcher, provider } = setup;
      const { projectPath, ws } = await setupWithWorkspace(dispatcher, provider);

      // Delete fails (non-force) — workspace enters deletionPending
      provider.removeWorkspace.mockRejectedValue(new Error("EBUSY"));
      const deleteIntent: DeleteWorkspaceIntent = {
        type: "workspace:delete",
        payload: {
          workspacePath: ws.path.toString(),
          keepBranch: false,
          force: false,
          removeWorktree: true,
        },
      };
      await dispatchDeleteWorkspace(dispatcher, deleteIntent);

      // Close project — should clear deletionPending too
      await dispatchCloseProject(dispatcher, projectPath);

      // Re-open with empty list
      provider.discover.mockResolvedValue([]);
      await dispatchOpenProject(dispatcher, projectPath);

      // Workspace should not be resolvable (close cleared deletionPending)
      const resolveResult = await dispatchResolveWorkspace(dispatcher, ws.path.toString());
      expect(resolveResult.projectPath).toBeUndefined();
    });
  });

  // ---------------------------------------------------------------------------
  // metadata-changed event
  // ---------------------------------------------------------------------------

  describe("metadata-changed event", () => {
    async function emitMetadataChanged(
      dispatcher: Dispatcher,
      event: MetadataChangedEvent
    ): Promise<void> {
      await dispatcher.dispatch({
        type: "emit-event",
        payload: event,
      } as Intent);
    }

    async function dispatchListWorkspaces(
      dispatcher: Dispatcher
    ): Promise<ListWorkspacesHookResult> {
      return (await dispatcher.dispatch({
        type: "project:list",
        payload: {},
      } as Intent)) as ListWorkspacesHookResult;
    }

    it("updates metadata when a key is set", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      const event: MetadataChangedEvent = {
        type: EVENT_METADATA_CHANGED,
        payload: {
          projectId: "test-project" as ProjectId,
          workspaceName: "feature-1" as WorkspaceName,
          workspacePath: ws.path.toString(),
          key: "auto-workspace.tracked",
          value: "true",
        },
      };
      await emitMetadataChanged(dispatcher, event);

      const result = await dispatchListWorkspaces(dispatcher);
      const entry = result.entries!.find((e) => e.projectPath === projectPath);
      const updatedWs = entry!.workspaces.find((w) => w.name === "feature-1");
      expect(updatedWs!.metadata).toEqual({
        base: "origin/main",
        "auto-workspace.tracked": "true",
      });
    });

    it("removes metadata key when value is null", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws: Workspace = {
        name: "feature-1",
        path: new Path(`${projectPath}/.worktrees/feature-1`),
        branch: "feature-1",
        metadata: { base: "origin/main", "auto-workspace.tracked": "true" },
      };
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      const event: MetadataChangedEvent = {
        type: EVENT_METADATA_CHANGED,
        payload: {
          projectId: "test-project" as ProjectId,
          workspaceName: "feature-1" as WorkspaceName,
          workspacePath: ws.path.toString(),
          key: "auto-workspace.tracked",
          value: null,
        },
      };
      await emitMetadataChanged(dispatcher, event);

      const result = await dispatchListWorkspaces(dispatcher);
      const entry = result.entries!.find((e) => e.projectPath === projectPath);
      const updatedWs = entry!.workspaces.find((w) => w.name === "feature-1");
      expect(updatedWs!.metadata).toEqual({ base: "origin/main" });
    });

    it("ignores event for unknown workspace path", async () => {
      const { dispatcher, provider } = setup;
      const projectPath = "/projects/my-app";

      const ws = makeWorkspace("feature-1", projectPath);
      provider.discover.mockResolvedValue([ws]);
      await dispatchOpenProject(dispatcher, projectPath);

      const event: MetadataChangedEvent = {
        type: EVENT_METADATA_CHANGED,
        payload: {
          projectId: "test-project" as ProjectId,
          workspaceName: "unknown" as WorkspaceName,
          workspacePath: "/nonexistent/workspace",
          key: "auto-workspace.tracked",
          value: "true",
        },
      };
      await emitMetadataChanged(dispatcher, event);

      // Original workspace should be unchanged
      const result = await dispatchListWorkspaces(dispatcher);
      const entry = result.entries!.find((e) => e.projectPath === projectPath);
      const originalWs = entry!.workspaces.find((w) => w.name === "feature-1");
      expect(originalWs!.metadata).toEqual({ base: "origin/main" });
    });
  });
});
