import { Test, TestingModule } from "@nestjs/testing";
import { DiscordService } from "./discord.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import { PrismaService } from "../../prisma/prisma.service";
import { Client, Events, GatewayIntentBits, Message } from "discord.js";
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { ChatMessage, ChatCommand } from "../interfaces";

// Mock discord.js Client
const mockReadyCallbacks: Array<() => void> = [];
const mockErrorCallbacks: Array<(error: Error) => void> = [];
const mockClient = {
  login: vi.fn().mockImplementation(async () => {
    // Trigger ready callback when login is called
    mockReadyCallbacks.forEach((cb) => cb());
    return Promise.resolve();
  }),
  destroy: vi.fn().mockResolvedValue(undefined),
  on: vi.fn().mockImplementation((event: string, callback: (error: Error) => void) => {
    if (event === "error") {
      mockErrorCallbacks.push(callback);
    }
  }),
  once: vi.fn().mockImplementation((event: string, callback: () => void) => {
    if (event === "ready") {
      mockReadyCallbacks.push(callback);
    }
  }),
  user: { tag: "TestBot#1234" },
  channels: {
    fetch: vi.fn(),
  },
  guilds: {
    fetch: vi.fn(),
  },
};

vi.mock("discord.js", () => {
  return {
    Client: class MockClient {
      login = mockClient.login;
      destroy = mockClient.destroy;
      on = mockClient.on;
      once = mockClient.once;
      user = mockClient.user;
      channels = mockClient.channels;
      guilds = mockClient.guilds;
    },
    Events: {
      ClientReady: "ready",
      MessageCreate: "messageCreate",
      Error: "error",
    },
    GatewayIntentBits: {
      Guilds: 1 << 0,
      GuildMessages: 1 << 9,
      MessageContent: 1 << 15,
    },
  };
});

