/**
 * Matrix Bridge Integration Tests
 *
 * These tests verify cross-service interactions in the Matrix bridge subsystem.
 * They use the NestJS Test module with mocked external dependencies (Prisma,
 * matrix-bot-sdk, discord.js) but test ACTUAL service-to-service wiring.
 *
 * Scenarios covered:
 * 1. BridgeModule DI: CHAT_PROVIDERS includes MatrixService when MATRIX_ACCESS_TOKEN is set
 * 2. BridgeModule without Matrix: Matrix excluded when MATRIX_ACCESS_TOKEN unset
 * 3. Command flow: room.message -> MatrixService -> CommandParserService -> StitcherService
 * 4. Herald broadcast: HeraldService broadcasts to MatrixService as a CHAT_PROVIDERS entry
 * 5. Room-workspace mapping: MatrixRoomService resolves workspace for MatrixService.handleRoomMessage
 * 6. Streaming flow: MatrixStreamingService.streamResponse via MatrixService's client
 * 7. Multi-provider coexistence: Both Discord and Matrix in CHAT_PROVIDERS
 */

import { Test, TestingModule } from "@nestjs/testing";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { BridgeModule } from "../bridge.module";
import { CHAT_PROVIDERS } from "../bridge.constants";
import { MatrixService } from "./matrix.service";
import { MatrixRoomService } from "./matrix-room.service";
import { MatrixStreamingService } from "./matrix-streaming.service";
import { CommandParserService } from "../parser/command-parser.service";
import { DiscordService } from "../discord/discord.service";
import { StitcherService } from "../../stitcher/stitcher.service";
import { HeraldService } from "../../herald/herald.service";
import { PrismaService } from "../../prisma/prisma.service";
import { BullMqService } from "../../bullmq/bullmq.service";
import { ChatProxyService } from "../../chat-proxy/chat-proxy.service";
import type { IChatProvider } from "../interfaces";
import { JOB_CREATED, JOB_STARTED } from "../../job-events/event-types";

// ---------------------------------------------------------------------------
// Mock discord.js
// ---------------------------------------------------------------------------
const mockDiscordReadyCallbacks: Array<() => void> = [];
const mockDiscordClient = {
  login: vi.fn().mockImplementation(async () => {
    mockDiscordReadyCallbacks.forEach((cb) => cb());
    return Promise.resolve();
  }),
  destroy: vi.fn().mockResolvedValue(undefined),
  on: vi.fn(),
  once: vi.fn().mockImplementation((event: string, callback: () => void) => {
    if (event === "ready") {
      mockDiscordReadyCallbacks.push(callback);
    }
  }),
  user: { tag: "TestBot#1234" },
  channels: { fetch: vi.fn() },
  guilds: { fetch: vi.fn() },
};

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

// ---------------------------------------------------------------------------
// Mock matrix-bot-sdk
// ---------------------------------------------------------------------------
const mockMatrixMessageCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> =
  [];
const mockMatrixEventCallbacks: Array<(roomId: string, event: Record<string, unknown>) => void> =
  [];

const mockMatrixClient = {
  start: vi.fn().mockResolvedValue(undefined),
  stop: vi.fn(),
  on: vi
    .fn()
    .mockImplementation(
      (event: string, callback: (roomId: string, evt: Record<string, unknown>) => void) => {
        if (event === "room.message") {
          mockMatrixMessageCallbacks.push(callback);
        }
        if (event === "room.event") {
          mockMatrixEventCallbacks.push(callback);
        }
      }
    ),
  sendMessage: vi.fn().mockResolvedValue("$mock-event-id"),
  sendEvent: vi.fn().mockResolvedValue("$mock-edit-event-id"),
  setTyping: vi.fn().mockResolvedValue(undefined),
  createRoom: vi.fn().mockResolvedValue("!new-room:example.com"),
};

