import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";

// Mock ffmpeg-static BEFORE any imports — returns "/mock/ffmpeg" by default
vi.mock("ffmpeg-static", () => ({ default: "/mock/ffmpeg" }));

// Mock child_process
vi.mock("node:child_process", () => ({
  execFile: vi.fn(),
  spawn: vi.fn(),
}));

// Mock fs/promises for temp file operations
vi.mock("node:fs/promises", () => ({
  writeFile: vi.fn().mockResolvedValue(undefined),
  unlink: vi.fn().mockResolvedValue(undefined),
  readFile: vi.fn().mockResolvedValue(Buffer.from("wav-audio-data")),
}));

// Import the service and mocks once at the top level (ffmpeg-static mock is in effect)
import { voicePipelineService } from "../services/voice-pipeline.js";
import { execFile as execFileCb, spawn } from "node:child_process";

const execFileMock = vi.mocked(execFileCb);
const spawnMock = vi.mocked(spawn);

describe("voicePipelineService", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe("factory", () => {
    it("throws when ffmpegPath is null", async () => {
      // This test uses a separate module load with overridden mock
      vi.doMock("ffmpeg-static", () => ({ default: null }));
      const { voicePipelineService: svcFactory } = await import(
        "../services/voice-pipeline.js?null-test"
      );
      expect(() => svcFactory()).toThrow("ffmpeg-static binary not found");
      vi.doUnmock("ffmpeg-static");
    });
  });

  describe("transcodeToWav16k", () => {
    it("spawns ffmpeg with correct args for webm input", async () => {
      const fakeProcess = new EventEmitter() as any;
      fakeProcess.stdin = { write: vi.fn(), end: vi.fn() };
      fakeProcess.stdout = new EventEmitter();
      fakeProcess.stderr = new EventEmitter();
      spawnMock.mockReturnValue(fakeProcess);

      const svc = voicePipelineService();
      const inputBuffer = Buffer.from("fake-webm-data");
      const promise = svc.transcodeToWav16k(inputBuffer, "webm");

      // Emit stdout data then close with code 0
      fakeProcess.stdout.emit("data", Buffer.from("wav-header"));
      fakeProcess.emit("close", 0);

      await promise;

      expect(spawnMock).toHaveBeenCalledWith(
        "/mock/ffmpeg",
        ["-f", "webm", "-i", "pipe:0", "-ar", "16000", "-ac", "1", "-f", "wav", "pipe:1"],
        { stdio: ["pipe", "pipe", "pipe"] }
      );
    });

    it("rejects when ffmpeg exits non-zero", async () => {
      const fakeProcess = new EventEmitter() as any;
      fakeProcess.stdin = { write: vi.fn(), end: vi.fn() };
      fakeProcess.stdout = new EventEmitter();
      fakeProcess.stderr = new EventEmitter();
      spawnMock.mockReturnValue(fakeProcess);

      const svc = voicePipelineService();
      const inputBuffer = Buffer.from("bad-audio");
      const promise = svc.transcodeToWav16k(inputBuffer, "webm");

      fakeProcess.emit("close", 1);

      await expect(promise).rejects.toThrow();
    });
  });

  describe("transcribe", () => {
    function setupSpawnForTranscode() {
      const fakeProcess = new EventEmitter() as any;
      fakeProcess.stdin = { write: vi.fn(), end: vi.fn() };
      fakeProcess.stdout = new EventEmitter();
      fakeProcess.stderr = new EventEmitter();
      spawnMock.mockReturnValue(fakeProcess);
      return fakeProcess;
    }

    it("calls transcodeToWav16k then whisper-cpp with --language auto for webm input", async () => {
      execFileMock.mockImplementation((_cmd: any, _args: any, _opts: any, callback: any) => {
        callback(null, "Hello world", "");
        return {} as any;
      });

      const svc = voicePipelineService();
      const inputBuffer = Buffer.from("fake-webm");

      // Set up spawn mock BEFORE calling transcribe
      const fakeProcess = setupSpawnForTranscode();

      // Start the transcribe (it will spawn ffmpeg internally)
      const transcribePromise = svc.transcribe(inputBuffer, "webm");

      // Give the spawn setup a tick to register, then emit close
      await new Promise((r) => setTimeout(r, 0));
      fakeProcess.stdout.emit("data", Buffer.from("wav-data"));
      fakeProcess.emit("close", 0);

      const result = await transcribePromise;

      // Verify spawn was called (transcode happened)
      expect(spawnMock).toHaveBeenCalled();

      // Verify whisper-cpp was called with --language auto
      expect(execFileMock).toHaveBeenCalledWith(
        "whisper-cpp",
        expect.arrayContaining(["--language", "auto"]),
        expect.anything(),
        expect.any(Function)
      );

      expect(result).toHaveProperty("text");
    });

    it("falls back to openai-whisper when whisper-cpp fails", async () => {
      let callCount = 0;
      execFileMock.mockImplementation((_cmd: any, _args: any, _opts: any, callback: any) => {
        callCount++;
        if (callCount === 1) {
          // whisper-cpp fails
          callback(new Error("whisper-cpp not found"), "", "");
        } else {
          // openai-whisper succeeds
          callback(null, "Fallback transcript", "");
        }
        return {} as any;
      });

      const svc = voicePipelineService();
      const inputBuffer = Buffer.from("fake-webm");
      const fakeProcess = setupSpawnForTranscode();
      const transcribePromise = svc.transcribe(inputBuffer, "webm");

      await new Promise((r) => setTimeout(r, 0));
      fakeProcess.stdout.emit("data", Buffer.from("wav-data"));
      fakeProcess.emit("close", 0);

      const result = await transcribePromise;

      expect(execFileMock).toHaveBeenCalledTimes(2);
      expect(result).toHaveProperty("text");
    });

    it("throws 503-style error when neither whisper binary is available", async () => {
      execFileMock.mockImplementation((_cmd: any, _args: any, _opts: any, callback: any) => {
        callback(new Error("command not found"), "", "");
        return {} as any;
      });

      const svc = voicePipelineService();
      const inputBuffer = Buffer.from("fake-webm");
      const fakeProcess = setupSpawnForTranscode();
      const transcribePromise = svc.transcribe(inputBuffer, "webm");

      await new Promise((r) => setTimeout(r, 0));
      fakeProcess.stdout.emit("data", Buffer.from("wav-data"));
      fakeProcess.emit("close", 0);

      await expect(transcribePromise).rejects.toThrow("Whisper not available");
    });
  });

  describe("synthesize", () => {
    it("splits text into sentences and calls piper execFile for each", async () => {
      execFileMock.mockImplementation((_cmd: any, _args: any, _opts: any, callback: any) => {
        callback(null, Buffer.from("wav-chunk"), "");
        return {} as any;
      });

      const svc = voicePipelineService();
      const result = await svc.synthesize("Hello world. How are you?");

      // Should have called piper twice (2 sentences)
      expect(execFileMock).toHaveBeenCalledTimes(2);
      expect(execFileMock).toHaveBeenCalledWith(
        "piper",
        expect.anything(),
        expect.anything(),
        expect.any(Function)
      );
      expect(Buffer.isBuffer(result)).toBe(true);
    });

    it("throws when piper binary is not found", async () => {
      const enoentErr = new Error("piper: command not found") as NodeJS.ErrnoException;
      enoentErr.code = "ENOENT";
      execFileMock.mockImplementation((_cmd: any, _args: any, _opts: any, callback: any) => {
        callback(enoentErr, "", "");
        return {} as any;
      });

      const svc = voicePipelineService();
      await expect(svc.synthesize("Hello.")).rejects.toThrow("Piper TTS not available");
    });

    it("rejects when piper call times out after 8000ms", async () => {
      vi.useFakeTimers();

      // Never call the callback (simulates hang)
      execFileMock.mockImplementation(() => ({} as any));

      const svc = voicePipelineService();
      const promise = svc.synthesize("Hello.");

      // Advance timers to trigger timeout
      vi.advanceTimersByTime(9000);

      await expect(promise).rejects.toThrow(/[Tt]imed out/);

      vi.useRealTimers();
    });
  });

  describe("formatForVoice", () => {
    it("extracts SPOKEN section when both SPOKEN: and DETAILED: markers are present", () => {
      const svc = voicePipelineService();
      const input = "SPOKEN: Hello there\n\nDETAILED: ## Hello\n**world**";
      const result = svc.formatForVoice(input);
      expect(result).toBe("Hello there");
    });

    it("strips markdown when SPOKEN marker is absent", () => {
      const svc = voicePipelineService();
      const input = "## Hello\n**world**\n- item\n```code```";
      const result = svc.formatForVoice(input);
      // Headings, bold, bullets, and code fences stripped
      expect(result).not.toContain("##");
      expect(result).not.toContain("**");
      expect(result).not.toContain("- item");
      expect(result).not.toContain("```");
      expect(result).toContain("Hello");
      expect(result).toContain("world");
      expect(result).toContain("item");
      expect(result).toContain("code");
    });

    it("returns empty string for empty input", () => {
      const svc = voicePipelineService();
      expect(svc.formatForVoice("")).toBe("");
    });
  });
});
