import { afterEach, expect, test } from "bun:test";
import {
  AIError,
  createAI,
  createAIClient,
  deterministicAIProvider,
  type AIProvider,
} from "../src/index.ts";

const originalFetch = globalThis.fetch;
afterEach(() => {
  globalThis.fetch = originalFetch;
});

test("requires an API key before making a request", async () => {
  const previous = process.env.ANTHROPIC_API_KEY;
  delete process.env.ANTHROPIC_API_KEY;
  await expect(createAI().generate("hello")).rejects.toBeInstanceOf(AIError);
  if (previous !== undefined) process.env.ANTHROPIC_API_KEY = previous;
});

test("builds a bounded messages request and joins text blocks", async () => {
  let request: RequestInit | undefined;
  globalThis.fetch = (async (_url, init) => {
    request = init;
    return Response.json({ content: [{ type: "text", text: "Hello" }, { type: "tool_use" }] });
  }) as typeof fetch;
  const text = await createAI({ apiKey: "secret", model: "test-model", maxTokens: 123 }).generate(
    "Hi",
  );
  expect(text).toBe("Hello");
  expect(JSON.parse(String(request?.body))).toMatchObject({
    model: "test-model",
    max_tokens: 123,
    messages: [{ role: "user", content: "Hi" }],
    stream: false,
  });
});

test("streams fragmented SSE and keeps a final event without a newline", async () => {
  const encoder = new TextEncoder();
  globalThis.fetch = (async () =>
    new Response(
      new ReadableStream({
        start(controller) {
          controller.enqueue(
            encoder.encode('data: {"type":"content_block_delta","delta":{"type":"text_'),
          );
          controller.enqueue(encoder.encode('delta","text":"A"}}\n'));
          controller.enqueue(
            encoder.encode(
              'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"B"}}',
            ),
          );
          controller.close();
        },
      }),
    )) as unknown as typeof fetch;
  const chunks: string[] = [];
  for await (const chunk of createAI({ apiKey: "secret" }).stream("Hi")) chunks.push(chunk);
  expect(chunks).toEqual(["A", "B"]);
});

test("generates and validates structured output with an offline provider", async () => {
  const client = createAIClient({
    providers: [deterministicAIProvider({ responses: ['```json\n{"ok":true}\n```'] })],
  });
  const result = await client.generateObject<{ ok: boolean }>("ignored", {
    validate: (value): value is { ok: boolean } =>
      typeof value === "object" && value !== null && "ok" in value,
  });
  expect(result.value).toEqual({ ok: true });
  expect(client.capabilities().deterministic?.structuredOutput).toBe(true);
});

test("retries transient failures and reports redacted metadata", async () => {
  let calls = 0;
  const events: unknown[] = [];
  const provider: AIProvider = {
    name: "retrying",
    async generate() {
      calls++;
      if (calls === 1) throw new AIError("temporary secret-token", 503, "overloaded");
      return { value: "done", provider: "retrying", usage: { totalTokens: 3, costUsd: 0.01 } };
    },
  };
  const result = await createAIClient({
    providers: [provider],
    retry: { attempts: 2, baseDelayMs: 0 },
    onAttempt: (event) => {
      events.push(event);
    },
  }).generate("private prompt");
  expect(result.value).toBe("done");
  expect(calls).toBe(2);
  expect(JSON.stringify(events)).not.toContain("private prompt");
  expect(JSON.stringify(events)).not.toContain("raw");
});

test("opens a provider circuit and falls back", async () => {
  let failedCalls = 0;
  const failing: AIProvider = {
    name: "failing",
    async generate() {
      failedCalls++;
      throw new AIError("down", 503);
    },
  };
  const client = createAIClient({
    providers: [failing, deterministicAIProvider({ responses: ["one", "two"] })],
    retry: { attempts: 1 },
    circuitBreaker: { failureThreshold: 1, resetAfterMs: 60_000 },
  });
  expect((await client.generate("first")).value).toBe("one");
  expect((await client.generate("second")).value).toBe("two");
  expect(failedCalls).toBe(1);
});

test("executes validated tool calls and forwards cancellation", async () => {
  const client = createAIClient({ providers: [deterministicAIProvider({ responses: ["ok"] })] });
  const result = {
    value: "",
    provider: "deterministic",
    toolCalls: [{ id: "1", name: "double", arguments: { value: 4 } }],
  };
  const output = await client.executeTools(result, [
    {
      name: "double",
      validate: (value): value is { value: number } =>
        typeof value === "object" && value !== null && "value" in value,
      execute: ({ value }) => value * 2,
    },
  ]);
  expect(output[0]?.value).toBe(8);
});
