/**
 * SpeechGateway Tests
 *
 * Issue #397: WebSocket streaming transcription endpoint tests.
 * Written FIRST following TDD (Red-Green-Refactor).
 *
 * Tests cover:
 * - Authentication via handshake token
 * - Session lifecycle: start -> audio chunks -> stop
 * - Transcription result emission
 * - Session cleanup on disconnect
 * - Error handling
 * - Buffer size limit enforcement
 */

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Socket } from "socket.io";
import { SpeechGateway } from "./speech.gateway";
import { SpeechService } from "./speech.service";
import { AuthService } from "../auth/auth.service";
import { PrismaService } from "../prisma/prisma.service";
import type { SpeechConfig } from "./speech.config";
import type { TranscriptionResult } from "./interfaces/speech-types";

// ==========================================
// Test helpers
// ==========================================

interface AuthenticatedSocket extends Socket {
  data: {
    userId?: string;
    workspaceId?: string;
  };
}

function createMockConfig(): SpeechConfig {
  return {
    stt: {
      enabled: true,
      baseUrl: "http://localhost:8000/v1",
      model: "test-model",
      language: "en",
    },
    tts: {
      default: { enabled: true, url: "http://localhost:8880/v1", voice: "test", format: "mp3" },
      premium: { enabled: false, url: "" },
      fallback: { enabled: false, url: "" },
    },
    limits: {
      maxUploadSize: 25_000_000,
      maxDurationSeconds: 600,
      maxTextLength: 4096,
    },
  };
}

function createMockSocket(overrides?: Partial<AuthenticatedSocket>): AuthenticatedSocket {
  return {
    id: "test-socket-id",
    join: vi.fn(),
    leave: vi.fn(),
    emit: vi.fn(),
    disconnect: vi.fn(),
    data: {},
    handshake: {
      auth: { token: "valid-token" },
      query: {},
      headers: {},
    },
    ...overrides,
  } as unknown as AuthenticatedSocket;
}

function createMockAuthService(): {
  verifySession: ReturnType<typeof vi.fn>;
} {
  return {
    verifySession: vi.fn().mockResolvedValue({
      user: { id: "user-123" },
      session: { id: "session-123" },
    }),
  };
}

function createMockPrismaService(): {
  workspaceMember: { findFirst: ReturnType<typeof vi.fn> };
} {
  return {
    workspaceMember: {
      findFirst: vi.fn().mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      }),
    },
  };
}

function createMockSpeechService(): {
  transcribe: ReturnType<typeof vi.fn>;
  isSTTAvailable: ReturnType<typeof vi.fn>;
} {
  return {
    transcribe: vi.fn().mockResolvedValue({
      text: "Hello world",
      language: "en",
      durationSeconds: 2.5,
    } satisfies TranscriptionResult),
    isSTTAvailable: vi.fn().mockReturnValue(true),
  };
}

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