vi.mock("matrix-bot-sdk", () => ({
  MatrixClient: class MockMatrixClient {
    start = mockMatrixClient.start;
    stop = mockMatrixClient.stop;
    on = mockMatrixClient.on;
    sendMessage = mockMatrixClient.sendMessage;
    sendEvent = mockMatrixClient.sendEvent;
    setTyping = mockMatrixClient.setTyping;
    createRoom = mockMatrixClient.createRoom;
  },
  SimpleFsStorageProvider: class MockStorage {
    constructor(_path: string) {
      // no-op
    }
  },
  AutojoinRoomsMixin: {
    setupOnClient: vi.fn(),
  },
}));

// ---------------------------------------------------------------------------
// Saved environment variables
// ---------------------------------------------------------------------------
interface SavedEnvVars {
  DISCORD_BOT_TOKEN?: string;
  DISCORD_GUILD_ID?: string;
  DISCORD_CONTROL_CHANNEL_ID?: string;
  DISCORD_WORKSPACE_ID?: string;
  MATRIX_ACCESS_TOKEN?: string;
  MATRIX_HOMESERVER_URL?: string;
  MATRIX_BOT_USER_ID?: string;
  MATRIX_CONTROL_ROOM_ID?: string;
  MATRIX_WORKSPACE_ID?: string;
  ENCRYPTION_KEY?: string;
}

const ENV_KEYS: (keyof SavedEnvVars)[] = [
  "DISCORD_BOT_TOKEN",
  "DISCORD_GUILD_ID",
  "DISCORD_CONTROL_CHANNEL_ID",
  "DISCORD_WORKSPACE_ID",
  "MATRIX_ACCESS_TOKEN",
  "MATRIX_HOMESERVER_URL",
  "MATRIX_BOT_USER_ID",
  "MATRIX_CONTROL_ROOM_ID",
  "MATRIX_WORKSPACE_ID",
  "ENCRYPTION_KEY",
];

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function saveAndClearEnv(): SavedEnvVars {
  const saved: SavedEnvVars = {};
  for (const key of ENV_KEYS) {
    saved[key] = process.env[key];
    delete process.env[key];
  }
  return saved;
}

function restoreEnv(saved: SavedEnvVars): void {
  for (const [key, value] of Object.entries(saved)) {
    if (value === undefined) {
      delete process.env[key];
    } else {
      process.env[key] = value;
    }
  }
}

function setMatrixEnv(): void {
  process.env.MATRIX_ACCESS_TOKEN = "test-matrix-token";
  process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
  process.env.MATRIX_BOT_USER_ID = "@bot:example.com";
  process.env.MATRIX_CONTROL_ROOM_ID = "!control-room:example.com";
  process.env.MATRIX_WORKSPACE_ID = "ws-integration-test";
}

function setDiscordEnv(): void {
  process.env.DISCORD_BOT_TOKEN = "test-discord-token";
  process.env.DISCORD_GUILD_ID = "test-guild-id";
  process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
  process.env.DISCORD_WORKSPACE_ID = "ws-discord-test";
}

function setEncryptionKey(): void {
  process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
  process.env.MOSAIC_SECRET_KEY = "test-mosaic-secret-key-minimum-32-characters-long";
}

/**
 * Compile the full BridgeModule with only external deps mocked
 */
async function compileBridgeModule(): Promise<TestingModule> {
  return Test.createTestingModule({
    imports: [BridgeModule],
  })
    .overrideProvider(PrismaService)
    .useValue({})
    .overrideProvider(BullMqService)
    .useValue({})
    .overrideProvider(ChatProxyService)
    .useValue({
      proxyChat: vi.fn().mockResolvedValue(new Response()),
    })
    .compile();
}

/**
 * Create an async iterable from an array of string tokens
 */
async function* createTokenStream(tokens: string[]): AsyncGenerator<string, void, undefined> {
  for (const token of tokens) {
    yield token;
  }
}

// ===========================================================================
// Integration Tests
// ===========================================================================

