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

const mockSwitchInputDevice = vi.fn().mockResolvedValue(undefined);
const mockSwitchOutputDevice = vi.fn().mockResolvedValue(undefined);
const mockSetVoiceSensitivity = vi.fn();
const mockSetInputVolume = vi.fn();
const mockSetOutputVolume = vi.fn();
const mockReapplyAudioProcessing = vi.fn().mockResolvedValue(undefined);

vi.mock("@lib/livekitSession", () => ({
  switchInputDevice: (...args: unknown[]) => mockSwitchInputDevice(...args),
  switchOutputDevice: (...args: unknown[]) => mockSwitchOutputDevice(...args),
  setVoiceSensitivity: (...args: unknown[]) => mockSetVoiceSensitivity(...args),
  setInputVolume: (...args: unknown[]) => mockSetInputVolume(...args),
  setOutputVolume: (...args: unknown[]) => mockSetOutputVolume(...args),
  reapplyAudioProcessing: (...args: unknown[]) => mockReapplyAudioProcessing(...args),
}));

import { createVoiceAudioTab } from "@components/settings/VoiceAudioTab";

describe("VoiceAudioTab camera preview", () => {
  beforeEach(() => {
    localStorage.clear();
    document.body.innerHTML = "";
    localStorage.setItem("owncord:settings:videoInputDevice", '"camera-1"');

    vi.stubGlobal(
      "AudioContext",
      class {
        createAnalyser() {
          return {
            fftSize: 0,
            smoothingTimeConstant: 0,
            frequencyBinCount: 32,
            getByteFrequencyData: vi.fn(),
          };
        }

        createMediaStreamSource() {
          return { connect: vi.fn() };
        }

        close() {
          return Promise.resolve();
        }
      },
    );
  });

  it("does not restore a stale camera stream after the tab is aborted", async () => {
    let resolveVideo: ((stream: MediaStream) => void) | null = null;
    const stopVideoTrack = vi.fn();
    const videoStream = {
      getTracks: () => [{ stop: stopVideoTrack }],
    } as unknown as MediaStream;
    const audioStream = {
      getTracks: () => [],
    } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi
          .fn()
          .mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return new Promise<MediaStream>((resolve) => {
              resolveVideo = resolve;
            });
          }
          return Promise.resolve(audioStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const element = tab.build();
    document.body.appendChild(element);
    const preview = element.querySelector("video") as HTMLVideoElement;

    ac.abort();
    (resolveVideo as ((stream: MediaStream) => void) | null)?.(videoStream);

    await vi.waitFor(() => {
      expect(stopVideoTrack).toHaveBeenCalledTimes(1);
      expect(preview.srcObject).toBeNull();
    });
  });

  it("does not restore a stale camera stream after the tab is cleaned up", async () => {
    let resolveVideo: ((stream: MediaStream) => void) | null = null;
    const stopVideoTrack = vi.fn();
    const videoStream = {
      getTracks: () => [{ stop: stopVideoTrack }],
    } as unknown as MediaStream;
    const audioStream = {
      getTracks: () => [],
    } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi
          .fn()
          .mockResolvedValue([{ kind: "videoinput", deviceId: "camera-1", label: "Camera 1" }]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return new Promise<MediaStream>((resolve) => {
              resolveVideo = resolve;
            });
          }
          return Promise.resolve(audioStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const element = tab.build();
    document.body.appendChild(element);
    const preview = element.querySelector("video") as HTMLVideoElement;

    tab.cleanup();
    (resolveVideo as ((stream: MediaStream) => void) | null)?.(videoStream);

    await vi.waitFor(() => {
      expect(stopVideoTrack).toHaveBeenCalledTimes(1);
      expect(preview.srcObject).toBeNull();
    });
  });
});

describe("VoiceAudioTab mic meter", () => {
  let resolveAudio: ((stream: MediaStream) => void) | null = null;
  const stopAudioTrack = vi.fn();
  const audioStream = {
    getTracks: () => [{ stop: stopAudioTrack }],
  } as unknown as MediaStream;

  beforeEach(() => {
    localStorage.clear();
    document.body.innerHTML = "";
    resolveAudio = null;
    stopAudioTrack.mockClear();

    vi.stubGlobal(
      "AudioContext",
      class {
        createAnalyser() {
          return {
            fftSize: 0,
            smoothingTimeConstant: 0,
            frequencyBinCount: 32,
            getByteFrequencyData: vi.fn(),
          };
        }

        createMediaStreamSource() {
          return { connect: vi.fn() };
        }

        close() {
          return Promise.resolve();
        }
      },
    );

    // No videoInputDevice pref, so only the mic meter requests media.
    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi.fn().mockResolvedValue([]),
        getUserMedia: vi.fn().mockImplementation(
          () =>
            new Promise<MediaStream>((resolve) => {
              resolveAudio = resolve;
            }),
        ),
      },
    });
  });

  it("stops the mic stream when cleanup ran while getUserMedia was pending", async () => {
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    document.body.appendChild(tab.build());

    // SettingsOverlay.hide() calls cleanup() without aborting; a getUserMedia
    // resolving afterwards must not open the mic — nobody is left to stop it.
    tab.cleanup();
    (resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream);

    await vi.waitFor(() => {
      expect(stopAudioTrack).toHaveBeenCalledTimes(1);
    });
  });

  it("stops the mic stream when the tab was aborted while getUserMedia was pending", async () => {
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    document.body.appendChild(tab.build());

    ac.abort();
    (resolveAudio as ((stream: MediaStream) => void) | null)?.(audioStream);

    await vi.waitFor(() => {
      expect(stopAudioTrack).toHaveBeenCalledTimes(1);
    });
  });
});

