import { test, describe } from "node:test";
import { strict as assert } from "node:assert";
import {
  sendEvent,
  extractVideoId,
  formatTime,
  parseTimestampedTranscript,
  safeText,
  retryGemini,
} from "../util.js";

describe("extractVideoId", () => {
  test("extracts from standard watch URL", () => {
    assert.equal(extractVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
  });

  test("extracts from youtu.be short URL", () => {
    assert.equal(extractVideoId("https://youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
  });

  test("extracts from /embed/ URL", () => {
    assert.equal(extractVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
  });

  test("accepts a bare 11-character id", () => {
    assert.equal(extractVideoId("dQw4w9WgXcQ"), "dQw4w9WgXcQ");
  });

  test("returns null for non-YouTube URL", () => {
    assert.equal(extractVideoId("https://example.com/video"), null);
  });

  test("returns null for empty / null input", () => {
    assert.equal(extractVideoId(""), null);
    assert.equal(extractVideoId(null), null);
    assert.equal(extractVideoId(undefined), null);
  });

  test("returns null for too-short id", () => {
    assert.equal(extractVideoId("dQw4w9W"), null);
  });
});

describe("formatTime", () => {
  test("formats sub-minute as M:SS", () => {
    assert.equal(formatTime(0), "0:00");
    assert.equal(formatTime(5), "0:05");
    assert.equal(formatTime(59), "0:59");
  });

  test("formats minutes as M:SS", () => {
    assert.equal(formatTime(60), "1:00");
    assert.equal(formatTime(125), "2:05");
    assert.equal(formatTime(3599), "59:59");
  });

  test("formats hours+ as H:MM:SS", () => {
    assert.equal(formatTime(3600), "1:00:00");
    assert.equal(formatTime(3661), "1:01:01");
    assert.equal(formatTime(7325), "2:02:05");
  });

  test("floors fractional seconds", () => {
    assert.equal(formatTime(60.9), "1:00");
  });
});

describe("parseTimestampedTranscript", () => {
  test("parses [M:SS] format", () => {
    const entries = parseTimestampedTranscript(
      "[0:00] Welcome\n[0:05] Today we're talking about\n[0:30] Topic two"
    );
    assert.equal(entries.length, 3);
    assert.deepEqual(entries[0], { text: "Welcome", offset: 0, duration: 5 });
    assert.equal(entries[1].offset, 5);
    assert.equal(entries[1].duration, 25);
    // Last entry gets default 15s duration
    assert.equal(entries[2].offset, 30);
    assert.equal(entries[2].duration, 15);
  });

  test("parses H:MM:SS format", () => {
    const entries = parseTimestampedTranscript("[1:02:30] Hello");
    assert.equal(entries[0].offset, 1 * 3600 + 2 * 60 + 30);
  });

  test("parses (M:SS) format", () => {
    const entries = parseTimestampedTranscript("(0:00) Welcome");
    assert.equal(entries.length, 1);
    assert.equal(entries[0].text, "Welcome");
  });

  test("strips markdown bold markers", () => {
    const entries = parseTimestampedTranscript("**[0:00]** Hello world");
    assert.equal(entries[0].text, "Hello world");
  });

  test("ignores blank lines", () => {
    const entries = parseTimestampedTranscript("\n[0:00] one\n\n[0:05] two\n");
    assert.equal(entries.length, 2);
  });

  test("returns empty array for unparseable input", () => {
    assert.deepEqual(parseTimestampedTranscript(""), []);
    assert.deepEqual(parseTimestampedTranscript("no timestamps here"), []);
  });
});

describe("safeText", () => {
  test("returns .text when accessible", () => {
    assert.equal(safeText({ text: "hello" }), "hello");
  });

  test("falls back to candidates[0].content.parts when .text throws", () => {
    const fake = {
      get text() { throw new Error("nope"); },
      candidates: [{ content: { parts: [{ text: "from " }, { text: "parts" }] } }],
    };
    assert.equal(safeText(fake), "from parts");
  });

  test("returns empty string when neither path yields text", () => {
    assert.equal(safeText({}), "");
    assert.equal(safeText({ candidates: [] }), "");
  });

  test("does not throw on null / weird shapes", () => {
    assert.equal(safeText({ candidates: [{ content: null }] }), "");
  });
});

describe("retryGemini", () => {
  test("returns successful result on first try", async () => {
    const result = await retryGemini(async () => "ok", { retries: 3, delayMs: 1 });
    assert.equal(result, "ok");
  });

  test("retries on 503 and eventually succeeds", async () => {
    let attempts = 0;
    const result = await retryGemini(
      async () => {
        attempts++;
        if (attempts < 3) {
          const err = new Error("temporary");
          err.status = 503;
          throw err;
        }
        return "ok";
      },
      { retries: 5, delayMs: 1 }
    );
    assert.equal(result, "ok");
    assert.equal(attempts, 3);
  });

  test("retries on network error patterns", async () => {
    let attempts = 0;
    const result = await retryGemini(
      async () => {
        attempts++;
        if (attempts < 2) throw new Error("fetch failed");
        return "ok";
      },
      { retries: 3, delayMs: 1 }
    );
    assert.equal(result, "ok");
    assert.equal(attempts, 2);
  });

  test("does NOT retry on non-retryable errors", async () => {
    let attempts = 0;
    await assert.rejects(
      retryGemini(
        async () => {
          attempts++;
          throw new Error("bad request: invalid argument");
        },
        { retries: 5, delayMs: 1 }
      )
    );
    assert.equal(attempts, 1);
  });

  test("throws after exhausting retries", async () => {
    let attempts = 0;
    await assert.rejects(
      retryGemini(
        async () => {
          attempts++;
          const err = new Error("still 503");
          err.status = 503;
          throw err;
        },
        { retries: 3, delayMs: 1 }
      )
    );
    assert.equal(attempts, 3);
  });

  test("calls log function on retries", async () => {
    const logs = [];
    await retryGemini(
      async () => {
        const err = new Error("503");
        err.status = 503;
        throw err;
      },
      { retries: 2, delayMs: 1, label: "test", log: (msg) => logs.push(msg) }
    ).catch(() => {});
    // retries: 2 → the loop logs twice: the "failed, retrying in …s" notice
    // before attempt 2's wait, then "Retrying… (attempt 2/2)" at the top of
    // attempt 2. (The test previously expected 1, written before the
    // top-of-attempt retry line existed.)
    assert.equal(logs.length, 2);
    assert.match(logs[0], /test/);
    assert.match(logs[0], /retrying/);
  });
});

describe("sendEvent", () => {
  test("writes a properly formatted SSE frame", () => {
    let written = "";
    const fakeRes = { write: (chunk) => { written += chunk; } };
    sendEvent(fakeRes, "result", { foo: "bar" });
    assert.equal(written, 'event: result\ndata: {"foo":"bar"}\n\n');
  });

  test("handles primitive data", () => {
    let written = "";
    const fakeRes = { write: (chunk) => { written += chunk; } };
    sendEvent(fakeRes, "ping", null);
    assert.equal(written, "event: ping\ndata: null\n\n");
  });
});
