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

// --- Hoisted mocks ---

const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
  mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
  mockSavePref: vi.fn(),
}));

vi.mock("@components/settings/helpers", () => ({
  loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
  savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));

vi.mock("@lib/logger", () => ({
  createLogger: () => ({
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  }),
}));

const mockGetLocalDevices = vi.fn();

vi.mock("livekit-client", () => ({
  Room: Object.assign(vi.fn(), {
    getLocalDevices: (...args: unknown[]) => mockGetLocalDevices(...args),
  }),
}));

const mockVoiceState = vi.hoisted(() => ({
  localMuted: false,
  localDeafened: false,
  localServerMuted: false,
  pttGated: false,
}));

vi.mock("@stores/voice.store", () => ({
  voiceStore: {
    getState: () => mockVoiceState,
  },
}));

import { DeviceManager, isMicPolicyGated } from "../../src/lib/deviceManager";

describe("isMicPolicyGated", () => {
  beforeEach(() => {
    mockVoiceState.localMuted = false;
    mockVoiceState.localDeafened = false;
    mockVoiceState.localServerMuted = false;
    mockVoiceState.pttGated = false;
  });

  it("is false when nothing gates the mic", () => {
    expect(isMicPolicyGated()).toBe(false);
  });

  it("is true when localMuted", () => {
    mockVoiceState.localMuted = true;
    expect(isMicPolicyGated()).toBe(true);
  });

  it("is true when localDeafened", () => {
    mockVoiceState.localDeafened = true;
    expect(isMicPolicyGated()).toBe(true);
  });

  it("is true when localServerMuted", () => {
    mockVoiceState.localServerMuted = true;
    expect(isMicPolicyGated()).toBe(true);
  });

  it("is true when pttGated", () => {
    mockVoiceState.pttGated = true;
    expect(isMicPolicyGated()).toBe(true);
  });
});

describe("DeviceManager", () => {
  let dm: DeviceManager;
  let mockRoom: any;

  beforeEach(() => {
    vi.clearAllMocks();
    vi.useFakeTimers();
    mockVoiceState.localMuted = false;
    mockVoiceState.localDeafened = false;
    mockVoiceState.localServerMuted = false;
    mockVoiceState.pttGated = false;
    dm = new DeviceManager();
    mockRoom = {
      localParticipant: {
        setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined),
      },
      switchActiveDevice: vi.fn().mockResolvedValue(undefined),
    };

    // Stub navigator.mediaDevices
    vi.stubGlobal("navigator", {
      mediaDevices: {
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        enumerateDevices: vi.fn().mockResolvedValue([]),
      },
    });
  });

  afterEach(() => {
    dm.setRoom(null);
    vi.useRealTimers();
    vi.unstubAllGlobals();
  });

  // -----------------------------------------------------------------------
  // setRoom
  // -----------------------------------------------------------------------

  describe("setRoom", () => {
    it("accepts null and does not register a device change listener", () => {
      dm.setRoom(null);
      expect(navigator.mediaDevices.addEventListener).not.toHaveBeenCalled();
    });

    it("starts device change listener when room is set", () => {
      dm.setRoom(mockRoom);
      expect(navigator.mediaDevices.addEventListener).toHaveBeenCalledWith(
        "devicechange",
        expect.any(Function),
      );
    });

    it("stops device change listener when room is set to null", () => {
      dm.setRoom(mockRoom);
      dm.setRoom(null);
      expect(navigator.mediaDevices.removeEventListener).toHaveBeenCalledWith(
        "devicechange",
        expect.any(Function),
      );
    });

    it("stops old listener before starting new one when room changes", () => {
      dm.setRoom(mockRoom);
      const firstHandler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      dm.setRoom(mockRoom);
      expect(navigator.mediaDevices.removeEventListener).toHaveBeenCalledWith(
        "devicechange",
        firstHandler,
      );
    });
  });

  // -----------------------------------------------------------------------
  // setAudioPipeline, setOnError, setOnToast
  // -----------------------------------------------------------------------

  describe("setAudioPipeline", () => {
    it("clears the pipeline so device switches skip pipeline setup", async () => {
      const pipeline = {
        setupAudioPipeline: vi.fn(),
        applyNoiseSuppressor: vi.fn(),
        removeNoiseSuppressor: vi.fn(),
      } as any;
      dm.setAudioPipeline(pipeline);
      dm.setAudioPipeline(null);
      // Verify pipeline methods are NOT called on a subsequent device switch
      dm.setRoom(mockRoom);
      await dm.switchInputDevice("device-1");
      expect(pipeline.setupAudioPipeline).not.toHaveBeenCalled();
    });

    it("stores a pipeline that is invoked during device switches", async () => {
      const pipeline = {
        setupAudioPipeline: vi.fn(),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      await dm.switchInputDevice("device-1");
      expect(pipeline.setupAudioPipeline).toHaveBeenCalled();
    });
  });

  describe("setOnError", () => {
    it("clears the error callback so device switch errors are suppressed", async () => {
      const onError = vi.fn();
      dm.setOnError(onError);
      dm.setOnError(null);
      dm.setRoom(mockRoom);
      mockRoom.switchActiveDevice.mockRejectedValue(new Error("device error"));
      await dm.switchInputDevice("device-1");
      expect(onError).not.toHaveBeenCalled();
    });
  });

  describe("setOnToast", () => {
    it("clears the toast callback so device switch toasts are suppressed", async () => {
      const onToast = vi.fn();
      const pipeline = {
        setupAudioPipeline: vi.fn(() => {
          throw new Error("pipeline error");
        }),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setOnToast(onToast);
      dm.setOnToast(null);
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      await dm.switchInputDevice("device-1");
      expect(onToast).not.toHaveBeenCalled();
    });
  });

  // -----------------------------------------------------------------------
  // switchInputDevice
  // -----------------------------------------------------------------------

  describe("switchInputDevice", () => {
    it("does nothing when no room is set", async () => {
      await dm.switchInputDevice("device-1");
      expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled();
    });

    it("calls room.switchActiveDevice for non-empty deviceId", async () => {
      dm.setRoom(mockRoom);
      await dm.switchInputDevice("device-1");
      expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "device-1");
    });

    it("re-enables microphone for empty deviceId (default fallback)", async () => {
      dm.setRoom(mockRoom);
      await dm.switchInputDevice("");
      // The pinned capture-device constraint must be reset to the system
      // default before the cycle — otherwise the off/on toggle re-acquires
      // whatever device a previous switchActiveDevice pinned.
      expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
      expect(mockRoom.switchActiveDevice.mock.invocationCallOrder[0]).toBeLessThan(
        mockRoom.localParticipant.setMicrophoneEnabled.mock.invocationCallOrder[0],
      );
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
    });

    it("waits for the default-device reset before cycling the mic", async () => {
      let resolveSwitch: ((switched: boolean) => void) | null = null;
      mockRoom.switchActiveDevice.mockImplementation(
        () =>
          new Promise((resolve) => {
            resolveSwitch = resolve;
          }),
      );
      dm.setRoom(mockRoom);
      const done = dm.switchInputDevice("");

      expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();

      resolveSwitch!(true);
      await done;
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
    });

    // B1_voice_mic-2: switchInputDevice('') must not silently undo a mute,
    // deafen, server-mute, or PTT gate — livekit's setMicrophoneEnabled(true)
    // is a bare unmute when the muted-but-published track survives the
    // toggle, so nothing downstream ever observes the re-publish.
    it("does not re-enable the mic on default-device switch while the user is muted", async () => {
      mockVoiceState.localMuted = true;
      dm.setRoom(mockRoom);
      await dm.switchInputDevice("");
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
    });

    it("does not re-enable the mic on default-device switch while push-to-talk is gating it", async () => {
      mockVoiceState.pttGated = true;
      dm.setRoom(mockRoom);
      await dm.switchInputDevice("");
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
    });

    it("calls setupAudioPipeline on the pipeline after switch", async () => {
      const pipeline = {
        setupAudioPipeline: vi.fn(),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      await dm.switchInputDevice("device-1");
      expect(pipeline.setupAudioPipeline).toHaveBeenCalled();
    });

    it("applies enhanced noise suppression when enabled", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "enhancedNoiseSuppression") return true;
        return defaultVal;
      });
      const pipeline = {
        setupAudioPipeline: vi.fn(),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      await dm.switchInputDevice("device-1");
      expect(pipeline.applyNoiseSuppressor).toHaveBeenCalled();
    });

    it("removes noise suppression when not enabled", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "enhancedNoiseSuppression") return false;
        return defaultVal;
      });
      const pipeline = {
        setupAudioPipeline: vi.fn(),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      await dm.switchInputDevice("device-1");
      expect(pipeline.removeNoiseSuppressor).toHaveBeenCalled();
    });

    it("calls onError callback on device switch failure", async () => {
      const onError = vi.fn();
      dm.setRoom(mockRoom);
      dm.setOnError(onError);
      mockRoom.switchActiveDevice.mockRejectedValue(new Error("device error"));
      await dm.switchInputDevice("device-1");
      expect(onError).toHaveBeenCalledWith("Failed to switch microphone");
    });

    it("shows toast when pipeline setup fails", async () => {
      const onToast = vi.fn();
      const pipeline = {
        setupAudioPipeline: vi.fn(() => {
          throw new Error("pipeline error");
        }),
        applyNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
        removeNoiseSuppressor: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      dm.setOnToast(onToast);
      await dm.switchInputDevice("device-1");
      expect(onToast).toHaveBeenCalledWith("Audio pipeline error after device switch");
    });
  });

  // -----------------------------------------------------------------------
  // switchOutputDevice
  // -----------------------------------------------------------------------

  describe("switchOutputDevice", () => {
    it("skips device switch when no room is set", async () => {
      await dm.switchOutputDevice("device-1");
      expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled();
    });

    it("calls room.switchActiveDevice for audiooutput", async () => {
      dm.setRoom(mockRoom);
      await dm.switchOutputDevice("device-1");
      expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audiooutput", "device-1");
    });

    it("reports a failed switch instead of rejecting into the void", async () => {
      // The settings tab calls this as a bare `void` — an unhandled rejection
      // would leave the user with a selection that silently never applied.
      const onError = vi.fn();
      dm.setOnError(onError);
      dm.setRoom(mockRoom);
      mockRoom.switchActiveDevice.mockRejectedValueOnce(new Error("setSinkId unsupported"));

      await expect(dm.switchOutputDevice("device-1")).resolves.toBeUndefined();
      expect(onError).toHaveBeenCalledWith("Failed to switch speaker");
    });
  });

  // -----------------------------------------------------------------------
  // Device change detection (hot-swap)
  // -----------------------------------------------------------------------

  describe("handleDeviceChange", () => {
    it("skips device enumeration when room is null at change time", async () => {
      dm.setRoom(mockRoom);
      // Capture the handler
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      // Set room to null before triggering
      dm.setRoom(null);
      // Trigger the handler (simulates device change event)
      handler();
      await vi.advanceTimersByTimeAsync(600);
      // With room null, getLocalDevices should never be called
      expect(mockGetLocalDevices).not.toHaveBeenCalled();
      expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled();
    });

    it("falls back to default input when saved device is removed", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "saved-device-id";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });
      // The saved device is not in the returned list
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([{ deviceId: "other-device" }]);
        return Promise.resolve([]);
      });

      const onToast = vi.fn();
      dm.setRoom(mockRoom);
      dm.setOnToast(onToast);

      // Trigger device change
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(mockSavePref).toHaveBeenCalledWith("audioInputDevice", "");
      // The removed device's pinned constraint must be reset so the cycle
      // actually reaches the system default.
      expect(mockRoom.switchActiveDevice).toHaveBeenCalledWith("audioinput", "default", false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(true);
      expect(onToast).toHaveBeenCalledWith("Audio device disconnected — switched to default");
    });

    // B1_voice_mic-1: the device-removed fallback must not silently undo a
    // mute the user (or a moderator, or push-to-talk) applied.
    it("does not re-enable the mic on device-removed fallback while the user is muted", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "saved-device-id";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([{ deviceId: "other-device" }]);
        return Promise.resolve([]);
      });
      mockVoiceState.localMuted = true;

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(mockRoom.localParticipant.setMicrophoneEnabled).toHaveBeenCalledWith(false);
      expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalledWith(true);
    });

    it("does nothing if saved input device still exists", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "device-A";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([{ deviceId: "device-A" }]);
        return Promise.resolve([]);
      });

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      // Should not reset the saved device
      expect(mockSavePref).not.toHaveBeenCalledWith("audioInputDevice", "");
    });

    it("does nothing if no saved device (empty string)", async () => {
      mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal);
      mockGetLocalDevices.mockResolvedValue([]);

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(mockSavePref).not.toHaveBeenCalledWith("audioInputDevice", "");
    });

    it("falls back to default output when saved output device is removed", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "";
        if (key === "audioOutputDevice") return "saved-output-id";
        return defaultVal;
      });
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([]);
        if (kind === "audiooutput") return Promise.resolve([{ deviceId: "other-output" }]);
        return Promise.resolve([]);
      });

      const onToast = vi.fn();
      dm.setRoom(mockRoom);
      dm.setOnToast(onToast);

      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(mockSavePref).toHaveBeenCalledWith("audioOutputDevice", "");
      expect(onToast).toHaveBeenCalledWith(
        "Audio output device disconnected — switched to default",
      );
    });

    it("calls onError when mic fallback fails", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "saved-device-id";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([]);
        return Promise.resolve([]);
      });
      mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValue(new Error("no device"));

      const onError = vi.fn();
      dm.setRoom(mockRoom);
      dm.setOnError(onError);

      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(onError).toHaveBeenCalledWith("No audio input device available");
    });

    it("debounces rapid device change events", async () => {
      mockLoadPref.mockImplementation((_key: string, defaultVal: unknown) => defaultVal);
      mockGetLocalDevices.mockResolvedValue([]);

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];

      // Fire multiple times in rapid succession
      handler();
      handler();
      handler();

      await vi.advanceTimersByTimeAsync(600);

      // handleDeviceChange calls getLocalDevices twice (audioinput + audiooutput)
      // but only ONE handleDeviceChange should run (debounced from 3 events)
      expect(mockGetLocalDevices).toHaveBeenCalledTimes(2);
      expect(mockGetLocalDevices).toHaveBeenCalledWith("audioinput");
      expect(mockGetLocalDevices).toHaveBeenCalledWith("audiooutput");
    });

    it("shows toast when pipeline setup fails during fallback", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "saved-device-id";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") return Promise.resolve([]); // Device removed
        return Promise.resolve([]);
      });

      const pipeline = {
        setupAudioPipeline: vi.fn(() => {
          throw new Error("pipeline error");
        }),
      } as any;
      const onToast = vi.fn();

      dm.setRoom(mockRoom);
      dm.setAudioPipeline(pipeline);
      dm.setOnToast(onToast);

      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      expect(onToast).toHaveBeenCalledWith("Audio pipeline error after device switch");
    });

    it("handles enumerate devices failure without crashing or switching devices", async () => {
      mockGetLocalDevices.mockRejectedValue(new Error("enumerate error"));

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      await vi.advanceTimersByTimeAsync(600);

      // Enumerate failed, so no device switch should have been attempted
      expect(mockRoom.switchActiveDevice).not.toHaveBeenCalled();
    });

    it("ignores a stale enumeration result when the room is swapped mid-await (v096)", async () => {
      mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
        if (key === "audioInputDevice") return "saved-device-id";
        if (key === "audioOutputDevice") return "";
        return defaultVal;
      });

      let resolveDevices: ((devices: Array<{ deviceId: string }>) => void) | null = null;
      mockGetLocalDevices.mockImplementation((kind: string) => {
        if (kind === "audioinput") {
          return new Promise((resolve) => {
            resolveDevices = resolve;
          });
        }
        return Promise.resolve([]);
      });

      dm.setRoom(mockRoom);
      const handler = (navigator.mediaDevices.addEventListener as any).mock.calls[0][1];
      handler();
      // Fire the debounce — handleDeviceChange starts and suspends on the
      // still-pending getLocalDevices("audioinput") call.
      await vi.advanceTimersByTimeAsync(500);
      expect(resolveDevices).not.toBeNull();

      // A system-driven reconnect (LiveKit Disconnected -> syncModuleRooms)
      // swaps in a fresh Room while enumeration is still in flight.
      const newRoom = {
        localParticipant: { setMicrophoneEnabled: vi.fn().mockResolvedValue(undefined) },
        switchActiveDevice: vi.fn().mockResolvedValue(undefined),
      } as any;
      dm.setRoom(newRoom);

      // The saved device is missing from this (stale) result, which would
      // normally trigger a fallback.
      resolveDevices!([{ deviceId: "other-device" }]);
      await vi.advanceTimersByTimeAsync(0);

      // The stale attempt must not act on either the old room (it isn't
      // "current" anymore) or the new one (this attempt was never for it).
      expect(mockRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
      expect(newRoom.localParticipant.setMicrophoneEnabled).not.toHaveBeenCalled();
    });
  });
});