// ---------------------------------------------------------------------------
// UI structure and interaction tests
// ---------------------------------------------------------------------------

describe("VoiceAudioTab UI structure", () => {
  /** Fires the `devicechange` listeners registered on the stubbed MediaDevices. */
  let emitDeviceChange: () => void = () => {};

  function stubNavigator(devices: Array<{ kind: string; deviceId: string; label: string }> = []): {
    setDevices(next: Array<{ kind: string; deviceId: string; label: string }>): void;
  } {
    const audioStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;

    let current = devices;
    const listeners = new Set<() => void>();
    emitDeviceChange = () => {
      for (const l of listeners) l();
    };

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi.fn().mockImplementation(() => Promise.resolve(current)),
        getUserMedia: vi.fn().mockResolvedValue(audioStream),
        addEventListener: (type: string, handler: () => void) => {
          if (type === "devicechange") listeners.add(handler);
        },
        removeEventListener: (_type: string, handler: () => void) => {
          listeners.delete(handler);
        },
      },
    });

    return {
      setDevices(next) {
        current = next;
      },
    };
  }

  beforeEach(() => {
    vi.clearAllMocks();
    localStorage.clear();
    document.body.innerHTML = "";
    vi.stubGlobal(
      "AudioContext",
      class {
        createAnalyser() {
          return {
            fftSize: 0,
            smoothingTimeConstant: 0,
            frequencyBinCount: 32,
            getByteFrequencyData: vi.fn(),
          };
        }
        createMediaStreamSource() {
          return { connect: vi.fn() };
        }
        close() {
          return Promise.resolve();
        }
      },
    );
  });

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

  it("builds a section element with settings-pane class", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    expect(el.tagName).toBe("DIV");
    expect(el.classList.contains("settings-pane")).toBe(true);
    ac.abort();
  });

  it("contains input device, output device, and video device selects", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const selects = el.querySelectorAll("select");
    // Input, output, stream quality, screen share fps, video = 5 selects
    expect(selects.length).toBe(5);
    ac.abort();
  });

  it("contains input volume and output volume sliders", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const sliders = el.querySelectorAll('input[type="range"]');
    expect(sliders.length).toBe(2); // input volume + output volume
    ac.abort();
  });

  it("input volume slider calls setInputVolume on change", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
    const inputSlider = sliders[0]!;
    inputSlider.value = "75";
    inputSlider.dispatchEvent(new Event("input"));

    expect(mockSetInputVolume).toHaveBeenCalledWith(75);
    ac.abort();
  });

  it("output volume slider calls setOutputVolume on change", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
    const outputSlider = sliders[1]!;
    outputSlider.value = "80";
    outputSlider.dispatchEvent(new Event("input"));

    expect(mockSetOutputVolume).toHaveBeenCalledWith(80);
    ac.abort();
  });

  it("restores saved input volume from preferences", () => {
    localStorage.setItem("owncord:settings:inputVolume", "75");
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
    expect(sliders[0]!.value).toBe("75");
    ac.abort();
  });

  it("restores saved output volume from preferences", () => {
    localStorage.setItem("owncord:settings:outputVolume", "60");
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const sliders = el.querySelectorAll('input[type="range"]') as NodeListOf<HTMLInputElement>;
    expect(sliders[1]!.value).toBe("60");
    ac.abort();
  });

  it("refreshes the device lists when hardware is plugged or unplugged", async () => {
    const nav = stubNavigator([
      { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
      { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
    ]);
    localStorage.setItem("owncord:settings:audioInputDevice", JSON.stringify("mic-1"));

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const inputSelect = el.querySelectorAll("select")[0]!;
    await vi.waitFor(() => {
      expect(inputSelect.querySelectorAll("option").length).toBe(2);
      expect(inputSelect.value).toBe("mic-1");
    });

    // Unplug mic-1, plug in mic-2. A stale list would keep offering a device
    // that no longer exists.
    nav.setDevices([
      { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
      { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
    ]);
    emitDeviceChange();

    await vi.waitFor(() => {
      const values = Array.from(inputSelect.querySelectorAll("option")).map((o) => o.value);
      expect(values).toEqual(["", "mic-2"]);
    });
    // The saved device is gone — fall back to Default rather than a dead entry.
    expect(inputSelect.value).toBe("");

    ac.abort();
  });

  it("stops refreshing device lists once the tab is aborted", async () => {
    const nav = stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const inputSelect = el.querySelectorAll("select")[0]!;
    await vi.waitFor(() => {
      expect(inputSelect.querySelectorAll("option").length).toBe(2);
    });

    ac.abort();
    nav.setDevices([
      { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
      { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
    ]);
    emitDeviceChange();
    await new Promise((r) => setTimeout(r, 10));

    expect(inputSelect.querySelectorAll("option").length).toBe(2);
  });

  it("populates device lists from enumerateDevices", async () => {
    stubNavigator([
      { kind: "audioinput", deviceId: "mic-1", label: "Mic 1" },
      { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
      { kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" },
      { kind: "videoinput", deviceId: "cam-1", label: "Cam 1" },
    ]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Wait for async device enumeration
    await vi.waitFor(() => {
      const selects = el.querySelectorAll("select");
      const inputSelect = selects[0];
      // Default + 2 mics = 3 options
      expect(inputSelect!.querySelectorAll("option").length).toBe(3);
    });

    const selects = el.querySelectorAll("select");
    const outputSelect = selects[1]!;
    // Default + 1 speaker = 2 options
    expect(outputSelect.querySelectorAll("option").length).toBe(2);

    ac.abort();
  });

  it("input device change calls switchInputDevice and saves pref", async () => {
    stubNavigator([{ kind: "audioinput", deviceId: "mic-1", label: "Mic 1" }]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const selects = el.querySelectorAll("select");
      expect(selects[0]!.querySelectorAll("option").length).toBeGreaterThan(1);
    });

    const inputSelect = el.querySelectorAll("select")[0] as HTMLSelectElement;
    inputSelect.value = "mic-1";
    inputSelect.dispatchEvent(new Event("change"));

    expect(mockSwitchInputDevice).toHaveBeenCalledWith("mic-1");
    ac.abort();
  });

  it("output device change calls switchOutputDevice and saves pref", async () => {
    stubNavigator([{ kind: "audiooutput", deviceId: "spk-1", label: "Speaker 1" }]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const selects = el.querySelectorAll("select");
      expect(selects[1]!.querySelectorAll("option").length).toBeGreaterThan(1);
    });

    const outputSelect = el.querySelectorAll("select")[1] as HTMLSelectElement;
    outputSelect.value = "spk-1";
    outputSelect.dispatchEvent(new Event("change"));

    expect(mockSwitchOutputDevice).toHaveBeenCalledWith("spk-1");
    ac.abort();
  });

  it("stream quality select saves to preferences on change", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Stream quality is the 3rd select (index 2)
    const qualitySelect = el.querySelectorAll("select")[2] as HTMLSelectElement;
    qualitySelect.value = "low";
    qualitySelect.dispatchEvent(new Event("change"));

    const saved = localStorage.getItem("owncord:settings:streamQuality");
    expect(saved).toBe('"low"');
    ac.abort();
  });

  it("screen share fps select persists the preference as a number", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Screen share FPS is the 4th select (index 3)
    const fpsSelect = el.querySelectorAll("select")[3] as HTMLSelectElement;
    expect(fpsSelect.value).toBe("30"); // default
    fpsSelect.value = "60";
    fpsSelect.dispatchEvent(new Event("change"));

    const saved = localStorage.getItem("owncord:settings:screenShareFps");
    expect(saved).toBe("60");
    ac.abort();
  });

  it("contains audio processing toggles", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const settingRows = el.querySelectorAll(".setting-row");
    // 4 toggles: echo cancellation, noise suppression, auto gain control, enhanced NS
    expect(settingRows.length).toBe(4);
    ac.abort();
  });

  it("audio toggle calls reapplyAudioProcessing when changed", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Toggles are divs with class "toggle" (not buttons)
    const toggleDiv = el.querySelector(".setting-row .toggle") as HTMLElement;
    expect(toggleDiv).not.toBeNull();
    toggleDiv.click();

    expect(mockReapplyAudioProcessing).toHaveBeenCalled();
    ac.abort();
  });

  it("handles enumerateDevices failure gracefully", async () => {
    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi.fn().mockRejectedValue(new Error("permission denied")),
        getUserMedia: vi.fn().mockRejectedValue(new Error("permission denied")),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const inputSelect = el.querySelectorAll("select")[0]!;
      const options = inputSelect.querySelectorAll("option");
      // Should have default + error option
      const texts = Array.from(options).map((o) => o.textContent);
      expect(texts.some((t) => t?.includes("Could not enumerate"))).toBe(true);
    });

    ac.abort();
  });

  it("does not start camera preview when no video device is saved", () => {
    stubNavigator();
    // Do NOT set videoInputDevice in localStorage
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const preview = el.querySelector("video") as HTMLVideoElement;
    // srcObject is undefined in JSDOM when never assigned (not null)
    expect(preview.srcObject).toBeFalsy();
    ac.abort();
  });

  it("starts camera preview when a video device is saved", async () => {
    localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');
    const cameraStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;
    const audioStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi
          .fn()
          .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return Promise.resolve(cameraStream);
          }
          return Promise.resolve(audioStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const preview = el.querySelector("video") as HTMLVideoElement;
      expect(preview.srcObject).toBe(cameraStream);
    });
    ac.abort();
  });

  it("video select change starts camera preview", async () => {
    const cameraStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;
    const audioStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi
          .fn()
          .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return Promise.resolve(cameraStream);
          }
          return Promise.resolve(audioStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Wait for devices to load
    await vi.waitFor(() => {
      const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
      expect(videoSelect.querySelectorAll("option").length).toBeGreaterThan(1);
    });

    const videoSelect = el.querySelectorAll("select")[4] as HTMLSelectElement;
    videoSelect.value = "cam-1";
    videoSelect.dispatchEvent(new Event("change"));

    await vi.waitFor(() => {
      const preview = el.querySelector("video") as HTMLVideoElement;
      expect(preview.srcObject).toBe(cameraStream);
    });

    ac.abort();
  });

  it("camera preview shows error when getUserMedia fails", async () => {
    localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');
    const audioStream = {
      getTracks: () => [{ stop: vi.fn() }],
    } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi
          .fn()
          .mockResolvedValue([{ kind: "videoinput", deviceId: "cam-1", label: "Camera 1" }]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return Promise.reject(new Error("Camera access denied"));
          }
          return Promise.resolve(audioStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const errorEl = el.querySelector(".setting-desc");
      expect(errorEl).not.toBeNull();
      expect(errorEl!.textContent).toBe("Camera access denied");
    });

    ac.abort();
  });

  it("sensitivity threshold handle is positioned based on saved sensitivity", () => {
    localStorage.setItem("owncord:settings:voiceSensitivity", "75");
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const threshold = el.querySelector(".mic-meter-threshold") as HTMLElement;
    expect(threshold).not.toBeNull();
    // Sensitivity 75 -> 100 - 75 = 25%
    expect(threshold.style.left).toBe("25%");
    ac.abort();
  });

  it("clicking the meter bar calls setVoiceSensitivity", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const meterBar = el.querySelector(".mic-meter-bar") as HTMLElement;
    expect(meterBar).not.toBeNull();

    // Simulate click at middle of bar — getBoundingClientRect returns 0,0
    // so clientX=0, ratio=0, sensitivity=100 (1-0)*100
    meterBar.dispatchEvent(new MouseEvent("click", { clientX: 0 }));

    expect(mockSetVoiceSensitivity).toHaveBeenCalled();
    ac.abort();
  });

  it("stops applying sensitivity after a pointercancel interrupts the drag (v097)", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const threshold = el.querySelector(".mic-meter-threshold") as HTMLElement;
    expect(threshold).not.toBeNull();
    // jsdom doesn't implement the Pointer Capture API — stub it as a no-op,
    // same as a real browser call the handler makes unconditionally.
    (threshold as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
      vi.fn();

    threshold.dispatchEvent(new PointerEvent("pointerdown", { pointerId: 1, clientX: 0 }));
    mockSetVoiceSensitivity.mockClear();

    threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 10 }));
    expect(mockSetVoiceSensitivity).toHaveBeenCalledTimes(1);

    // The OS claims the touch gesture as a pan and fires pointercancel
    // instead of pointerup.
    threshold.dispatchEvent(new PointerEvent("pointercancel", { pointerId: 1 }));

    mockSetVoiceSensitivity.mockClear();
    threshold.dispatchEvent(new PointerEvent("pointermove", { pointerId: 1, clientX: 50 }));

    // Without a pointercancel listener, onMove stays attached and this
    // would call setVoiceSensitivity again with no button held.
    expect(mockSetVoiceSensitivity).not.toHaveBeenCalled();

    ac.abort();
  });

  it("mic level monitoring handles getUserMedia failure gracefully", async () => {
    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi.fn().mockResolvedValue([]),
        getUserMedia: vi.fn().mockRejectedValue(new Error("mic denied")),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    // Should not throw — mic meter stays empty
    await new Promise((r) => setTimeout(r, 0));
    ac.abort();
  });

  it("restores saved device selections from localStorage", async () => {
    localStorage.setItem("owncord:settings:audioInputDevice", '"mic-2"');
    localStorage.setItem("owncord:settings:audioOutputDevice", '"spk-2"');
    stubNavigator([
      { kind: "audioinput", deviceId: "mic-2", label: "Mic 2" },
      { kind: "audiooutput", deviceId: "spk-2", label: "Speaker 2" },
    ]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const selects = el.querySelectorAll("select");
      expect((selects[0] as HTMLSelectElement).value).toBe("mic-2");
      expect((selects[1] as HTMLSelectElement).value).toBe("spk-2");
    });

    ac.abort();
  });

  it("uses device ID fallback label for devices without labels", async () => {
    stubNavigator([{ kind: "audioinput", deviceId: "abcdef12", label: "" }]);
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    await vi.waitFor(() => {
      const inputSelect = el.querySelectorAll("select")[0]!;
      const options = inputSelect.querySelectorAll("option");
      expect(options.length).toBe(2); // default + 1 device
      expect(options[1]!.textContent).toContain("Microphone");
    });

    ac.abort();
  });

  it("cleanup stops mic and camera streams", async () => {
    // A saved video device is required for the camera preview to start at all.
    localStorage.setItem("owncord:settings:videoInputDevice", '"cam-1"');

    const stopMicTrack = vi.fn();
    const stopCamTrack = vi.fn();
    const micStream = { getTracks: () => [{ stop: stopMicTrack }] } as unknown as MediaStream;
    const camStream = { getTracks: () => [{ stop: stopCamTrack }] } as unknown as MediaStream;

    vi.stubGlobal("navigator", {
      mediaDevices: {
        enumerateDevices: vi.fn().mockResolvedValue([]),
        getUserMedia: vi.fn().mockImplementation((constraints: MediaStreamConstraints) => {
          if (constraints.video && constraints.audio === false) {
            return Promise.resolve(camStream);
          }
          return Promise.resolve(micStream);
        }),
      },
    });

    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);
    const preview = el.querySelector("video") as HTMLVideoElement;

    // Wait for both the mic-monitoring and camera-preview getUserMedia calls to
    // resolve and register their streams before triggering cleanup.
    await vi.waitFor(() => {
      expect(preview.srcObject).toBe(camStream);
    });

    tab.cleanup();

    expect(stopMicTrack).toHaveBeenCalled();
    expect(stopCamTrack).toHaveBeenCalled();

    ac.abort();
  });

  it("restores saved stream quality selection", () => {
    localStorage.setItem("owncord:settings:streamQuality", '"low"');
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    const el = tab.build();
    document.body.appendChild(el);

    const qualitySelect = el.querySelectorAll("select")[2] as HTMLSelectElement;
    expect(qualitySelect.value).toBe("low");
    ac.abort();
  });

  it("rebuild cleans up previous mic/camera before building again", () => {
    stubNavigator();
    const ac = new AbortController();
    const tab = createVoiceAudioTab(ac.signal);
    tab.build();
    // Calling build again should not throw
    expect(() => tab.build()).not.toThrow();
    ac.abort();
  });

  it("does not register a new permanent abort listener on every rebuild (OC-0125)", () => {
    // Simulates the settings overlay staying open for a session while the
    // user re-opens the Voice & Audio tab (e.g. hide()/show() cycles):
    // SettingsOverlay's single AbortController lives for the whole session,
    // and build() is called again each time the tab is (re)rendered.
    stubNavigator();
    const ac = new AbortController();
    const addEventListenerSpy = vi.spyOn(ac.signal, "addEventListener");
    const abortListenerCount = (): number =>
      addEventListenerSpy.mock.calls.filter(([type]) => type === "abort").length;

    const tab = createVoiceAudioTab(ac.signal);

    // The first build may add jsdom's single internal bookkeeping listener
    // for `{ signal }`-scoped DOM listeners; snapshot after it so the
    // assertion isolates listener growth caused by rebuilds.
    tab.build();
    const afterFirstBuild = abortListenerCount();

    tab.build();
    tab.build();
    tab.build();

    // Rebuilding the tab three times must not add three more permanent
    // "abort" listeners to the overlay-lifetime signal — only the single
    // listener the factory registers once at creation should exist.
    expect(abortListenerCount()).toBe(afterFirstBuild);

    ac.abort();
  });
});
