import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendFile, sendFileData, withRetry, type FeishuRuntime } from "../../src/feishu/client.js";

describe("withRetry", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  it("succeeds on first attempt without delaying", async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const fn = vi.fn(async () => "ok");

    await expect(withRetry(fn)).resolves.toBe("ok");

    expect(fn).toHaveBeenCalledTimes(1);
    expect(setTimeoutSpy).not.toHaveBeenCalled();
  });

  it("retries on failure then succeeds", async () => {
    const fn = vi.fn()
      .mockRejectedValueOnce(new Error("temporary failure"))
      .mockResolvedValueOnce("ok");

    const result = withRetry(fn);
    await Promise.resolve();

    expect(fn).toHaveBeenCalledTimes(1);

    await vi.advanceTimersByTimeAsync(1000);

    await expect(result).resolves.toBe("ok");
    expect(fn).toHaveBeenCalledTimes(2);
  });

  it("exhausts all attempts and rethrows", async () => {
    const error = new Error("still failing");
    const fn = vi.fn(async () => {
      throw error;
    });

    const result = withRetry(fn);
    const assertion = expect(result).rejects.toBe(error);
    await Promise.resolve();

    await vi.advanceTimersByTimeAsync(1000);
    await vi.advanceTimersByTimeAsync(2000);

    await assertion;
    expect(fn).toHaveBeenCalledTimes(3);
  });

  it("respects shouldRetry predicate", async () => {
    const error = new Error("validation failure");
    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const shouldRetry = vi.fn(() => false);
    const fn = vi.fn(async () => {
      throw error;
    });

    await expect(withRetry(fn, { shouldRetry })).rejects.toBe(error);

    expect(fn).toHaveBeenCalledTimes(1);
    expect(shouldRetry).toHaveBeenCalledWith(error);
    expect(setTimeoutSpy).not.toHaveBeenCalled();
  });

  it("uses exponential backoff delays", async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
    const fn = vi.fn()
      .mockRejectedValueOnce(new Error("first failure"))
      .mockRejectedValueOnce(new Error("second failure"))
      .mockResolvedValueOnce("ok");

    const result = withRetry(fn);
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(1000);
    await vi.advanceTimersByTimeAsync(2000);

    await expect(result).resolves.toBe("ok");
    expect(setTimeoutSpy.mock.calls.map(([, delay]) => delay)).toEqual([1000, 2000]);
  });
});

describe("sendFile retry", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it("retries upload on transient failure", async () => {
    const dir = await mkdtemp(join(tmpdir(), "hub-feishu-retry-"));
    try {
      const file = join(dir, "student.pdf");
      await writeFile(file, "pdf bytes");

      let failFirstUpload: (error: Error) => void = () => {
        throw new Error("upload rejection was not initialized");
      };
      const firstUpload = new Promise<never>((_resolve, reject) => {
        failFirstUpload = reject;
      });
      const fileCreate = vi.fn()
        .mockReturnValueOnce(firstUpload)
        .mockResolvedValueOnce({ data: { file_key: "file-key-1" } });
      const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
      const rt = mockRuntime({ fileCreate, messageCreate });

      const result = sendFile(rt, "chat-1", file, "student.pdf");
      await waitForCalls(fileCreate, 1);
      vi.useFakeTimers();
      failFirstUpload(new Error("temporary upload failure"));
      await Promise.resolve();
      await vi.advanceTimersByTimeAsync(1000);

      await expect(result).resolves.toBe("message-1");
      expect(fileCreate).toHaveBeenCalledTimes(2);
      expect(messageCreate).toHaveBeenCalledTimes(1);
    } finally {
      vi.useRealTimers();
      await rm(dir, { recursive: true, force: true });
    }
  });

  it("sendFileData preserves an exhausted upload failure as its cause", async () => {
    vi.useFakeTimers();
    const cause = new Error("persistent upload failure");
    const fileCreate = vi.fn(async () => { throw cause; });
    const rt = mockRuntime({ fileCreate, messageCreate: vi.fn() });

    const result = sendFileData(rt, "chat-1", Buffer.from("pdf bytes"), "student.pdf");
    const assertion = expect(result).rejects.toMatchObject({
      name: "FeishuFileDeliveryError",
      cause,
    });
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(1000);
    await vi.advanceTimersByTimeAsync(2000);

    await assertion;
    expect(fileCreate).toHaveBeenCalledTimes(3);
  });
});

async function waitForCalls(mock: Mock, expectedCalls: number): Promise<void> {
  for (let attempt = 0; attempt < 50; attempt += 1) {
    if (mock.mock.calls.length >= expectedCalls) return;
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
  throw new Error(`Timed out waiting for mock to be called ${expectedCalls} time(s)`);
}

function mockRuntime(options: {
  readonly fileCreate: (payload: unknown) => Promise<unknown>;
  readonly messageCreate: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
  return {
    client: {
      im: {
        v1: {
          file: { create: options.fileCreate },
          message: { create: options.messageCreate },
        },
      },
    } as unknown as FeishuRuntime["client"],
    logger: {
      info() {},
      warn() {},
      error() {},
      debug() {},
      fatal() {},
      child() { return this; },
      level: "silent",
    } as unknown as FeishuRuntime["logger"],
  };
}
