import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test";
import { DatadogLogsClient } from "@/datadog/client.js";

const CLIENT = new DatadogLogsClient("test-api-key", "test-app-key", "datadoghq.com");

const BASE_PARAMS = {
  query: "service:my-svc",
  from: "2024-01-01T00:00:00Z",
  to: "2024-01-01T01:00:00Z",
  limit: 100,
};

function makeLogEvent(overrides?: {
  id?: string;
  message?: string;
  timestamp?: string;
  status?: string;
  service?: string;
  host?: string;
  attributes?: Record<string, unknown>;
}) {
  return {
    id: overrides?.id ?? "log-id-1",
    type: "log",
    attributes: {
      message: overrides?.message ?? "hello world",
      timestamp: overrides?.timestamp ?? "2024-01-01T00:00:00Z",
      status: overrides?.status ?? "info",
      service: overrides?.service ?? "my-svc",
      host: overrides?.host,
      attributes: overrides?.attributes,
    },
  };
}

function successResponse(data: unknown[], afterCursor?: string, status = 200): Response {
  return new Response(
    JSON.stringify({
      data,
      meta: { page: { after: afterCursor } },
    }),
    { status, headers: { "Content-Type": "application/json" } },
  );
}

describe("DatadogLogsClient", () => {
  let fetchSpy: ReturnType<typeof spyOn<typeof globalThis, "fetch">>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  describe("searchLogs", () => {
    test("returns empty result when data is empty array", async () => {
      fetchSpy.mockResolvedValueOnce(successResponse([]));

      const result = await CLIENT.searchLogs(BASE_PARAMS);

      expect(result.logs).toEqual([]);
      expect(result.total).toBe(0);
      expect(result.truncated).toBeUndefined();
    });

    test("single page: maps log events to DatadogLogSummary correctly", async () => {
      fetchSpy.mockResolvedValueOnce(
        successResponse([
          makeLogEvent({
            id: "abc-123",
            message: "request received",
            timestamp: "2024-01-01T00:01:00Z",
            status: "info",
            service: "api",
            host: "host-1",
          }),
        ]),
      );

      const result = await CLIENT.searchLogs(BASE_PARAMS);

      expect(result.logs).toHaveLength(1);
      expect(result.logs[0]).toEqual({
        id: "abc-123",
        message: "request received",
        timestamp: "2024-01-01T00:01:00Z",
        status: "info",
        service: "api",
        host: "host-1",
        source: null,
      });
      expect(result.total).toBe(1);
    });

    test("maps nested host and ddsource from attributes.attributes", async () => {
      fetchSpy.mockResolvedValueOnce(
        successResponse([
          makeLogEvent({
            id: "nested-1",
            host: undefined,
            attributes: { host: "nested-host", ddsource: "my-source" },
          }),
        ]),
      );

      const result = await CLIENT.searchLogs(BASE_PARAMS);

      expect(result.logs).toHaveLength(1);
      expect(result.logs[0]?.host).toBe("nested-host");
      expect(result.logs[0]?.source).toBe("my-source");
    });

    test("follows pagination cursor until meta.page.after is absent", async () => {
      fetchSpy
        .mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-1" })], "cursor-abc"))
        .mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-2" })], "cursor-def"))
        .mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-3" })]));

      const result = await CLIENT.searchLogs({ ...BASE_PARAMS, limit: 100 });

      expect(result.logs).toHaveLength(3);
      expect(result.logs.map((l) => l.id)).toEqual(["log-1", "log-2", "log-3"]);
      expect(result.truncated).toBeUndefined();
      expect(fetchSpy.mock.calls).toHaveLength(3);
    });

    test("stops pagination when limit is reached across multiple pages", async () => {
      // limit=2, page 1 returns 1 log + cursor, page 2 returns 1 log + no cursor
      fetchSpy
        .mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-1" })], "cursor-1"))
        .mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-2" })]));

      const result = await CLIENT.searchLogs({ ...BASE_PARAMS, limit: 2 });

      expect(result.logs).toHaveLength(2);
      expect(result.truncated).toBeUndefined();
    });

    test("sets truncated=true when limit reached mid-pagination", async () => {
      // limit=1, page 1 returns 1 log + cursor → allLogs.length >= maxTotal, nextCursor exists
      fetchSpy.mockResolvedValueOnce(
        successResponse([makeLogEvent({ id: "log-1" })], "cursor-more"),
      );

      const result = await CLIENT.searchLogs({ ...BASE_PARAMS, limit: 1 });

      expect(result.logs).toHaveLength(1);
      expect(result.truncated).toBe(true);
    });

    test("sets truncated=true when MAX_PAGES reached with cursor still set", async () => {
      // MAX_PAGES=100: mock 100 pages each with 1 log and a cursor
      for (let i = 0; i < 100; i++) {
        fetchSpy.mockResolvedValueOnce(
          successResponse([makeLogEvent({ id: `log-${i}` })], `cursor-${i}`),
        );
      }

      const result = await CLIENT.searchLogs({ ...BASE_PARAMS, limit: 50_000 });

      expect(result.logs).toHaveLength(100);
      expect(result.truncated).toBe(true);
      expect(fetchSpy.mock.calls).toHaveLength(100);
    });

    test("throws DatadogApiError on non-ok HTTP response", async () => {
      fetchSpy.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }));

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 401,
      });
    });

    test("throws DatadogApiError with status 502 on invalid JSON", async () => {
      fetchSpy.mockResolvedValueOnce(new Response("not-valid-json{{{", { status: 200 }));

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 502,
      });
    });

    test("throws DatadogApiError with status 502 on unexpected response shape", async () => {
      fetchSpy.mockResolvedValueOnce(
        new Response(JSON.stringify({ data: "not-an-array" }), {
          status: 200,
          headers: { "Content-Type": "application/json" },
        }),
      );

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 502,
      });
    });

    test("retries on 429 up to MAX_RATE_LIMIT_RETRIES times", async () => {
      // 3 x 429 (attempts 0, 1, 2 all retried), then 1 x 200 (attempt 3, which >= MAX_RATE_LIMIT_RETRIES=3? no)
      // Actually: attempt=0,1,2 → retry (attempt < 3); attempt=3 → falls through to text/ok check
      // So we need 4 x 429 to exhaust, or 3 x 429 + 1 x 200 to succeed
      for (let i = 0; i < 3; i++) {
        fetchSpy.mockResolvedValueOnce(
          new Response("", { status: 429, headers: { "Retry-After": "0" } }),
        );
      }
      fetchSpy.mockResolvedValueOnce(successResponse([makeLogEvent({ id: "log-ok" })]));

      const result = await CLIENT.searchLogs(BASE_PARAMS);

      expect(result.logs).toHaveLength(1);
      expect(result.logs[0]?.id).toBe("log-ok");
      expect(fetchSpy.mock.calls).toHaveLength(4);
    });

    test("throws after exhausting retries on persistent 429", async () => {
      // 4 x 429: attempts 0, 1, 2 retry; attempt 3 (>= MAX_RATE_LIMIT_RETRIES) falls through to text/ok check → throws
      for (let i = 0; i < 4; i++) {
        fetchSpy.mockResolvedValueOnce(
          new Response("rate limited", { status: 429, headers: { "Retry-After": "0" } }),
        );
      }

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 429,
      });
    });

    test("throws DatadogApiError with status 504 on AbortError (timeout)", async () => {
      const abortError = new Error("The operation was aborted");
      abortError.name = "AbortError";
      fetchSpy.mockRejectedValueOnce(abortError);

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 504,
      });
    });

    test("throws DatadogApiError with status 502 on generic network error", async () => {
      fetchSpy.mockRejectedValueOnce(new Error("ECONNREFUSED"));

      await expect(CLIENT.searchLogs(BASE_PARAMS)).rejects.toMatchObject({
        name: "DatadogApiError",
        status: 502,
      });
    });
  });
});