describe("DiscordService", () => {
  let service: DiscordService;
  let stitcherService: StitcherService;
  let chatProxyService: ChatProxyService;
  let prismaService: PrismaService;

  const mockStitcherService = {
    dispatchJob: vi.fn().mockResolvedValue({
      jobId: "test-job-id",
      queueName: "main",
      status: "PENDING",
    }),
    trackJobEvent: vi.fn().mockResolvedValue(undefined),
  };

  const mockChatProxyService = {
    proxyChat: vi.fn().mockResolvedValue(
      new Response('data: {"choices":[{"delta":{"content":"Hello"}}]}\n\ndata: [DONE]\n\n', {
        headers: { "Content-Type": "text/event-stream" },
      })
    ),
  };

  const mockPrismaService = {
    workspace: {
      findUnique: vi.fn().mockResolvedValue({
        ownerId: "owner-user-id",
      }),
    },
  };

  beforeEach(async () => {
    // Set environment variables for testing
    process.env.DISCORD_BOT_TOKEN = "test-token";
    process.env.DISCORD_GUILD_ID = "test-guild-id";
    process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
    process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
    process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";

    // Clear callbacks
    mockReadyCallbacks.length = 0;
    mockErrorCallbacks.length = 0;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DiscordService,
        {
          provide: StitcherService,
          useValue: mockStitcherService,
        },
        {
          provide: ChatProxyService,
          useValue: mockChatProxyService,
        },
        {
          provide: PrismaService,
          useValue: mockPrismaService,
        },
      ],
    }).compile();

    service = module.get<DiscordService>(DiscordService);
    stitcherService = module.get<StitcherService>(StitcherService);
    chatProxyService = module.get<ChatProxyService>(ChatProxyService);
    prismaService = module.get<PrismaService>(PrismaService);

    // Clear all mocks
    vi.clearAllMocks();
  });

  describe("Connection Management", () => {
    it("should connect to Discord", async () => {
      await service.connect();

      expect(mockClient.login).toHaveBeenCalledWith("test-token");
    });

    it("should disconnect from Discord", async () => {
      await service.connect();
      await service.disconnect();

      expect(mockClient.destroy).toHaveBeenCalled();
    });

    it("should check connection status", async () => {
      expect(service.isConnected()).toBe(false);

      await service.connect();
      expect(service.isConnected()).toBe(true);

      await service.disconnect();
      expect(service.isConnected()).toBe(false);
    });
  });

  describe("Message Handling", () => {
    it("should send a message to a channel", async () => {
      const mockChannel = {
        send: vi.fn().mockResolvedValue({}),
        isTextBased: () => true,
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockChannel);

      await service.connect();
      await service.sendMessage("test-channel-id", "Hello, Discord!");

      expect(mockClient.channels.fetch).toHaveBeenCalledWith("test-channel-id");
      expect(mockChannel.send).toHaveBeenCalledWith("Hello, Discord!");
    });

    it("should throw error if channel not found", async () => {
      (mockClient.channels.fetch as any).mockResolvedValue(null);

      await service.connect();

      await expect(service.sendMessage("invalid-channel", "Test")).rejects.toThrow(
        "Channel not found"
      );
    });
  });

  describe("Thread Management", () => {
    it("should create a thread for job updates", async () => {
      const mockChannel = {
        isTextBased: () => true,
        threads: {
          create: vi.fn().mockResolvedValue({
            id: "thread-123",
            send: vi.fn(),
          }),
        },
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockChannel);

      await service.connect();
      const threadId = await service.createThread({
        channelId: "test-channel-id",
        name: "Job #42",
        message: "Starting job...",
      });

      expect(threadId).toBe("thread-123");
      expect(mockChannel.threads.create).toHaveBeenCalledWith({
        name: "Job #42",
        reason: "Job updates thread",
      });
    });

    it("should send a message to a thread", async () => {
      const mockThread = {
        send: vi.fn().mockResolvedValue({}),
        isThread: () => true,
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockThread);

      await service.connect();
      await service.sendThreadMessage({
        threadId: "thread-123",
        channelId: "test-channel-id",
        content: "Step completed",
      });

      expect(mockThread.send).toHaveBeenCalledWith("Step completed");
    });
  });

  describe("Command Parsing", () => {
    it("should parse @mosaic fix command", () => {
      const message: ChatMessage = {
        id: "msg-1",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic fix 42",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "fix",
        args: ["42"],
        message,
      });
    });

    it("should parse @mosaic status command", () => {
      const message: ChatMessage = {
        id: "msg-2",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic status job-123",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "status",
        args: ["job-123"],
        message,
      });
    });

    it("should parse @mosaic cancel command", () => {
      const message: ChatMessage = {
        id: "msg-3",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic cancel job-456",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "cancel",
        args: ["job-456"],
        message,
      });
    });

    it("should parse @mosaic verbose command", () => {
      const message: ChatMessage = {
        id: "msg-4",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic verbose job-789",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "verbose",
        args: ["job-789"],
        message,
      });
    });

    it("should parse @mosaic quiet command", () => {
      const message: ChatMessage = {
        id: "msg-5",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic quiet",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "quiet",
        args: [],
        message,
      });
    });

    it("should parse @mosaic help command", () => {
      const message: ChatMessage = {
        id: "msg-6",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic help",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "help",
        args: [],
        message,
      });
    });

    it("should return null for non-command messages", () => {
      const message: ChatMessage = {
        id: "msg-7",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "Just a regular message",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toBeNull();
    });

    it("should return null for messages without @mosaic mention", () => {
      const message: ChatMessage = {
        id: "msg-8",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "fix 42",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toBeNull();
    });

    it("should handle commands with multiple arguments", () => {
      const message: ChatMessage = {
        id: "msg-9",
        channelId: "channel-1",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic fix 42 high-priority",
        timestamp: new Date(),
      };

      const command = service.parseCommand(message);

      expect(command).toEqual({
        command: "fix",
        args: ["42", "high-priority"],
        message,
      });
    });
  });

  describe("Command Execution", () => {
    it("should forward fix command to stitcher", async () => {
      const message: ChatMessage = {
        id: "msg-1",
        channelId: "test-channel-id",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic fix 42",
        timestamp: new Date(),
      };

      const mockThread = {
        id: "thread-123",
        send: vi.fn(),
        isThread: () => true,
      };

      const mockChannel = {
        isTextBased: () => true,
        threads: {
          create: vi.fn().mockResolvedValue(mockThread),
        },
      };

      // Mock channels.fetch to return channel first, then thread
      (mockClient.channels.fetch as any)
        .mockResolvedValueOnce(mockChannel)
        .mockResolvedValueOnce(mockThread);

      await service.connect();
      await service.handleCommand({
        command: "fix",
        args: ["42"],
        message,
      });

      expect(stitcherService.dispatchJob).toHaveBeenCalledWith({
        workspaceId: "test-workspace-id",
        type: "code-task",
        priority: 10,
        metadata: {
          issueNumber: 42,
          command: "fix",
          channelId: "test-channel-id",
          threadId: "thread-123",
          authorId: "user-1",
          authorName: "TestUser",
        },
      });
    });

    it("should respond with help message", async () => {
      const message: ChatMessage = {
        id: "msg-1",
        channelId: "test-channel-id",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic help",
        timestamp: new Date(),
      };

      const mockChannel = {
        send: vi.fn(),
        isTextBased: () => true,
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockChannel);

      await service.connect();
      await service.handleCommand({
        command: "help",
        args: [],
        message,
      });

      expect(mockChannel.send).toHaveBeenCalledWith(expect.stringContaining("Available commands:"));
    });
  });

  describe("Configuration", () => {
    it("should throw error if DISCORD_BOT_TOKEN is not set", async () => {
      delete process.env.DISCORD_BOT_TOKEN;

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          DiscordService,
          {
            provide: StitcherService,
            useValue: mockStitcherService,
          },
          {
            provide: ChatProxyService,
            useValue: mockChatProxyService,
          },
          {
            provide: PrismaService,
            useValue: mockPrismaService,
          },
        ],
      }).compile();

      const newService = module.get<DiscordService>(DiscordService);

      await expect(newService.connect()).rejects.toThrow("DISCORD_BOT_TOKEN is required");

      // Restore for other tests
      process.env.DISCORD_BOT_TOKEN = "test-token";
    });

    it("should throw error if DISCORD_WORKSPACE_ID is not set", async () => {
      delete process.env.DISCORD_WORKSPACE_ID;

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          DiscordService,
          {
            provide: StitcherService,
            useValue: mockStitcherService,
          },
          {
            provide: ChatProxyService,
            useValue: mockChatProxyService,
          },
          {
            provide: PrismaService,
            useValue: mockPrismaService,
          },
        ],
      }).compile();

      const newService = module.get<DiscordService>(DiscordService);

      await expect(newService.connect()).rejects.toThrow("DISCORD_WORKSPACE_ID is required");

      // Restore for other tests
      process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
    });

    it("should use configured workspace ID from environment", async () => {
      const testWorkspaceId = "configured-workspace-123";
      process.env.DISCORD_WORKSPACE_ID = testWorkspaceId;

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          DiscordService,
          {
            provide: StitcherService,
            useValue: mockStitcherService,
          },
          {
            provide: ChatProxyService,
            useValue: mockChatProxyService,
          },
          {
            provide: PrismaService,
            useValue: mockPrismaService,
          },
        ],
      }).compile();

      const newService = module.get<DiscordService>(DiscordService);

      const message: ChatMessage = {
        id: "msg-1",
        channelId: "test-channel-id",
        authorId: "user-1",
        authorName: "TestUser",
        content: "@mosaic fix 42",
        timestamp: new Date(),
      };

      const mockThread = {
        id: "thread-123",
        send: vi.fn(),
        isThread: () => true,
      };

      const mockChannel = {
        isTextBased: () => true,
        threads: {
          create: vi.fn().mockResolvedValue(mockThread),
        },
      };

      (mockClient.channels.fetch as any)
        .mockResolvedValueOnce(mockChannel)
        .mockResolvedValueOnce(mockThread);

      await newService.connect();
      await newService.handleCommand({
        command: "fix",
        args: ["42"],
        message,
      });

      expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith(
        expect.objectContaining({
          workspaceId: testWorkspaceId,
        })
      );

      // Restore for other tests
      process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
    });
  });

  describe("Error Logging Security", () => {
    it("should sanitize sensitive data in error logs", () => {
      const loggerErrorSpy = vi.spyOn((service as any).logger, "error");

      // Simulate an error with sensitive data
      const errorWithSecrets = new Error("Connection failed");
      (errorWithSecrets as any).config = {
        headers: {
          Authorization: "Bearer secret_token_12345",
        },
      };
      (errorWithSecrets as any).token =
        "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs";

      // Trigger error event handler
      expect(mockErrorCallbacks.length).toBeGreaterThan(0);
      mockErrorCallbacks[0]?.(errorWithSecrets);

      // Verify error was logged
      expect(loggerErrorSpy).toHaveBeenCalled();

      // Get the logged error
      const loggedArgs = loggerErrorSpy.mock.calls[0];
      const loggedError = loggedArgs[1];

      // Verify sensitive data was redacted
      expect(loggedError.config.headers.Authorization).toBe("[REDACTED]");
      expect(loggedError.token).toBe("[REDACTED]");
      expect(loggedError.message).toBe("Connection failed");
      expect(loggedError.name).toBe("Error");
    });

    it("should not leak bot token in error logs", () => {
      const loggerErrorSpy = vi.spyOn((service as any).logger, "error");

      // Simulate an error with bot token in message
      const errorWithToken = new Error(
        "Discord authentication failed with token MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs"
      );

      // Trigger error event handler
      expect(mockErrorCallbacks.length).toBeGreaterThan(0);
      mockErrorCallbacks[0]?.(errorWithToken);

      // Verify error was logged
      expect(loggerErrorSpy).toHaveBeenCalled();

      // Get the logged error
      const loggedArgs = loggerErrorSpy.mock.calls[0];
      const loggedError = loggedArgs[1];

      // Verify token was redacted from message
      expect(loggedError.message).not.toContain(
        "MTk4NjIyNDgzNDcxOTI1MjQ4.Cl2FMQ.ZnCjm1XVW7vRze4b7Cq4se7kKWs"
      );
      expect(loggedError.message).toContain("[REDACTED]");
    });

    it("should sanitize API keys in error logs", () => {
      const loggerErrorSpy = vi.spyOn((service as any).logger, "error");

      // Simulate an error with API key
      const errorWithApiKey = new Error("Request failed");
      (errorWithApiKey as any).apiKey = "sk_live_1234567890abcdef";
      (errorWithApiKey as any).response = {
        data: {
          error: "Invalid API key: sk_live_1234567890abcdef",
        },
      };

      // Trigger error event handler
      expect(mockErrorCallbacks.length).toBeGreaterThan(0);
      mockErrorCallbacks[0]?.(errorWithApiKey);

      // Verify error was logged
      expect(loggerErrorSpy).toHaveBeenCalled();

      // Get the logged error
      const loggedArgs = loggerErrorSpy.mock.calls[0];
      const loggedError = loggedArgs[1];

      // Verify API key was redacted
      expect(loggedError.apiKey).toBe("[REDACTED]");
      expect(loggedError.response.data.error).not.toContain("sk_live_1234567890abcdef");
      expect(loggedError.response.data.error).toContain("[REDACTED]");
    });

    it("should preserve non-sensitive error information", () => {
      const loggerErrorSpy = vi.spyOn((service as any).logger, "error");

      // Simulate a normal error without secrets
      const normalError = new Error("Connection timeout");
      (normalError as any).code = "ETIMEDOUT";
      (normalError as any).statusCode = 408;

      // Trigger error event handler
      expect(mockErrorCallbacks.length).toBeGreaterThan(0);
      mockErrorCallbacks[0]?.(normalError);

      // Verify error was logged
      expect(loggerErrorSpy).toHaveBeenCalled();

      // Get the logged error
      const loggedArgs = loggerErrorSpy.mock.calls[0];
      const loggedError = loggedArgs[1];

      // Verify non-sensitive data was preserved
      expect(loggedError.message).toBe("Connection timeout");
      expect(loggedError.name).toBe("Error");
      expect(loggedError.code).toBe("ETIMEDOUT");
      expect(loggedError.statusCode).toBe(408);
    });
  });

  describe("Agent Channel Routing", () => {
    it("should load agent channel mappings from environment", () => {
      // The service should have loaded the agent channels from DISCORD_AGENT_CHANNELS
      expect((service as any).agentChannels.size).toBe(2);
      expect((service as any).agentChannels.get("jarvis-channel")).toBe("jarvis");
      expect((service as any).agentChannels.get("builder-channel")).toBe("builder");
    });

    it("should handle empty agent channels config", async () => {
      delete process.env.DISCORD_AGENT_CHANNELS;

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          DiscordService,
          {
            provide: StitcherService,
            useValue: mockStitcherService,
          },
          {
            provide: ChatProxyService,
            useValue: mockChatProxyService,
          },
          {
            provide: PrismaService,
            useValue: mockPrismaService,
          },
        ],
      }).compile();

      const newService = module.get<DiscordService>(DiscordService);
      expect((newService as any).agentChannels.size).toBe(0);

      // Restore for other tests
      process.env.DISCORD_AGENT_CHANNELS = "jarvis-channel:jarvis,builder-channel:builder";
    });

    it("should route messages in agent channels to ChatProxyService", async () => {
      const mockChannel = {
        send: vi.fn().mockResolvedValue({}),
        isTextBased: () => true,
        sendTyping: vi.fn(),
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockChannel);

      // Create a mock streaming response
      const mockStreamResponse = new Response(
        'data: {"choices":[{"delta":{"content":"Test response"}}]}\n\ndata: [DONE]\n\n',
        { headers: { "Content-Type": "text/event-stream" } }
      );
      mockChatProxyService.proxyChat.mockResolvedValue(mockStreamResponse);

      await service.connect();

      // Simulate a message in the jarvis channel
      const message: ChatMessage = {
        id: "msg-agent-1",
        channelId: "jarvis-channel",
        authorId: "user-1",
        authorName: "TestUser",
        content: "Hello Jarvis!",
        timestamp: new Date(),
      };

      // Call handleAgentChat directly
      await (service as any).handleAgentChat(message, "jarvis");

      // Verify ChatProxyService was called with workspace owner's ID and agent name
      expect(mockChatProxyService.proxyChat).toHaveBeenCalledWith(
        "owner-user-id",
        [{ role: "user", content: "Hello Jarvis!" }],
        undefined,
        "jarvis"
      );

      // Verify response was sent to channel
      expect(mockChannel.send).toHaveBeenCalled();
    });

    it("should not route empty messages", async () => {
      const message: ChatMessage = {
        id: "msg-empty",
        channelId: "jarvis-channel",
        authorId: "user-1",
        authorName: "TestUser",
        content: "   ",
        timestamp: new Date(),
      };

      await (service as any).handleAgentChat(message, "jarvis");

      expect(mockChatProxyService.proxyChat).not.toHaveBeenCalled();
    });

    it("should handle ChatProxyService errors gracefully", async () => {
      const mockChannel = {
        send: vi.fn().mockResolvedValue({}),
        isTextBased: () => true,
        sendTyping: vi.fn(),
      };
      (mockClient.channels.fetch as any).mockResolvedValue(mockChannel);

      mockChatProxyService.proxyChat.mockRejectedValue(new Error("Agent not found"));

      await service.connect();

      const message: ChatMessage = {
        id: "msg-error",
        channelId: "jarvis-channel",
        authorId: "user-1",
        authorName: "TestUser",
        content: "Hello",
        timestamp: new Date(),
      };

      await (service as any).handleAgentChat(message, "jarvis");

      // Should send error message to channel
      expect(mockChannel.send).toHaveBeenCalledWith(
        expect.stringContaining("Failed to get response from jarvis")
      );
    });

    it("should split long messages for Discord", () => {
      const longContent = "A".repeat(5000);
      const chunks = (service as any).splitMessageForDiscord(longContent);

      // Should split into chunks of 2000 or less
      expect(chunks.length).toBeGreaterThan(1);
      for (const chunk of chunks) {
        expect(chunk.length).toBeLessThanOrEqual(2000);
      }

      // Reassembled content should match original
      expect(chunks.join("")).toBe(longContent.trim());
    });

    it("should prefer paragraph breaks when splitting messages", () => {
      const content = "A".repeat(1500) + "\n\n" + "B".repeat(1500);
      const chunks = (service as any).splitMessageForDiscord(content);

      expect(chunks.length).toBe(2);
      expect(chunks[0]).toContain("A");
      expect(chunks[1]).toContain("B");
    });
  });
});
