import { intersection } from "es-toolkit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ConfigResolver } from "../../config/config-resolver.js";
import { CommandsProcessor } from "../../features/commands/commands-processor.js";
import { IgnoreProcessor } from "../../features/ignore/ignore-processor.js";
import { McpProcessor } from "../../features/mcp/mcp-processor.js";
import { RulesProcessor } from "../../features/rules/rules-processor.js";
import { SubagentsProcessor } from "../../features/subagents/subagents-processor.js";
import { createMockLogger } from "../../test-utils/mock-logger.js";
import { ErrorCodes } from "../../types/json-output.js";
import { fileExists } from "../../utils/file.js";
import type { GenerateOptions } from "./generate.js";
import { generateCommand } from "./generate.js";

// Mock all dependencies
vi.mock("../../config/config-resolver.js");
vi.mock("../../features/rules/rules-processor.js");
vi.mock("../../features/ignore/ignore-processor.js");
vi.mock("../../features/mcp/mcp-processor.js");
vi.mock("../../features/subagents/subagents-processor.js");
vi.mock("../../features/commands/commands-processor.js");
vi.mock("../../utils/file.js");
vi.mock("es-toolkit", () => ({
  intersection: vi.fn(),
}));

describe("generateCommand", () => {
  let mockExit: any;
  let mockConfig: any;
  let mockProcessorInstance: any;
  let mockLogger: ReturnType<typeof createMockLogger>;

  beforeEach(() => {
    // Mock process.cwd to return a consistent value
    vi.spyOn(process, "cwd").mockReturnValue("/test/project");

    // Mock process.exit
    mockExit = vi.spyOn(process, "exit").mockImplementation((() => {
      throw new Error("Process exit");
    }) as any);

    // Setup default mock config
    mockConfig = {
      getVerbose: vi.fn().mockReturnValue(false),
      getSilent: vi.fn().mockReturnValue(false),
      getOutputRoots: vi.fn().mockReturnValue(["."]),
      getTargets: vi.fn().mockReturnValue(["claudecode"]),
      getFeatures: vi.fn().mockReturnValue(["rules", "ignore", "mcp", "commands", "subagents"]),
      getFeatureOptions: vi.fn().mockReturnValue(undefined),
      getDelete: vi.fn().mockReturnValue(false),
      getGlobal: vi.fn().mockReturnValue(false),
      getSimulateCommands: vi.fn().mockReturnValue(false),
      getSimulateSubagents: vi.fn().mockReturnValue(false),
      getSimulateSkills: vi.fn().mockReturnValue(false),
      getDryRun: vi.fn().mockReturnValue(false),
      getCheck: vi.fn().mockReturnValue(false),
      isPreviewMode: vi.fn().mockReturnValue(false),
      getInputRoot: vi.fn().mockReturnValue(process.cwd()),
    };

    vi.mocked(ConfigResolver.resolve).mockResolvedValue(mockConfig);
    vi.mocked(fileExists).mockResolvedValue(true);

    mockLogger = createMockLogger();

    // Setup intersection mock to return the first array by default
    vi.mocked(intersection).mockImplementation((a, b) => a.filter((item) => b.includes(item)));

    // Setup default processor mock instance
    mockProcessorInstance = {
      loadToolFiles: vi.fn().mockResolvedValue([]),
      removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
      loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
      convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
      writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
    };

    // Setup processor static method mocks
    vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode"]);
    vi.mocked(IgnoreProcessor.getToolTargets).mockReturnValue(["claudecode"]);
    vi.mocked(McpProcessor.getToolTargets).mockReturnValue(["claudecode"]);
    vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
    vi.mocked(CommandsProcessor.getToolTargets).mockReturnValue(["claudecode"]);

    // Setup processor constructor mocks - create new instance each time to ensure isolation
    vi.mocked(RulesProcessor).mockImplementation(function () {
      return {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      } as any;
    });
    vi.mocked(IgnoreProcessor).mockImplementation(function () {
      return {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      } as any;
    });
    vi.mocked(McpProcessor).mockImplementation(function () {
      return {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      } as any;
    });
    vi.mocked(SubagentsProcessor).mockImplementation(function () {
      return {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      } as any;
    });
    vi.mocked(CommandsProcessor).mockImplementation(function () {
      return {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      } as any;
    });
  });

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

  describe("initial setup", () => {
    it("should resolve config", async () => {
      const options: GenerateOptions = { verbose: true };

      await generateCommand(mockLogger, options);

      expect(ConfigResolver.resolve).toHaveBeenCalledWith(options, { logger: mockLogger });
    });

    it("should log generating files message", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating files...");
    });

    it("should map deprecated --base-dir (singular) to outputRoots on the resolver call", async () => {
      const options: GenerateOptions = { baseDir: ["a", "b"] };

      await generateCommand(mockLogger, options);

      expect(ConfigResolver.resolve).toHaveBeenCalledWith(
        expect.objectContaining({ outputRoots: ["a", "b"] }),
        { logger: mockLogger },
      );
    });

    it("should warn that --base-dir is deprecated whenever it is supplied", async () => {
      const options: GenerateOptions = { baseDir: ["a"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining("--base-dir is deprecated"),
      );
    });

    it("should prefer outputRoots over deprecated baseDir when both are provided", async () => {
      const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["b"] };

      await generateCommand(mockLogger, options);

      expect(ConfigResolver.resolve).toHaveBeenCalledWith(
        expect.objectContaining({ outputRoots: ["b"] }),
        { logger: mockLogger },
      );
    });

    it("should warn when --base-dir and --output-roots disagree on non-empty values", async () => {
      const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["b"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });

    it("should not warn about override when --base-dir and --output-roots match exactly", async () => {
      const options: GenerateOptions = { baseDir: ["a", "b"], outputRoots: ["a", "b"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).not.toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });

    it("should not warn about override when --base-dir and --output-roots match as a set (different order)", async () => {
      // Order is irrelevant — `outputRoots: ["a","b"]` and `baseDir: ["b","a"]`
      // describe the same set of output roots, so no override is happening.
      const options: GenerateOptions = { baseDir: ["b", "a"], outputRoots: ["a", "b"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).not.toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });

    it("should warn about override when --base-dir and --output-roots differ as sets", async () => {
      // Different sets — `["a"]` vs `["a","b"]` — must trigger the override
      // warning even though one is a subset of the other.
      const options: GenerateOptions = { baseDir: ["a"], outputRoots: ["a", "b"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });

    it("should not warn about override when only deprecated baseDir is provided", async () => {
      const options: GenerateOptions = { baseDir: ["a"] };

      await generateCommand(mockLogger, options);

      expect(mockLogger.warn).not.toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });

    it("should fall back to deprecated baseDir when outputRoots is an empty array", async () => {
      // A programmatic caller passing `outputRoots: []` together with a
      // populated `baseDir` must not silently lose the alias's values to
      // the empty array. The empty array is treated as "not provided" so
      // the deprecated alias still flows through to the resolver.
      const options: GenerateOptions = { baseDir: ["a", "b"], outputRoots: [] };

      await generateCommand(mockLogger, options);

      expect(ConfigResolver.resolve).toHaveBeenCalledWith(
        expect.objectContaining({ outputRoots: ["a", "b"] }),
        { logger: mockLogger },
      );
      // The override warning must NOT fire here because the empty array is
      // treated as "not provided" — there is nothing to disagree about.
      expect(mockLogger.warn).not.toHaveBeenCalledWith(
        expect.stringContaining("Both '--output-roots' and '--base-dir'"),
      );
    });
  });

  describe("rulesync directory check", () => {
    it("should throw error when .rulesync directory does not exist", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);
      const options: GenerateOptions = {};

      await expect(generateCommand(mockLogger, options)).rejects.toThrow(
        ".rulesync directory not found",
      );

      expect(fileExists).toHaveBeenCalledWith("/test/project/.rulesync");
    });

    it("should continue when .rulesync directory exists", async () => {
      vi.mocked(fileExists).mockResolvedValue(true);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(fileExists).toHaveBeenCalledWith("/test/project/.rulesync");
      expect(mockExit).not.toHaveBeenCalled();
    });
  });

  describe("rules feature", () => {
    beforeEach(() => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
    });

    it("should generate rule files when rules feature is enabled", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating rule files...");
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          global: false,
          toolTarget: "claudecode",
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
    });

    it("should pass simulation options to RulesProcessor", async () => {
      mockConfig.getSimulateCommands.mockReturnValue(true);
      mockConfig.getSimulateSubagents.mockReturnValue(true);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          global: false,
          toolTarget: "claudecode",
          simulateCommands: true,
          simulateSubagents: true,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
    });

    it("should remove old files when delete option is enabled", async () => {
      mockConfig.getDelete.mockReturnValue(true);
      const oldFiles = [{ file: "old" }];

      // Create a custom mock instance for this test
      const customMockInstance = {
        loadToolFiles: vi.fn().mockResolvedValue(oldFiles),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return customMockInstance as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(customMockInstance.loadToolFiles).toHaveBeenCalledWith({ forDeletion: true });
      expect(customMockInstance.removeOrphanAiFiles).toHaveBeenCalled();
    });

    it("should process multiple base directories", async () => {
      mockConfig.getOutputRoots.mockReturnValue(["dir1", "dir2"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "dir1",
          global: false,
          toolTarget: "claudecode",
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "dir2",
          global: false,
          toolTarget: "claudecode",
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
    });

    it("should skip rules when feature is not enabled", async () => {
      mockConfig.getFeatures.mockReturnValue([]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).not.toHaveBeenCalled();
    });
  });

  describe("mcp feature", () => {
    beforeEach(() => {
      mockConfig.getFeatures.mockReturnValue(["mcp"]);
    });

    it("should generate MCP files when mcp feature is enabled", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating MCP files...");
      expect(McpProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: false,
          dryRun: false,
        }),
      );
    });

    it("should only process supported MCP targets", async () => {
      mockConfig.getTargets.mockReturnValue(["claudecode", "cursor", "unsupported"]);
      vi.mocked(intersection).mockReturnValue(["claudecode", "cursor"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(intersection).toHaveBeenCalledWith(
        ["claudecode", "cursor", "unsupported"],
        ["claudecode"],
      );
    });

    it("should remove old MCP files when delete option is enabled", async () => {
      mockConfig.getDelete.mockReturnValue(true);
      const oldFiles = [{ file: "old-mcp" }];

      // Create a custom mock instance for this test
      const customMockInstance = {
        loadToolFiles: vi.fn().mockResolvedValue(oldFiles),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      };
      vi.mocked(McpProcessor).mockImplementation(function () {
        return customMockInstance as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(customMockInstance.loadToolFiles).toHaveBeenCalledWith({ forDeletion: true });
      expect(customMockInstance.removeOrphanAiFiles).toHaveBeenCalled();
    });

    it("should skip MCP when feature is not enabled", async () => {
      mockConfig.getFeatures.mockReturnValue([]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(McpProcessor).not.toHaveBeenCalled();
    });
  });

  describe("commands feature", () => {
    beforeEach(() => {
      mockConfig.getFeatures.mockReturnValue(["commands"]);
    });

    it("should generate command files when commands feature is enabled", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating command files...");
      expect(CommandsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: false,
          dryRun: false,
        }),
      );
    });

    it("should pass includeSimulated flag to getToolTargets", async () => {
      mockConfig.getSimulateCommands.mockReturnValue(true);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(CommandsProcessor.getToolTargets).toHaveBeenCalledWith({
        global: false,
        includeSimulated: true,
      });
    });

    it("should skip commands when feature is not enabled", async () => {
      mockConfig.getFeatures.mockReturnValue([]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(CommandsProcessor).not.toHaveBeenCalled();
    });
  });

  describe("ignore feature", () => {
    beforeEach(() => {
      mockConfig.getFeatures.mockReturnValue(["ignore"]);
      mockConfig.getOutputRoots.mockReturnValue(["."]);
    });

    it("should generate ignore files when ignore feature is enabled", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating ignore files...");
      expect(IgnoreProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          dryRun: false,
        }),
      );
    });

    it("should pass outputRoot verbatim even when it equals current working directory", async () => {
      // The legacy `outputRoot === process.cwd() ? "." : outputRoot` heuristic was
      // removed to keep ignore processing consistent with every other
      // feature processor — IgnoreProcessor now receives the same absolute
      // path the other processors receive.
      const mockCwd = vi.spyOn(process, "cwd").mockReturnValue("/current/working/dir");
      mockConfig.getOutputRoots.mockReturnValue(["/current/working/dir"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(IgnoreProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/current/working/dir",
          toolTarget: "claudecode",
          dryRun: false,
        }),
      );

      mockCwd.mockRestore();
    });

    it("should handle errors in ignore processing gracefully", async () => {
      vi.mocked(IgnoreProcessor).mockImplementation(function () {
        throw new Error("Test error");
      });
      const options: GenerateOptions = {};

      // Should not throw, errors are caught and processing continues
      await generateCommand(mockLogger, options);

      // Should still complete without error
      expect(mockLogger.info).toHaveBeenCalledWith("✓ All files are up to date (ignore)");
    });

    it("should skip ignore files when no rulesync files found", async () => {
      mockProcessorInstance.loadRulesyncFiles.mockResolvedValue([]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockProcessorInstance.convertRulesyncFilesToToolFiles).not.toHaveBeenCalled();
      expect(mockProcessorInstance.writeAiFiles).not.toHaveBeenCalled();
    });
  });

  describe("subagents feature", () => {
    beforeEach(() => {
      mockConfig.getFeatures.mockReturnValue(["subagents"]);
    });

    it("should generate subagent files when subagents feature is enabled", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating subagent files...");
      expect(SubagentsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: false,
          dryRun: false,
        }),
      );
    });

    it("should pass includeSimulated flag to getToolTargets", async () => {
      mockConfig.getSimulateSubagents.mockReturnValue(true);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(SubagentsProcessor.getToolTargets).toHaveBeenCalledWith({
        global: false,
        includeSimulated: true,
      });
    });

    describe("global mode", () => {
      beforeEach(() => {
        mockConfig.getGlobal.mockReturnValue(true);
      });

      it("should use getToolTargets with global: true in global mode", async () => {
        vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
        const options: GenerateOptions = {};

        await generateCommand(mockLogger, options);

        expect(SubagentsProcessor.getToolTargets).toHaveBeenCalledWith(
          expect.objectContaining({ global: true }),
        );
      });

      it("should pass global flag to SubagentsProcessor constructor", async () => {
        vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
        const options: GenerateOptions = {};

        await generateCommand(mockLogger, options);

        expect(SubagentsProcessor).toHaveBeenCalledWith(
          expect.objectContaining({
            outputRoot: ".",
            toolTarget: "claudecode",
            global: true,
            dryRun: false,
          }),
        );
      });

      it("should only process claudecode target in global mode", async () => {
        mockConfig.getTargets.mockReturnValue(["claudecode", "copilot", "cursor"]);
        vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
        vi.mocked(intersection).mockReturnValue(["claudecode"]);
        const options: GenerateOptions = {};

        await generateCommand(mockLogger, options);

        expect(intersection).toHaveBeenCalledWith(
          ["claudecode", "copilot", "cursor"],
          ["claudecode"],
        );
        expect(SubagentsProcessor).toHaveBeenCalledWith(
          expect.objectContaining({
            outputRoot: ".",
            toolTarget: "claudecode",
            global: true,
            dryRun: false,
          }),
        );
      });

      it("should not process simulated targets in global mode even if simulateSubagents is true", async () => {
        mockConfig.getSimulateSubagents.mockReturnValue(true);
        mockConfig.getTargets.mockReturnValue(["claudecode", "copilot"]);
        vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
        vi.mocked(intersection).mockReturnValue(["claudecode"]);
        const options: GenerateOptions = {};

        await generateCommand(mockLogger, options);

        // Should use getToolTargets with global: true instead of includeSimulated
        expect(SubagentsProcessor.getToolTargets).toHaveBeenCalledWith(
          expect.objectContaining({ global: true }),
        );
        expect(SubagentsProcessor).toHaveBeenCalledTimes(1);
        expect(SubagentsProcessor).toHaveBeenCalledWith(
          expect.objectContaining({
            outputRoot: ".",
            toolTarget: "claudecode",
            global: true,
            dryRun: false,
          }),
        );
      });
    });
  });

  describe("output counting and final messages", () => {
    it("should show info when no files are written", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);

      // Override the RulesProcessor mock to return 0 files written
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
        } as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.info).toHaveBeenCalledWith("✓ All files are up to date (rules)");
    });

    it("should show success message with correct totals", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules", "mcp", "commands"]);

      // Create custom mock instances with specific return values
      const rulesMock = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 2, paths: [] }),
      };
      const mcpMock = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
      };
      const commandsMock = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      };

      vi.mocked(RulesProcessor).mockImplementation(function () {
        return rulesMock as any;
      });
      vi.mocked(McpProcessor).mockImplementation(function () {
        return mcpMock as any;
      });
      vi.mocked(CommandsProcessor).mockImplementation(function () {
        return commandsMock as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 6 file(s) total (2 rules + 3 MCP files + 1 commands)",
      );
    });

    it("should handle all feature types in success message", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules", "ignore", "mcp", "commands", "subagents"]);
      mockProcessorInstance.loadRulesyncFiles.mockResolvedValue([{ file: "test" }]); // For ignore to process

      mockProcessorInstance.writeAiFiles.mockResolvedValue({ count: 1, paths: [] });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 5 file(s) total (1 rules + 1 ignore files + 1 MCP files + 1 commands + 1 subagents)",
      );
    });

    it("should log output roots", async () => {
      mockConfig.getOutputRoots.mockReturnValue(["dir1", "dir2"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Output roots: dir1, dir2");
    });

    it("should log success for each processor type", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);

      // Create a custom mock instance that returns 3
      const customMockInstance = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return customMockInstance as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.success).toHaveBeenCalledWith("Written 3 rules");
    });
  });

  describe("check mode", () => {
    it("should fail when check mode would delete orphan files", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getCheck.mockReturnValue(true);
      mockConfig.getDelete.mockReturnValue(true);
      mockConfig.isPreviewMode.mockReturnValue(true);

      const rulesMock = {
        loadToolFiles: vi.fn().mockResolvedValue([
          {
            getFilePath: () => "/path/to/orphan",
          },
        ]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(1),
        loadRulesyncFiles: vi.fn().mockResolvedValue([
          {
            getFilePath: () => "/path/to/rulesync",
          },
        ]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([
          {
            getFilePath: () => "/path/to/converted",
          },
        ]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return rulesMock as any;
      });

      await expect(generateCommand(mockLogger, {})).rejects.toThrow(
        "Files are not up to date. Run 'rulesync generate' to update.",
      );

      expect(mockLogger.info).not.toHaveBeenCalledWith("✓ All files are up to date (rules)");
    });

    it("should succeed when check mode finds no diff", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getCheck.mockReturnValue(true);
      mockConfig.isPreviewMode.mockReturnValue(true);

      const rulesMock = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(0),
        loadRulesyncFiles: vi.fn().mockResolvedValue([
          {
            getFilePath: () => "/path/to/rulesync",
          },
        ]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([
          {
            getFilePath: () => "/path/to/converted",
          },
        ]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return rulesMock as any;
      });

      await generateCommand(mockLogger, {});

      expect(mockLogger.success).toHaveBeenCalledWith("✓ All files are up to date.");
    });
  });

  describe("error handling", () => {
    it("should handle ConfigResolver errors", async () => {
      vi.mocked(ConfigResolver.resolve).mockRejectedValue(new Error("Config error"));
      const options: GenerateOptions = {};

      await expect(generateCommand(mockLogger, options)).rejects.toThrow("Config error");
    });

    it("should handle file existence check errors", async () => {
      vi.mocked(fileExists).mockRejectedValue(new Error("File system error"));
      const options: GenerateOptions = {};

      await expect(generateCommand(mockLogger, options)).rejects.toThrow("File system error");
    });

    it("should handle processor instantiation errors", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      vi.mocked(RulesProcessor).mockImplementation(function () {
        throw new Error("Processor error");
      });
      const options: GenerateOptions = {};

      await expect(generateCommand(mockLogger, options)).rejects.toThrow("Processor error");
    });
  });

  describe("global mode", () => {
    beforeEach(() => {
      mockConfig.getGlobal.mockReturnValue(true);
      mockConfig.getFeatures.mockReturnValue(["rules", "mcp", "commands", "ignore", "subagents"]);
    });

    it("should check .rulesync directory from process.cwd() not from outputRoots in global mode", async () => {
      mockConfig.getOutputRoots.mockReturnValue(["/home/user"]);
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      // Should check .rulesync in process.cwd(), not in outputRoots[0] (which is /home/user in global mode)
      expect(fileExists).toHaveBeenCalledWith("/test/project/.rulesync");
    });

    it("should use getToolTargets with global: true when global mode is enabled", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor.getToolTargets).toHaveBeenCalledWith({ global: true });
    });

    it("should pass simulation options to RulesProcessor in global mode", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getSimulateCommands.mockReturnValue(true);
      mockConfig.getSimulateSubagents.mockReturnValue(true);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: true,
          simulateCommands: true,
          simulateSubagents: true,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
    });

    it("should process delete option in global mode", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getDelete.mockReturnValue(true);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);

      // Create a custom mock instance to track calls
      const customMockInstance = {
        loadToolFiles: vi.fn().mockResolvedValue([{ file: "old" }]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 1, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return customMockInstance as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(customMockInstance.loadToolFiles).toHaveBeenCalledWith({ forDeletion: true });
      expect(customMockInstance.removeOrphanAiFiles).toHaveBeenCalled();
    });

    it("should use each outputRoot in global mode", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getOutputRoots.mockReturnValue(["dir1", "dir2", "dir3"]);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "dir1",
          toolTarget: "claudecode",
          global: true,
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "dir2",
          toolTarget: "claudecode",
          global: true,
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "dir3",
          toolTarget: "claudecode",
          global: true,
          simulateCommands: false,
          simulateSubagents: false,
          simulateSkills: false,
          skills: [],
          dryRun: false,
        }),
      );
      expect(RulesProcessor).toHaveBeenCalledTimes(3); // Once for each outputRoot
    });

    it("should skip MCP generation in global mode when no targets match", async () => {
      // When targets is ["claudecode"] and global targets is ["codexcli"], intersection is empty
      vi.mocked(McpProcessor.getToolTargets).mockReturnValue(["codexcli"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.debug).toHaveBeenCalledWith("Generating MCP files...");
      // McpProcessor should not be called because intersection of targets is empty
      expect(McpProcessor).not.toHaveBeenCalled();
    });

    it("should generate commands in global mode for supported tools", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(CommandsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: true,
          dryRun: false,
        }),
      );
      expect(CommandsProcessor.getToolTargets).toHaveBeenCalledWith(
        expect.objectContaining({ global: true }),
      );
    });

    it("should skip ignore generation in global mode", async () => {
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(IgnoreProcessor).not.toHaveBeenCalled();
    });

    it("should generate claudecode subagents in global mode", async () => {
      mockConfig.getFeatures.mockReturnValue(["subagents"]);
      mockConfig.getTargets.mockReturnValue(["claudecode"]);
      vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(SubagentsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: ".",
          toolTarget: "claudecode",
          global: true,
          dryRun: false,
        }),
      );
    });

    it("should show success message with only rules count in global mode", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(intersection).mockReturnValue(["claudecode"]);

      // Create a custom mock instance that returns 5
      const customMockInstance = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 5, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return customMockInstance as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 5 file(s) total (5 rules)",
      );
    });

    it("should only process rules, commands, mcp, and subagents when global mode is enabled with multiple features", async () => {
      mockConfig.getTargets.mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(RulesProcessor.getToolTargets).mockReturnValue(["claudecode", "codexcli"]);
      vi.mocked(CommandsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
      vi.mocked(McpProcessor.getToolTargets).mockReturnValue(["codexcli"]);
      vi.mocked(SubagentsProcessor.getToolTargets).mockReturnValue(["claudecode"]);

      // Set up intersection to return correct values
      const originalIntersection = vi.mocked(intersection);
      originalIntersection.mockImplementation((a: readonly unknown[], b: readonly unknown[]) =>
        (a as unknown[]).filter((item) => (b as unknown[]).includes(item)),
      );

      // Create factory functions that return new mock instances each time
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
        } as any;
      });
      vi.mocked(McpProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
        } as any;
      });
      vi.mocked(CommandsProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
        } as any;
      });
      vi.mocked(SubagentsProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 3, paths: [] }),
        } as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(RulesProcessor).toHaveBeenCalledTimes(2); // Once for claudecode, once for codexcli
      expect(CommandsProcessor).toHaveBeenCalledTimes(1); // Once for claudecode
      expect(McpProcessor).toHaveBeenCalledTimes(1); // Once for codexcli in global mode
      expect(SubagentsProcessor).toHaveBeenCalledTimes(1); // Once for claudecode
      expect(IgnoreProcessor).not.toHaveBeenCalled();
      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 15 file(s) total (6 rules + 3 MCP files + 3 commands + 3 subagents)",
      );
    });
  });

  describe("inputRoot decoupling", () => {
    // Rules source dir (where .rulesync/ lives) is independent of output outputRoots.
    const inputRoot = "/central/rulesync-source";
    const outputRoots = ["/project/app-one", "/project/app-two"];

    beforeEach(() => {
      mockConfig.getInputRoot.mockReturnValue(inputRoot);
      mockConfig.getOutputRoots.mockReturnValue(outputRoots);
    });

    it("should check for .rulesync under inputRoot, not under outputRoots", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);

      await generateCommand(mockLogger, {});

      expect(fileExists).toHaveBeenCalledWith("/central/rulesync-source/.rulesync");
      expect(fileExists).not.toHaveBeenCalledWith("/project/app-one/.rulesync");
      expect(fileExists).not.toHaveBeenCalledWith("/project/app-two/.rulesync");
    });

    it("should construct RulesProcessor with inputRoot distinct from outputRoot for each output dir", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);

      await generateCommand(mockLogger, {});

      expect(RulesProcessor).toHaveBeenCalledTimes(2);
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-one",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
      expect(RulesProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-two",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
    });

    it("should pass inputRoot to IgnoreProcessor independently of outputRoot", async () => {
      mockConfig.getFeatures.mockReturnValue(["ignore"]);

      await generateCommand(mockLogger, {});

      expect(IgnoreProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-one",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
      expect(IgnoreProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-two",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
    });

    it("should pass inputRoot to McpProcessor independently of outputRoot", async () => {
      mockConfig.getFeatures.mockReturnValue(["mcp"]);

      await generateCommand(mockLogger, {});

      expect(McpProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-one",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
      expect(McpProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-two",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
    });

    it("should pass inputRoot to CommandsProcessor independently of outputRoot", async () => {
      mockConfig.getFeatures.mockReturnValue(["commands"]);

      await generateCommand(mockLogger, {});

      expect(CommandsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-one",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
      expect(CommandsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-two",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
    });

    it("should pass inputRoot to SubagentsProcessor independently of outputRoot", async () => {
      mockConfig.getFeatures.mockReturnValue(["subagents"]);

      await generateCommand(mockLogger, {});

      expect(SubagentsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-one",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
      expect(SubagentsProcessor).toHaveBeenCalledWith(
        expect.objectContaining({
          outputRoot: "/project/app-two",
          inputRoot,
          toolTarget: "claudecode",
        }),
      );
    });
  });

  describe("integration scenarios", () => {
    it("should handle mixed success and failure scenarios", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules", "ignore"]);

      // Set up rules processor to succeed
      const mockRulesProcessor = {
        loadToolFiles: vi.fn().mockResolvedValue([]),
        removeOrphanAiFiles: vi.fn().mockResolvedValue(undefined),
        loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
        convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
        writeAiFiles: vi.fn().mockResolvedValue({ count: 2, paths: [] }),
      };
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return mockRulesProcessor as any;
      });

      // Set up ignore processor to throw an error (errors are caught and ignored in lib)
      vi.mocked(IgnoreProcessor).mockImplementation(function () {
        throw new Error("Ignore error");
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      expect(mockLogger.success).toHaveBeenCalledWith("Written 2 rules");
      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 2 file(s) total (2 rules)",
      );
    });

    it("should fail in check mode when only orphan deletions occur (no new files)", async () => {
      // Regression test: previously, generateCommand returned early when
      // totalGenerated === 0, which skipped the --check mode hasDiff handling.
      // This caused check mode to silently succeed when the only diff was an
      // orphan file that would be deleted.
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getCheck.mockReturnValue(true);
      mockConfig.getDelete.mockReturnValue(true);

      // Shape of the orphan entry is irrelevant — only the count returned by
      // removeOrphanAiFiles drives the hasDiff branch under test.
      const removeOrphanMock = vi.fn().mockResolvedValue(1);
      const loadToolFilesMock = vi.fn().mockResolvedValue([{ orphan: "file" }]);
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return {
          loadToolFiles: loadToolFilesMock,
          removeOrphanAiFiles: removeOrphanMock,
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
        } as any;
      });

      const options: GenerateOptions = {};

      await expect(generateCommand(mockLogger, options)).rejects.toMatchObject({
        code: ErrorCodes.GENERATION_FAILED,
        message: "Files are not up to date. Run 'rulesync generate' to update.",
      });

      // Guard the actual code path: orphan detection must have run before the
      // check-mode hasDiff branch throws.
      expect(loadToolFilesMock).toHaveBeenCalled();
      expect(removeOrphanMock).toHaveBeenCalled();
    });

    it("should succeed in check mode when no diff exists and delete is enabled", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getCheck.mockReturnValue(true);
      mockConfig.getDelete.mockReturnValue(true);

      const removeOrphanMock = vi.fn().mockResolvedValue(0);
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: removeOrphanMock,
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
        } as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      // Orphan scan must run when delete is enabled, and report no diff.
      expect(removeOrphanMock).toHaveBeenCalled();
      expect(mockLogger.success).toHaveBeenCalledWith("✓ All files are up to date.");
    });

    it("should succeed in check mode when no diff exists and delete is disabled", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getCheck.mockReturnValue(true);
      mockConfig.getDelete.mockReturnValue(false);

      const removeOrphanMock = vi.fn().mockResolvedValue(0);
      vi.mocked(RulesProcessor).mockImplementation(function () {
        return {
          loadToolFiles: vi.fn().mockResolvedValue([]),
          removeOrphanAiFiles: removeOrphanMock,
          loadRulesyncFiles: vi.fn().mockResolvedValue([{ file: "test" }]),
          convertRulesyncFilesToToolFiles: vi.fn().mockResolvedValue([{ tool: "converted" }]),
          writeAiFiles: vi.fn().mockResolvedValue({ count: 0, paths: [] }),
        } as any;
      });

      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      // When delete is disabled, orphan removal must NOT run; the success log
      // still fires from the no-diff branch.
      expect(removeOrphanMock).not.toHaveBeenCalled();
      expect(mockLogger.success).toHaveBeenCalledWith("✓ All files are up to date.");
    });

    it("should handle multiple targets and base directories", async () => {
      mockConfig.getFeatures.mockReturnValue(["rules"]);
      mockConfig.getOutputRoots.mockReturnValue(["dir1", "dir2"]);
      mockConfig.getTargets.mockReturnValue(["claudecode", "cursor"]);
      vi.mocked(intersection).mockReturnValue(["claudecode", "cursor"]);

      mockProcessorInstance.writeAiFiles.mockResolvedValue({ count: 1, paths: [] });
      const options: GenerateOptions = {};

      await generateCommand(mockLogger, options);

      // Should create processors for each combination of base dir and target
      expect(RulesProcessor).toHaveBeenCalledTimes(4); // 2 dirs × 2 targets
      // Total count is 4 (1 per processor)
      expect(mockLogger.success).toHaveBeenCalledWith("Written 4 rules");
      expect(mockLogger.success).toHaveBeenCalledWith(
        "🎉 All done! Written 4 file(s) total (4 rules)",
      );
    });
  });
});
