import { test, expect } from "bun:test";

// bridge.ts imports the runtime `config`, which requires DISCORD_GUILD_ID.
// Set it before the dynamic import so the module loads without a real .env.
process.env.DISCORD_GUILD_ID ||= "test-guild";
const { ndjson, converseStream } = await import("./bridge.ts");

const enc = (s: string) => new TextEncoder().encode(s);

async function* chunked(...cs: string[]): AsyncGenerator<Uint8Array> {
  for (const c of cs) yield enc(c);
}

test("ndjson yields one object per line even when chunks split mid-line", async () => {
  const out: any[] = [];
  for await (const o of ndjson(chunked('{"a":1}\n{"b":', '2}\n{"c":3}'))) out.push(o);
  expect(out).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }]);
});

test("ndjson skips blank lines and a trailing newline", async () => {
  const out: any[] = [];
  for await (const o of ndjson(chunked('{"a":1}\n\n{"b":2}\n'))) out.push(o);
  expect(out).toEqual([{ a: 1 }, { b: 2 }]);
});

test("converseStream surfaces meta first, then plays each sentence clip in order", async () => {
  const clipA = Buffer.from("clipA");
  const clipB = Buffer.from("clipB");
  const body =
    [
      JSON.stringify({
        type: "meta",
        transcript: "안녕",
        reply: "안녕하세요. 반갑습니다!",
        broadcast_action: "start",
        llm_start_ms: 1000,
        llm_end_ms: 2600,
      }),
      JSON.stringify({ type: "audio", seq: 0, audio_b64: clipA.toString("base64") }),
      JSON.stringify({ type: "audio", seq: 1, audio_b64: clipB.toString("base64") }),
      JSON.stringify({ type: "end", tts_sec: 0.9, tts_start_ms: 2600, tts_end_ms: 3500 }),
    ].join("\n") + "\n";

  const orig = globalThis.fetch;
  globalThis.fetch = (async () => ({
    ok: true,
    body: chunked(body),
    text: async () => "",
  })) as any;

  try {
    const events: string[] = [];
    const clips: Buffer[] = [];
    let meta: any;
    let end: any;
    await converseStream(Buffer.from("wav"), true, {
      onMeta: (m) => {
        meta = m;
        events.push("meta");
      },
      onAudio: (c) => {
        clips.push(c);
        events.push("audio");
      },
      onEnd: (e) => {
        end = e;
        events.push("end");
      },
    });

    expect(meta.transcript).toBe("안녕");
    expect(meta.broadcast_action).toBe("start");
    // LLM wall-clock window rides on the meta line.
    expect(meta.llm_start_ms).toBe(1000);
    expect(meta.llm_end_ms).toBe(2600);
    // Meta must arrive before any audio, the end (with TTS timing) comes last,
    // and clips stay in order.
    expect(events).toEqual(["meta", "audio", "audio", "end"]);
    expect(clips.map((c) => c.toString())).toEqual(["clipA", "clipB"]);
    expect(end.tts_start_ms).toBe(2600);
    expect(end.tts_end_ms).toBe(3500);
  } finally {
    globalThis.fetch = orig;
  }
});

test("converseStream throws on a non-ok bridge response", async () => {
  const orig = globalThis.fetch;
  globalThis.fetch = (async () => ({
    ok: false,
    status: 500,
    body: null,
    text: async () => "boom",
  })) as any;
  try {
    await expect(converseStream(Buffer.from("wav"), undefined, {})).rejects.toThrow("500");
  } finally {
    globalThis.fetch = orig;
  }
});