describe("Matrix Bridge Integration Tests", () => {
  let savedEnv: SavedEnvVars;

  beforeEach(() => {
    savedEnv = saveAndClearEnv();
    setEncryptionKey();

    // Clear callback arrays
    mockMatrixMessageCallbacks.length = 0;
    mockMatrixEventCallbacks.length = 0;
    mockDiscordReadyCallbacks.length = 0;

    vi.clearAllMocks();
  });

  afterEach(() => {
    restoreEnv(savedEnv);
  });

  // =========================================================================
  // Scenario 1: BridgeModule DI with Matrix enabled
  // =========================================================================
  describe("BridgeModule DI: Matrix enabled", () => {
    it("should include MatrixService in CHAT_PROVIDERS when MATRIX_ACCESS_TOKEN is set", async () => {
      setMatrixEnv();
      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toBeDefined();
      expect(providers.length).toBeGreaterThanOrEqual(1);

      const matrixProvider = providers.find((p) => p instanceof MatrixService);
      expect(matrixProvider).toBeDefined();
      expect(matrixProvider).toBeInstanceOf(MatrixService);
    });

    it("should export MatrixService, MatrixRoomService, MatrixStreamingService, and CommandParserService", async () => {
      setMatrixEnv();
      const module = await compileBridgeModule();

      expect(module.get(MatrixService)).toBeInstanceOf(MatrixService);
      expect(module.get(MatrixRoomService)).toBeInstanceOf(MatrixRoomService);
      expect(module.get(MatrixStreamingService)).toBeInstanceOf(MatrixStreamingService);
      expect(module.get(CommandParserService)).toBeInstanceOf(CommandParserService);
    });

    it("should provide StitcherService to MatrixService via StitcherModule import", async () => {
      setMatrixEnv();
      const module = await compileBridgeModule();

      const stitcher = module.get(StitcherService);
      expect(stitcher).toBeDefined();
      expect(stitcher).toBeInstanceOf(StitcherService);
    });
  });

  // =========================================================================
  // Scenario 2: BridgeModule without Matrix
  // =========================================================================
  describe("BridgeModule DI: Matrix disabled", () => {
    it("should NOT include MatrixService in CHAT_PROVIDERS when MATRIX_ACCESS_TOKEN is unset", async () => {
      // No Matrix env vars set - only encryption key
      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toBeDefined();
      const matrixProvider = providers.find((p) => p instanceof MatrixService);
      expect(matrixProvider).toBeUndefined();
    });

    it("should still register MatrixService as a provider even when not in CHAT_PROVIDERS", async () => {
      // MatrixService is always registered (for optional injection), just not in CHAT_PROVIDERS
      const module = await compileBridgeModule();

      const matrixService = module.get(MatrixService);
      expect(matrixService).toBeDefined();
      expect(matrixService).toBeInstanceOf(MatrixService);
    });

    it("should produce empty CHAT_PROVIDERS when neither bridge is configured", async () => {
      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toEqual([]);
    });
  });

  // =========================================================================
  // Scenario 3: Command flow - message -> parser -> stitcher
  // =========================================================================
  describe("Command flow: message -> CommandParserService -> StitcherService", () => {
    let matrixService: MatrixService;
    let stitcherService: StitcherService;
    let commandParser: CommandParserService;

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

    const mockRoomService = {
      getWorkspaceForRoom: vi.fn().mockResolvedValue(null),
      getRoomForWorkspace: vi.fn().mockResolvedValue(null),
      provisionRoom: vi.fn().mockResolvedValue(null),
      linkWorkspaceToRoom: vi.fn().mockResolvedValue(undefined),
      unlinkWorkspace: vi.fn().mockResolvedValue(undefined),
    };

    beforeEach(async () => {
      setMatrixEnv();

      const module = await Test.createTestingModule({
        providers: [
          MatrixService,
          CommandParserService,
          {
            provide: StitcherService,
            useValue: mockStitcher,
          },
          {
            provide: MatrixRoomService,
            useValue: mockRoomService,
          },
        ],
      }).compile();

      matrixService = module.get(MatrixService);
      stitcherService = module.get(StitcherService);
      commandParser = module.get(CommandParserService);
    });

    it("should parse @mosaic fix #42 through CommandParserService and dispatch to StitcherService", async () => {
      // MatrixRoomService returns a workspace for the room
      mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-mapped-123");

      await matrixService.connect();

      // Simulate incoming Matrix message event
      const callback = mockMatrixMessageCallbacks[0];
      expect(callback).toBeDefined();

      callback?.("!some-room:example.com", {
        event_id: "$ev-fix-42",
        sender: "@alice:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic fix #42",
        },
      });

      // Wait for async processing
      await new Promise((resolve) => setTimeout(resolve, 100));

      // Verify StitcherService.dispatchJob was called with correct workspace
      expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
        expect.objectContaining({
          workspaceId: "ws-mapped-123",
          type: "code-task",
          priority: 10,
          metadata: expect.objectContaining({
            issueNumber: 42,
            command: "fix",
            authorId: "@alice:example.com",
          }),
        })
      );
    });

    it("should normalize !mosaic prefix through CommandParserService and dispatch correctly", async () => {
      mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-bang-prefix");

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      callback?.("!room:example.com", {
        event_id: "$ev-bang-fix",
        sender: "@bob:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "!mosaic fix #99",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      expect(stitcherService.dispatchJob).toHaveBeenCalledWith(
        expect.objectContaining({
          workspaceId: "ws-bang-prefix",
          metadata: expect.objectContaining({
            issueNumber: 99,
          }),
        })
      );
    });

    it("should send help text when CommandParserService fails to parse an invalid command", async () => {
      mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-test");

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      callback?.("!room:example.com", {
        event_id: "$ev-bad-cmd",
        sender: "@user:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic invalidcmd",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      // Should NOT dispatch to stitcher
      expect(stitcherService.dispatchJob).not.toHaveBeenCalled();

      // Should send help text back to the room
      expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
        "!room:example.com",
        expect.objectContaining({
          body: expect.stringContaining("Available commands"),
        })
      );
    });

    it("should create a thread and send confirmation after dispatching a fix command", async () => {
      mockRoomService.getWorkspaceForRoom.mockResolvedValue("ws-thread-test");

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      callback?.("!room:example.com", {
        event_id: "$ev-fix-thread",
        sender: "@alice:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic fix #10",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      // First sendMessage creates the thread root
      const sendCalls = mockMatrixClient.sendMessage.mock.calls;
      expect(sendCalls.length).toBeGreaterThanOrEqual(2);

      // Thread root message
      const threadRootCall = sendCalls[0];
      expect(threadRootCall?.[0]).toBe("!room:example.com");
      expect(threadRootCall?.[1]).toEqual(
        expect.objectContaining({
          body: expect.stringContaining("Job #10"),
        })
      );

      // Confirmation message sent as thread reply (uses channelId from message, not hardcoded controlRoomId)
      const confirmationCall = sendCalls[1];
      expect(confirmationCall?.[0]).toBe("!room:example.com");
      expect(confirmationCall?.[1]).toEqual(
        expect.objectContaining({
          body: expect.stringContaining("Job created: job-integ-001"),
          "m.relates_to": expect.objectContaining({
            rel_type: "m.thread",
          }),
        })
      );
    });

    it("should verify CommandParserService is the real service (not a mock)", () => {
      // This confirms the integration test wires up the actual CommandParserService
      const result = commandParser.parseCommand("@mosaic fix #42");

      expect(result.success).toBe(true);
      if (result.success) {
        expect(result.command.action).toBe("fix");
        expect(result.command.issue?.number).toBe(42);
      }
    });
  });

  // =========================================================================
  // Scenario 4: Herald broadcast to MatrixService via CHAT_PROVIDERS
  // =========================================================================
  describe("Herald broadcast via CHAT_PROVIDERS", () => {
    it("should broadcast to MatrixService when it is connected", async () => {
      setMatrixEnv();

      // Create a connected mock MatrixService that tracks sendThreadMessage calls
      const threadMessages: Array<{ threadId: string; channelId: string; content: string }> = [];
      const mockMatrixProvider: IChatProvider = {
        connect: vi.fn().mockResolvedValue(undefined),
        disconnect: vi.fn().mockResolvedValue(undefined),
        isConnected: vi.fn().mockReturnValue(true),
        sendMessage: vi.fn().mockResolvedValue(undefined),
        createThread: vi.fn().mockResolvedValue("$thread-id"),
        sendThreadMessage: vi.fn().mockImplementation(async (options) => {
          threadMessages.push(options as { threadId: string; channelId: string; content: string });
        }),
        parseCommand: vi.fn().mockReturnValue(null),
      };

      const mockPrisma = {
        runnerJob: {
          findUnique: vi.fn().mockResolvedValue({
            id: "job-herald-001",
            workspaceId: "ws-herald-test",
            type: "code-task",
          }),
        },
        jobEvent: {
          findFirst: vi.fn().mockResolvedValue({
            payload: {
              metadata: {
                threadId: "$thread-herald-root",
                channelId: "!herald-room:example.com",
                issueNumber: 55,
              },
            },
          }),
        },
      };

      const module = await Test.createTestingModule({
        providers: [
          HeraldService,
          {
            provide: PrismaService,
            useValue: mockPrisma,
          },
          {
            provide: CHAT_PROVIDERS,
            useValue: [mockMatrixProvider],
          },
        ],
      }).compile();

      const herald = module.get(HeraldService);

      await herald.broadcastJobEvent("job-herald-001", {
        id: "evt-001",
        jobId: "job-herald-001",
        type: JOB_STARTED,
        timestamp: new Date(),
        actor: "stitcher",
        payload: {},
      });

      // Verify Herald sent the message via the MatrixService (CHAT_PROVIDERS)
      expect(threadMessages).toHaveLength(1);
      expect(threadMessages[0]?.threadId).toBe("$thread-herald-root");
      expect(threadMessages[0]?.content).toContain("#55");
    });

    it("should skip disconnected providers and continue to next", async () => {
      const disconnectedProvider: IChatProvider = {
        connect: vi.fn().mockResolvedValue(undefined),
        disconnect: vi.fn().mockResolvedValue(undefined),
        isConnected: vi.fn().mockReturnValue(false),
        sendMessage: vi.fn().mockResolvedValue(undefined),
        createThread: vi.fn().mockResolvedValue("$t"),
        sendThreadMessage: vi.fn().mockResolvedValue(undefined),
        parseCommand: vi.fn().mockReturnValue(null),
      };

      const connectedProvider: IChatProvider = {
        connect: vi.fn().mockResolvedValue(undefined),
        disconnect: vi.fn().mockResolvedValue(undefined),
        isConnected: vi.fn().mockReturnValue(true),
        sendMessage: vi.fn().mockResolvedValue(undefined),
        createThread: vi.fn().mockResolvedValue("$t"),
        sendThreadMessage: vi.fn().mockResolvedValue(undefined),
        parseCommand: vi.fn().mockReturnValue(null),
      };

      const mockPrisma = {
        runnerJob: {
          findUnique: vi.fn().mockResolvedValue({
            id: "job-skip-001",
            workspaceId: "ws-skip",
            type: "code-task",
          }),
        },
        jobEvent: {
          findFirst: vi.fn().mockResolvedValue({
            payload: {
              metadata: {
                threadId: "$thread-skip",
                channelId: "!skip-room:example.com",
                issueNumber: 1,
              },
            },
          }),
        },
      };

      const module = await Test.createTestingModule({
        providers: [
          HeraldService,
          {
            provide: PrismaService,
            useValue: mockPrisma,
          },
          {
            provide: CHAT_PROVIDERS,
            useValue: [disconnectedProvider, connectedProvider],
          },
        ],
      }).compile();

      const herald = module.get(HeraldService);

      await herald.broadcastJobEvent("job-skip-001", {
        id: "evt-002",
        jobId: "job-skip-001",
        type: JOB_CREATED,
        timestamp: new Date(),
        actor: "stitcher",
        payload: {},
      });

      // Disconnected provider should NOT have received message
      expect(disconnectedProvider.sendThreadMessage).not.toHaveBeenCalled();
      // Connected provider SHOULD have received message
      expect(connectedProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
    });

    it("should continue broadcasting to other providers if one throws an error", async () => {
      const failingProvider: IChatProvider = {
        connect: vi.fn().mockResolvedValue(undefined),
        disconnect: vi.fn().mockResolvedValue(undefined),
        isConnected: vi.fn().mockReturnValue(true),
        sendMessage: vi.fn().mockResolvedValue(undefined),
        createThread: vi.fn().mockResolvedValue("$t"),
        sendThreadMessage: vi.fn().mockRejectedValue(new Error("Network failure")),
        parseCommand: vi.fn().mockReturnValue(null),
      };

      const healthyProvider: IChatProvider = {
        connect: vi.fn().mockResolvedValue(undefined),
        disconnect: vi.fn().mockResolvedValue(undefined),
        isConnected: vi.fn().mockReturnValue(true),
        sendMessage: vi.fn().mockResolvedValue(undefined),
        createThread: vi.fn().mockResolvedValue("$t"),
        sendThreadMessage: vi.fn().mockResolvedValue(undefined),
        parseCommand: vi.fn().mockReturnValue(null),
      };

      const mockPrisma = {
        runnerJob: {
          findUnique: vi.fn().mockResolvedValue({
            id: "job-err-001",
            workspaceId: "ws-err",
            type: "code-task",
          }),
        },
        jobEvent: {
          findFirst: vi.fn().mockResolvedValue({
            payload: {
              metadata: {
                threadId: "$thread-err",
                channelId: "!err-room:example.com",
                issueNumber: 77,
              },
            },
          }),
        },
      };

      const module = await Test.createTestingModule({
        providers: [
          HeraldService,
          {
            provide: PrismaService,
            useValue: mockPrisma,
          },
          {
            provide: CHAT_PROVIDERS,
            useValue: [failingProvider, healthyProvider],
          },
        ],
      }).compile();

      const herald = module.get(HeraldService);

      // Should not throw even though first provider fails
      await expect(
        herald.broadcastJobEvent("job-err-001", {
          id: "evt-003",
          jobId: "job-err-001",
          type: JOB_STARTED,
          timestamp: new Date(),
          actor: "stitcher",
          payload: {},
        })
      ).resolves.toBeUndefined();

      // Both providers should have been attempted
      expect(failingProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
      expect(healthyProvider.sendThreadMessage).toHaveBeenCalledTimes(1);
    });
  });

  // =========================================================================
  // Scenario 5: Room-workspace mapping integration
  // =========================================================================
  describe("Room-workspace mapping: MatrixRoomService -> MatrixService", () => {
    let matrixService: MatrixService;

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

    const mockPrisma = {
      workspace: {
        findFirst: vi.fn(),
        findUnique: vi.fn(),
        update: vi.fn(),
      },
    };

    beforeEach(async () => {
      setMatrixEnv();

      const module = await Test.createTestingModule({
        providers: [
          MatrixService,
          CommandParserService,
          MatrixRoomService,
          {
            provide: StitcherService,
            useValue: mockStitcher,
          },
          {
            provide: PrismaService,
            useValue: mockPrisma,
          },
        ],
      }).compile();

      matrixService = module.get(MatrixService);
    });

    it("should resolve workspace from MatrixRoomService's Prisma lookup and dispatch command", async () => {
      // Mock Prisma: room maps to workspace
      mockPrisma.workspace.findFirst.mockResolvedValue({ id: "ws-prisma-resolved" });

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      callback?.("!mapped-room:example.com", {
        event_id: "$ev-room-map",
        sender: "@user:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic fix #77",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      // MatrixRoomService should have queried Prisma with the room ID
      expect(mockPrisma.workspace.findFirst).toHaveBeenCalledWith({
        where: { matrixRoomId: "!mapped-room:example.com" },
        select: { id: true },
      });

      // StitcherService should have been called with the resolved workspace
      expect(mockStitcher.dispatchJob).toHaveBeenCalledWith(
        expect.objectContaining({
          workspaceId: "ws-prisma-resolved",
        })
      );
    });

    it("should fall back to control room workspace when room is not mapped in Prisma", async () => {
      // Prisma returns no workspace for arbitrary rooms
      mockPrisma.workspace.findFirst.mockResolvedValue(null);

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      // Send to the control room (which is !control-room:example.com from setMatrixEnv)
      callback?.("!control-room:example.com", {
        event_id: "$ev-control-fallback",
        sender: "@user:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic fix #5",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      // Should use the env-configured workspace ID as fallback
      expect(mockStitcher.dispatchJob).toHaveBeenCalledWith(
        expect.objectContaining({
          workspaceId: "ws-integration-test",
        })
      );
    });

    it("should ignore messages in unmapped rooms that are not the control room", async () => {
      mockPrisma.workspace.findFirst.mockResolvedValue(null);

      await matrixService.connect();

      const callback = mockMatrixMessageCallbacks[0];
      callback?.("!unknown-room:example.com", {
        event_id: "$ev-unmapped",
        sender: "@user:example.com",
        origin_server_ts: Date.now(),
        content: {
          msgtype: "m.text",
          body: "@mosaic fix #1",
        },
      });

      await new Promise((resolve) => setTimeout(resolve, 100));

      expect(mockStitcher.dispatchJob).not.toHaveBeenCalled();
    });
  });

  // =========================================================================
  // Scenario 6: Streaming flow - MatrixStreamingService via MatrixService's client
  // =========================================================================
  describe("Streaming flow: MatrixStreamingService via MatrixService client", () => {
    let streamingService: MatrixStreamingService;
    let matrixService: MatrixService;

    const mockStitcher = {
      dispatchJob: vi.fn().mockResolvedValue({
        jobId: "job-stream-001",
        queueName: "main",
        status: "PENDING",
      }),
    };

    const mockRoomService = {
      getWorkspaceForRoom: vi.fn().mockResolvedValue(null),
      getRoomForWorkspace: vi.fn().mockResolvedValue(null),
      provisionRoom: vi.fn().mockResolvedValue(null),
      linkWorkspaceToRoom: vi.fn().mockResolvedValue(undefined),
      unlinkWorkspace: vi.fn().mockResolvedValue(undefined),
    };

    beforeEach(async () => {
      setMatrixEnv();

      const module = await Test.createTestingModule({
        providers: [
          MatrixService,
          MatrixStreamingService,
          CommandParserService,
          {
            provide: StitcherService,
            useValue: mockStitcher,
          },
          {
            provide: MatrixRoomService,
            useValue: mockRoomService,
          },
        ],
      }).compile();

      matrixService = module.get(MatrixService);
      streamingService = module.get(MatrixStreamingService);
    });

    it("should use the real MatrixService's client for streaming operations", async () => {
      // Connect MatrixService so the client is available
      await matrixService.connect();

      // Verify the client is available via getClient
      const client = matrixService.getClient();
      expect(client).not.toBeNull();

      // Verify MatrixStreamingService can use the client
      expect(matrixService.isConnected()).toBe(true);
    });

    it("should stream response through MatrixStreamingService using MatrixService connection", async () => {
      await matrixService.connect();

      const tokens = ["Hello", " ", "world"];
      const stream = createTokenStream(tokens);

      await streamingService.streamResponse("!room:example.com", stream);

      // Verify initial message was sent via the client
      expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
        "!room:example.com",
        expect.objectContaining({
          msgtype: "m.text",
          body: "Thinking...",
        })
      );

      // Verify typing indicator was managed
      expect(mockMatrixClient.setTyping).toHaveBeenCalledWith("!room:example.com", true, 30000);
      // Last setTyping call should clear the indicator
      const typingCalls = mockMatrixClient.setTyping.mock.calls;
      const lastTypingCall = typingCalls[typingCalls.length - 1];
      expect(lastTypingCall).toEqual(["!room:example.com", false, undefined]);

      // Verify the final edit contains accumulated text
      const editCalls = mockMatrixClient.sendEvent.mock.calls;
      expect(editCalls.length).toBeGreaterThanOrEqual(1);
      const lastEditCall = editCalls[editCalls.length - 1];
      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
      expect(lastEditCall[2]["m.new_content"].body).toBe("Hello world");
    });

    it("should throw when streaming without a connected MatrixService", async () => {
      // Do NOT connect MatrixService
      const stream = createTokenStream(["test"]);

      await expect(streamingService.streamResponse("!room:example.com", stream)).rejects.toThrow(
        "Matrix client is not connected"
      );
    });

    it("should support threaded streaming via MatrixStreamingService", async () => {
      await matrixService.connect();

      const tokens = ["Threaded", " ", "reply"];
      const stream = createTokenStream(tokens);

      await streamingService.streamResponse("!room:example.com", stream, {
        threadId: "$thread-root-event",
      });

      // Initial message should include thread relation
      expect(mockMatrixClient.sendMessage).toHaveBeenCalledWith(
        "!room:example.com",
        expect.objectContaining({
          "m.relates_to": expect.objectContaining({
            rel_type: "m.thread",
            event_id: "$thread-root-event",
          }),
        })
      );
    });
  });

  // =========================================================================
  // Scenario 7: Multi-provider coexistence
  // =========================================================================
  describe("Multi-provider coexistence: Discord + Matrix", () => {
    it("should include both DiscordService and MatrixService in CHAT_PROVIDERS when both tokens are set", async () => {
      setDiscordEnv();
      setMatrixEnv();

      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toHaveLength(2);

      const discordProvider = providers.find((p) => p instanceof DiscordService);
      const matrixProvider = providers.find((p) => p instanceof MatrixService);

      expect(discordProvider).toBeInstanceOf(DiscordService);
      expect(matrixProvider).toBeInstanceOf(MatrixService);
    });

    it("should maintain correct provider order: Discord first, then Matrix", async () => {
      setDiscordEnv();
      setMatrixEnv();

      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      // The factory pushes Discord first, then Matrix (based on BridgeModule order)
      expect(providers[0]).toBeInstanceOf(DiscordService);
      expect(providers[1]).toBeInstanceOf(MatrixService);
    });

    it("should share the same CommandParserService and StitcherService across both providers", async () => {
      setDiscordEnv();
      setMatrixEnv();

      const module = await compileBridgeModule();

      const discordService = module.get(DiscordService);
      const matrixService = module.get(MatrixService);
      const stitcher = module.get(StitcherService);
      const parser = module.get(CommandParserService);

      // Both services exist and are distinct instances
      expect(discordService).toBeDefined();
      expect(matrixService).toBeDefined();
      expect(discordService).not.toBe(matrixService);

      // Shared singletons
      expect(stitcher).toBeDefined();
      expect(parser).toBeDefined();
    });

    it("should include only DiscordService when MATRIX_ACCESS_TOKEN is unset", async () => {
      setDiscordEnv();
      // No Matrix env vars

      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toHaveLength(1);
      expect(providers[0]).toBeInstanceOf(DiscordService);
    });

    it("should include only MatrixService when DISCORD_BOT_TOKEN is unset", async () => {
      setMatrixEnv();
      // No Discord env vars

      const module = await compileBridgeModule();

      const providers = module.get<IChatProvider[]>(CHAT_PROVIDERS);

      expect(providers).toHaveLength(1);
      expect(providers[0]).toBeInstanceOf(MatrixService);
    });
  });
});
