import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ConfigResolver } from "../../config/config-resolver.js";
import { createMockLogger } from "../../test-utils/mock-logger.js";
import { fileExists, readFileContent, writeFileContent } from "../../utils/file.js";
import { ALL_GITIGNORE_ENTRIES, filterGitignoreEntries } from "./gitignore-entries.js";
import { gitignoreCommand } from "./gitignore.js";

const buildRulesyncBlock = (): string => {
  const entries = filterGitignoreEntries();
  return `# Generated by Rulesync\n${entries.join("\n")}\n`;
};

vi.mock("../../utils/file.js");
vi.mock("../../config/config-resolver.js", () => ({
  ConfigResolver: {
    resolve: vi.fn().mockResolvedValue({
      getGitignoreDestination: vi.fn().mockReturnValue("gitignore"),
    }),
  },
}));

describe("gitignore entries", () => {
  it("should use forward slashes only in all entries for .gitignore compatibility", () => {
    for (const entry of ALL_GITIGNORE_ENTRIES) {
      expect(entry, `Entry "${entry}" should not contain backslashes`).not.toContain("\\");
    }
  });
});

describe("gitignoreCommand", () => {
  const mockGitignorePath = "/workspace/.gitignore";
  let mockLogger: ReturnType<typeof createMockLogger>;

  beforeEach(() => {
    vi.spyOn(process, "cwd").mockReturnValue("/workspace");

    mockLogger = createMockLogger();

    vi.mocked(fileExists).mockResolvedValue(false);
    vi.mocked(readFileContent).mockResolvedValue("");
    vi.mocked(writeFileContent).mockResolvedValue(undefined);
  });

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

  describe("when .gitignore does not exist", () => {
    it("should create a new .gitignore with all AI tool rules", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      expect(fileExists).toHaveBeenCalledWith(mockGitignorePath);
      expect(readFileContent).not.toHaveBeenCalled();
      expect(writeFileContent).toHaveBeenCalledWith(
        mockGitignorePath,
        expect.stringContaining("# Generated by Rulesync"),
      );
      expect(mockLogger.success).toHaveBeenCalledWith("Updated .gitignore with rulesync entries:");
    });

    it("should include all expected AI tool patterns", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("**/.cursor/");
      expect(content).toContain("**/.clinerules/");
      expect(content).toContain("**/.clinerules/workflows/");
      expect(content).toContain("**/CLAUDE.md");
      expect(content).toContain("**/.opencode/agent/");
      expect(content).toContain("**/.gemini/memories/");
      expect(content).toContain("**/.roo/rules/");
      expect(content).toContain("**/.kilo/skills/");
      expect(content).toContain("**/.kilo/rules/");
      expect(content).toContain("**/.kilo/workflows/");
      expect(content).toContain("**/.roo/skills/");
      expect(content).toContain("**/.aiignore");
      expect(content).toContain("**/.mcp.json");
      expect(content).toContain("**/.github/agents/");
      expect(content).toContain("**/.github/hooks/");
      expect(content).toContain("**/.github/prompts/");
      expect(content).toContain("**/.warp/");
      expect(content).toContain("**/.codex/memories/");
      expect(content).toContain("**/.codex/skills/");
      expect(content).toContain("**/.deepagents/AGENTS.md");
      expect(content).toContain("**/.deepagents/memories/");
      expect(content).toContain("**/.deepagents/.mcp.json");
      expect(content).toContain("**/.deepagents/skills/");
      expect(content).toContain("**/.deepagents/agents/");
      expect(content).toContain("**/.deepagents/hooks.json");
      expect(content).not.toContain("**/.agent/rules/");
      expect(content).not.toContain("**/.agent/workflows/");
      expect(content).not.toContain("**/.agent/skills/");
    });

    it("should place .rulesync/.aiignore exception after .aiignore ignore entries", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content.indexOf("**/.aiignore")).toBeLessThan(content.indexOf("!.rulesync/.aiignore"));
    });

    it("should format content properly with newline at end", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toMatch(/\n$/);
      expect(content).toContain("# Generated by Rulesync");
    });
  });

  describe("when .gitignore exists but is empty", () => {
    it("should add all rules to empty .gitignore", async () => {
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue("");

      await gitignoreCommand(mockLogger);

      expect(fileExists).toHaveBeenCalledWith(mockGitignorePath);
      expect(readFileContent).toHaveBeenCalledWith(mockGitignorePath);
      expect(writeFileContent).toHaveBeenCalledWith(
        mockGitignorePath,
        expect.stringContaining("# Generated by Rulesync"),
      );
      expect(mockLogger.success).toHaveBeenCalledWith("Updated .gitignore with rulesync entries:");
    });
  });

  describe("when .gitignore exists with existing content", () => {
    it("should append rulesync block to existing content", async () => {
      const existingContent = "node_modules/\n*.log\n";
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain(existingContent.trim());
      expect(content).toContain("# Generated by Rulesync");
      expect(content).toMatch(/\*\.log\n\n# Generated by Rulesync/);
    });

    it("should preserve existing content with proper spacing", async () => {
      const existingContent = "node_modules/\n*.log";
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toMatch(/\*\.log\n\n# Generated by Rulesync/);
    });
  });

  describe("when rulesync entries already exist scattered in the file", () => {
    it("should remove scattered entries and consolidate at the end", async () => {
      const existingContent = `node_modules/
*.log
**/.cursor/
dist/`;

      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("node_modules/");
      expect(content).toContain("*.log");
      expect(content).toContain("dist/");

      const cursorMatches = content.match(/\*\*\/\.cursor\//g);
      expect(cursorMatches).toHaveLength(1);

      const rulesyncIndex = content.indexOf("# Generated by Rulesync");
      const cursorIndex = content.indexOf("**/.cursor/");
      expect(cursorIndex).toBeGreaterThan(rulesyncIndex);
    });

    it("should remove legacy header and entries", async () => {
      const existingContent = `node_modules/
# Generated by rulesync - AI tool configuration files
**/.cursor/

dist/`;

      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).not.toContain("# Generated by rulesync - AI tool configuration files");
      expect(content).toContain("# Generated by Rulesync");
      expect(content).toContain("node_modules/");
      expect(content).toContain("dist/");
    });
  });

  describe("when .gitignore is already up to date", () => {
    it("should report that .gitignore is already up to date", async () => {
      const rulesyncBlock = buildRulesyncBlock();

      vi.mocked(fileExists).mockImplementation(async (path: string) => path.endsWith(".gitignore"));
      vi.mocked(readFileContent).mockImplementation(async (path: string) =>
        path.endsWith(".gitignore") ? rulesyncBlock : "",
      );
      vi.mocked(writeFileContent).mockClear();
      vi.mocked(mockLogger.success).mockClear();
      vi.mocked(mockLogger.info).mockClear();

      await gitignoreCommand(mockLogger);

      expect(mockLogger.success).toHaveBeenCalledWith(
        ".gitignore / .gitattributes are already up to date",
      );
      expect(writeFileContent).not.toHaveBeenCalled();
    });
  });

  describe("logging behavior", () => {
    it("should log each added rule", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      expect(mockLogger.success).toHaveBeenCalledWith("Updated .gitignore with rulesync entries:");

      expect(mockLogger.info).toHaveBeenCalledWith("  **/.cursor/");
      expect(mockLogger.info).toHaveBeenCalledWith("  **/.kilo/rules/");
      expect(mockLogger.info).toHaveBeenCalledWith("  **/.opencode/agent/");
      expect(mockLogger.info).toHaveBeenCalledWith("  **/CLAUDE.md");
    });

    it("should log Antigravity workaround info after update", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Google Antigravity"));
      expect(mockLogger.info).toHaveBeenCalledWith(
        "   You can add the following to .git/info/exclude instead:",
      );
      expect(mockLogger.info).toHaveBeenCalledWith("   **/.agent/rules/");
      expect(mockLogger.info).toHaveBeenCalledWith("   **/.agent/workflows/");
      expect(mockLogger.info).toHaveBeenCalledWith("   **/.agent/skills/");
      expect(mockLogger.info).toHaveBeenCalledWith(
        "   For more details: https://github.com/dyoshikawa/rulesync/issues/981",
      );
    });

    it("should not log Antigravity workaround info when already up to date", async () => {
      const rulesyncBlock = buildRulesyncBlock();

      vi.mocked(fileExists).mockImplementation(async (path: string) => path.endsWith(".gitignore"));
      vi.mocked(readFileContent).mockImplementation(async (path: string) =>
        path.endsWith(".gitignore") ? rulesyncBlock : "",
      );
      vi.mocked(mockLogger.info).mockClear();

      await gitignoreCommand(mockLogger);

      const allInfoCalls = vi.mocked(mockLogger.info).mock.calls.map((call: unknown[]) => call[0]);
      const antigravityMessages = allInfoCalls.filter(
        (msg: unknown) => typeof msg === "string" && msg.includes("Antigravity"),
      );
      expect(antigravityMessages).toHaveLength(0);
    });
  });

  describe("error handling", () => {
    it("should handle file existence check errors", async () => {
      vi.mocked(fileExists).mockRejectedValue(new Error("Permission denied"));

      await expect(gitignoreCommand(mockLogger)).rejects.toThrow("Permission denied");

      expect(fileExists).toHaveBeenCalledWith(mockGitignorePath);
      expect(readFileContent).not.toHaveBeenCalled();
      expect(writeFileContent).not.toHaveBeenCalled();
    });

    it("should handle file read errors", async () => {
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockRejectedValue(new Error("Read error"));

      await expect(gitignoreCommand(mockLogger)).rejects.toThrow("Read error");

      expect(fileExists).toHaveBeenCalledWith(mockGitignorePath);
      expect(readFileContent).toHaveBeenCalledWith(mockGitignorePath);
      expect(writeFileContent).not.toHaveBeenCalled();
    });

    it("should handle file write errors", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);
      vi.mocked(writeFileContent).mockRejectedValue(new Error("Write error"));

      await expect(gitignoreCommand(mockLogger)).rejects.toThrow("Write error");

      expect(fileExists).toHaveBeenCalledWith(mockGitignorePath);
      expect(writeFileContent).toHaveBeenCalled();
      expect(mockLogger.success).not.toHaveBeenCalled();
    });
  });

  describe("path handling", () => {
    it("should use correct .gitignore path based on current working directory", async () => {
      const testCwd = "/different/path";
      vi.spyOn(process, "cwd").mockReturnValue(testCwd);
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger);

      expect(fileExists).toHaveBeenCalledWith(`${testCwd}/.gitignore`);
      expect(writeFileContent).toHaveBeenCalledWith(`${testCwd}/.gitignore`, expect.any(String));
    });
  });

  describe("content formatting", () => {
    it("should handle existing content with trailing whitespace", async () => {
      const existingContent = "node_modules/\n*.log   \n   ";
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toMatch(/\*\.log\n\n# Generated by Rulesync/);
    });

    it("should ensure proper line separation between existing and new content", async () => {
      const existingContent = "node_modules/";
      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toMatch(/node_modules\/\n\n# Generated by Rulesync/);
    });

    it("should handle rulesync block in the middle of file", async () => {
      const existingContent = `node_modules/
# Generated by Rulesync
**/.cursor/

other-stuff/
dist/`;

      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger);

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("node_modules/");
      expect(content).toContain("other-stuff/");
      expect(content).toContain("dist/");

      const rulesyncIndex = content.indexOf("# Generated by Rulesync");
      const otherStuffIndex = content.indexOf("other-stuff/");
      expect(rulesyncIndex).toBeGreaterThan(otherStuffIndex);
    });
  });

  describe("with targets filter", () => {
    it("should only include entries for specified target", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger, { targets: ["claudecode"] });

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("**/CLAUDE.md");
      expect(content).toContain("**/.claude/rules/");
      expect(content).toContain(".rulesync/skills/.curated/");
      expect(content).not.toContain("**/.cursor/");
      expect(content).not.toContain("**/.github/instructions/");
    });

    it("should still remove all existing rulesync entries regardless of filter", async () => {
      const existingContent = `node_modules/
**/.cursor/
**/.github/instructions/
dist/`;

      vi.mocked(fileExists).mockResolvedValue(true);
      vi.mocked(readFileContent).mockResolvedValue(existingContent);

      await gitignoreCommand(mockLogger, { targets: ["claudecode"] });

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("node_modules/");
      expect(content).toContain("dist/");
      // Scattered entries from other targets should be removed
      expect(content).not.toMatch(/^(?!.*# Generated by Rulesync).*\*\*\/\.cursor\//m);
    });
  });

  describe("with features filter", () => {
    it("should only include entries for specified features", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger, { features: ["rules"] });

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("**/CLAUDE.md");
      expect(content).toContain("**/.cursor/");
      // general entries should be included
      expect(content).toContain("**/.claude/memories/");
      // non-rules features should not be included
      expect(content).not.toContain("**/.claude/commands/");
      expect(content).not.toContain("**/.cursorignore");
    });
  });

  describe("with combined targets and features filter", () => {
    it("should apply both filters", async () => {
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger, { targets: ["copilot"], features: ["rules", "commands"] });

      const writeCall = vi.mocked(writeFileContent).mock.calls[0];
      expect(writeCall).toBeDefined();
      const content = writeCall![1];

      expect(content).toContain("**/.github/copilot-instructions.md");
      expect(content).toContain("**/.github/instructions/");
      expect(content).toContain("**/.github/prompts/");
      expect(content).not.toContain("**/.github/agents/");
      expect(content).not.toContain("**/CLAUDE.md");
    });
  });

  describe("gitattributes destination", () => {
    it("writes entries to .gitattributes when target destination is configured", async () => {
      const getGitignoreDestination = vi
        .fn()
        .mockImplementation((target: string) =>
          target === "claudecode" ? "gitattributes" : "gitignore",
        );
      vi.mocked(ConfigResolver.resolve).mockResolvedValueOnce({
        getGitignoreDestination,
      } as never);
      vi.mocked(fileExists).mockResolvedValue(false);

      await gitignoreCommand(mockLogger, { targets: ["claudecode"], features: ["rules"] });

      expect(writeFileContent).toHaveBeenCalledWith(
        "/workspace/.gitattributes",
        expect.stringContaining("**/CLAUDE.md"),
      );
    });
  });
});