describe("SpeechGateway", () => {
  let gateway: SpeechGateway;
  let mockAuthService: ReturnType<typeof createMockAuthService>;
  let mockPrismaService: ReturnType<typeof createMockPrismaService>;
  let mockSpeechService: ReturnType<typeof createMockSpeechService>;
  let mockConfig: SpeechConfig;
  let mockClient: AuthenticatedSocket;

  beforeEach(() => {
    mockAuthService = createMockAuthService();
    mockPrismaService = createMockPrismaService();
    mockSpeechService = createMockSpeechService();
    mockConfig = createMockConfig();
    mockClient = createMockSocket();

    gateway = new SpeechGateway(
      mockAuthService as unknown as AuthService,
      mockPrismaService as unknown as PrismaService,
      mockSpeechService as unknown as SpeechService,
      mockConfig
    );

    vi.clearAllMocks();
  });

  // ==========================================
  // Authentication
  // ==========================================
  describe("handleConnection", () => {
    it("should authenticate client and populate socket data on valid token", async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });

      await gateway.handleConnection(mockClient);

      expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
      expect(mockClient.data.userId).toBe("user-123");
      expect(mockClient.data.workspaceId).toBe("workspace-456");
    });

    it("should disconnect client without token", async () => {
      const clientNoToken = createMockSocket({
        handshake: { auth: {}, query: {}, headers: {} },
      } as Partial<AuthenticatedSocket>);

      await gateway.handleConnection(clientNoToken);

      expect(clientNoToken.disconnect).toHaveBeenCalled();
    });

    it("should disconnect client with invalid token", async () => {
      mockAuthService.verifySession.mockResolvedValue(null);

      await gateway.handleConnection(mockClient);

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

    it("should disconnect client without workspace access", async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);

      await gateway.handleConnection(mockClient);

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

    it("should disconnect client when auth throws", async () => {
      mockAuthService.verifySession.mockRejectedValue(new Error("Auth failure"));

      await gateway.handleConnection(mockClient);

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

    it("should extract token from handshake.query as fallback", async () => {
      const clientQueryToken = createMockSocket({
        handshake: {
          auth: {},
          query: { token: "query-token" },
          headers: {},
        },
      } as Partial<AuthenticatedSocket>);

      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });

      await gateway.handleConnection(clientQueryToken);

      expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
    });
  });

  // ==========================================
  // start-transcription
  // ==========================================
  describe("handleStartTranscription", () => {
    beforeEach(async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });
      await gateway.handleConnection(mockClient);
      vi.clearAllMocks();
    });

    it("should create a transcription session", () => {
      gateway.handleStartTranscription(mockClient, { language: "en" });

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-started",
        expect.objectContaining({ sessionId: expect.any(String) })
      );
    });

    it("should create a session with optional language parameter", () => {
      gateway.handleStartTranscription(mockClient, { language: "fr" });

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-started",
        expect.objectContaining({ sessionId: expect.any(String) })
      );
    });

    it("should create a session with no options", () => {
      gateway.handleStartTranscription(mockClient, {});

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-started",
        expect.objectContaining({ sessionId: expect.any(String) })
      );
    });

    it("should emit error if client is not authenticated", () => {
      const unauthClient = createMockSocket();
      // Not connected through handleConnection, so no userId set

      gateway.handleStartTranscription(unauthClient, {});

      expect(unauthClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should replace existing session if one already exists", () => {
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleStartTranscription(mockClient, { language: "de" });

      // Should have emitted transcription-started twice (no error)
      const startedCalls = (mockClient.emit as ReturnType<typeof vi.fn>).mock.calls.filter(
        (call: unknown[]) => call[0] === "transcription-started"
      );
      expect(startedCalls).toHaveLength(2);
    });
  });

  // ==========================================
  // audio-chunk
  // ==========================================
  describe("handleAudioChunk", () => {
    beforeEach(async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });
      await gateway.handleConnection(mockClient);
      vi.clearAllMocks();
      gateway.handleStartTranscription(mockClient, {});
      vi.clearAllMocks();
    });

    it("should accumulate audio data in the session", () => {
      const chunk = Buffer.from("audio-data-1");
      gateway.handleAudioChunk(mockClient, chunk);

      // No error emitted
      const errorCalls = (mockClient.emit as ReturnType<typeof vi.fn>).mock.calls.filter(
        (call: unknown[]) => call[0] === "transcription-error"
      );
      expect(errorCalls).toHaveLength(0);
    });

    it("should accept Uint8Array data and convert to Buffer", () => {
      const chunk = new Uint8Array([1, 2, 3, 4]);
      gateway.handleAudioChunk(mockClient, chunk);

      const errorCalls = (mockClient.emit as ReturnType<typeof vi.fn>).mock.calls.filter(
        (call: unknown[]) => call[0] === "transcription-error"
      );
      expect(errorCalls).toHaveLength(0);
    });

    it("should emit error if no active session exists", () => {
      const noSessionClient = createMockSocket({ id: "no-session" });
      noSessionClient.data = { userId: "user-123", workspaceId: "workspace-456" };

      const chunk = Buffer.from("audio-data");
      gateway.handleAudioChunk(noSessionClient, chunk);

      expect(noSessionClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should emit error if client is not authenticated", () => {
      const unauthClient = createMockSocket({ id: "unauth" });
      // Not authenticated

      const chunk = Buffer.from("audio-data");
      gateway.handleAudioChunk(unauthClient, chunk);

      expect(unauthClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should emit error when buffer size exceeds max upload size", () => {
      // Set a very small max upload size
      const smallConfig = createMockConfig();
      smallConfig.limits.maxUploadSize = 10;

      const limitedGateway = new SpeechGateway(
        mockAuthService as unknown as AuthService,
        mockPrismaService as unknown as PrismaService,
        mockSpeechService as unknown as SpeechService,
        smallConfig
      );

      // We need to manually set up the authenticated client in the new gateway
      const limitedClient = createMockSocket({ id: "limited-client" });
      limitedClient.data = { userId: "user-123", workspaceId: "workspace-456" };

      // Start session directly (since handleConnection populates data)
      limitedGateway.handleStartTranscription(limitedClient, {});
      vi.clearAllMocks();

      // Send a chunk that exceeds the limit
      const largeChunk = Buffer.alloc(20, "a");
      limitedGateway.handleAudioChunk(limitedClient, largeChunk);

      expect(limitedClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.stringContaining("exceeds") })
      );
    });

    it("should emit error when accumulated buffer size exceeds max upload size", () => {
      const smallConfig = createMockConfig();
      smallConfig.limits.maxUploadSize = 15;

      const limitedGateway = new SpeechGateway(
        mockAuthService as unknown as AuthService,
        mockPrismaService as unknown as PrismaService,
        mockSpeechService as unknown as SpeechService,
        smallConfig
      );

      const limitedClient = createMockSocket({ id: "limited-client-2" });
      limitedClient.data = { userId: "user-123", workspaceId: "workspace-456" };

      limitedGateway.handleStartTranscription(limitedClient, {});
      vi.clearAllMocks();

      // Send two chunks that together exceed the limit
      const chunk1 = Buffer.alloc(10, "a");
      const chunk2 = Buffer.alloc(10, "b");
      limitedGateway.handleAudioChunk(limitedClient, chunk1);
      limitedGateway.handleAudioChunk(limitedClient, chunk2);

      expect(limitedClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.stringContaining("exceeds") })
      );
    });
  });

  // ==========================================
  // stop-transcription
  // ==========================================
  describe("handleStopTranscription", () => {
    beforeEach(async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });
      await gateway.handleConnection(mockClient);
      vi.clearAllMocks();
    });

    it("should transcribe accumulated audio and emit final result", async () => {
      gateway.handleStartTranscription(mockClient, { language: "en" });

      const chunk1 = Buffer.from("audio-part-1");
      const chunk2 = Buffer.from("audio-part-2");
      gateway.handleAudioChunk(mockClient, chunk1);
      gateway.handleAudioChunk(mockClient, chunk2);

      vi.clearAllMocks();

      const expectedResult: TranscriptionResult = {
        text: "Hello world",
        language: "en",
        durationSeconds: 2.5,
      };
      mockSpeechService.transcribe.mockResolvedValue(expectedResult);

      await gateway.handleStopTranscription(mockClient);

      // Should have called transcribe with concatenated buffer
      expect(mockSpeechService.transcribe).toHaveBeenCalledWith(
        expect.any(Buffer),
        expect.objectContaining({})
      );

      // Should emit transcription-final
      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-final",
        expect.objectContaining({ text: "Hello world" })
      );
    });

    it("should pass language option to SpeechService.transcribe", async () => {
      gateway.handleStartTranscription(mockClient, { language: "fr" });
      gateway.handleAudioChunk(mockClient, Buffer.from("audio"));

      vi.clearAllMocks();

      await gateway.handleStopTranscription(mockClient);

      expect(mockSpeechService.transcribe).toHaveBeenCalledWith(
        expect.any(Buffer),
        expect.objectContaining({ language: "fr" })
      );
    });

    it("should clean up session after stop", async () => {
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleAudioChunk(mockClient, Buffer.from("audio"));

      await gateway.handleStopTranscription(mockClient);

      vi.clearAllMocks();

      // Sending more audio after stop should error (no session)
      gateway.handleAudioChunk(mockClient, Buffer.from("more-audio"));

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should emit transcription-error when transcription fails", async () => {
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleAudioChunk(mockClient, Buffer.from("audio"));

      vi.clearAllMocks();

      mockSpeechService.transcribe.mockRejectedValue(new Error("STT service down"));

      await gateway.handleStopTranscription(mockClient);

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.stringContaining("STT service down") })
      );
    });

    it("should emit error if no active session exists", async () => {
      await gateway.handleStopTranscription(mockClient);

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should emit error if client is not authenticated", async () => {
      const unauthClient = createMockSocket({ id: "unauth-stop" });

      await gateway.handleStopTranscription(unauthClient);

      expect(unauthClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should emit error when stopping with no audio chunks received", async () => {
      gateway.handleStartTranscription(mockClient, {});

      vi.clearAllMocks();

      await gateway.handleStopTranscription(mockClient);

      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.stringContaining("No audio") })
      );
    });
  });

  // ==========================================
  // handleDisconnect
  // ==========================================
  describe("handleDisconnect", () => {
    beforeEach(async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });
      await gateway.handleConnection(mockClient);
      vi.clearAllMocks();
    });

    it("should clean up active session on disconnect", () => {
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleAudioChunk(mockClient, Buffer.from("audio"));

      gateway.handleDisconnect(mockClient);

      // Session should be gone. Verify by trying to add a chunk to a new
      // socket with the same ID (should error since session was cleaned up).
      const newClient = createMockSocket({ id: mockClient.id });
      newClient.data = { userId: "user-123", workspaceId: "workspace-456" };

      gateway.handleAudioChunk(newClient, Buffer.from("more"));

      expect(newClient.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.any(String) })
      );
    });

    it("should not throw when disconnecting client without active session", () => {
      expect(() => gateway.handleDisconnect(mockClient)).not.toThrow();
    });

    it("should not throw when disconnecting unauthenticated client", () => {
      const unauthClient = createMockSocket({ id: "unauth-disconnect" });
      expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
    });
  });

  // ==========================================
  // Edge cases
  // ==========================================
  describe("edge cases", () => {
    beforeEach(async () => {
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-123" },
        session: { id: "session-123" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-123",
        workspaceId: "workspace-456",
        role: "MEMBER",
      });
      await gateway.handleConnection(mockClient);
      vi.clearAllMocks();
    });

    it("should handle multiple start-stop cycles for the same client", async () => {
      // First cycle
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleAudioChunk(mockClient, Buffer.from("cycle-1"));
      await gateway.handleStopTranscription(mockClient);

      vi.clearAllMocks();

      // Second cycle
      gateway.handleStartTranscription(mockClient, { language: "de" });
      gateway.handleAudioChunk(mockClient, Buffer.from("cycle-2"));
      await gateway.handleStopTranscription(mockClient);

      expect(mockSpeechService.transcribe).toHaveBeenCalledTimes(1);
      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-final",
        expect.objectContaining({ text: "Hello world" })
      );
    });

    it("should isolate sessions between different clients", async () => {
      const client2 = createMockSocket({ id: "client-2" });
      client2.data = { userId: "user-456", workspaceId: "workspace-789" };

      // Client 2 also needs to be "connected"
      mockAuthService.verifySession.mockResolvedValue({
        user: { id: "user-456" },
        session: { id: "session-456" },
      });
      mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
        userId: "user-456",
        workspaceId: "workspace-789",
        role: "MEMBER",
      });
      await gateway.handleConnection(client2);
      vi.clearAllMocks();

      // Start sessions for both clients
      gateway.handleStartTranscription(mockClient, {});
      gateway.handleStartTranscription(client2, {});

      // Send audio to client 1 only
      gateway.handleAudioChunk(mockClient, Buffer.from("audio-for-client-1"));

      // Stop client 2 (no audio)
      await gateway.handleStopTranscription(client2);

      // Client 2 should get an error (no audio received)
      expect(client2.emit).toHaveBeenCalledWith(
        "transcription-error",
        expect.objectContaining({ message: expect.stringContaining("No audio") })
      );

      vi.clearAllMocks();

      // Stop client 1 (has audio) -- should succeed
      await gateway.handleStopTranscription(mockClient);
      expect(mockSpeechService.transcribe).toHaveBeenCalled();
      expect(mockClient.emit).toHaveBeenCalledWith(
        "transcription-final",
        expect.objectContaining({ text: "Hello world" })
      );
    });
  });
});
