import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { HarnessClient } from "../../src/client/harness-client.js";
import { HarnessApiError } from "../../src/utils/errors.js";
import type { Config } from "../../src/config.js";

function makeConfig(overrides: Partial<Config> = {}): Config {
  return {
    HARNESS_API_KEY: "pat.test-account.token.secret",
    HARNESS_ACCOUNT_ID: "test-account",
    HARNESS_BASE_URL: "https://app.harness.io",
    HARNESS_ORG: "default",
    HARNESS_PROJECT: "test-project",
    HARNESS_API_TIMEOUT_MS: 5000,
    HARNESS_MAX_RETRIES: 2,
    LOG_LEVEL: "error",
    HARNESS_RATE_LIMIT_RPS: 1000, // high limit so rate limiter doesn't interfere
    HARNESS_MAX_BODY_SIZE_MB: 10,
    HARNESS_READ_ONLY: false,
    ...overrides,
  };
}

describe("HarnessClient", () => {
  let fetchSpy: ReturnType<typeof vi.spyOn>;

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

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

  describe("constructor and account getter", () => {
    it("exposes account ID", () => {
      const client = new HarnessClient(makeConfig());
      expect(client.account).toBe("test-account");
    });

    it("uses resolved account ID when resolver is set", () => {
      const client = new HarnessClient(makeConfig());
      client.setAccountIdResolver(() => "resolved-account");
      expect(client.account).toBe("resolved-account");
    });
  });

  describe("request — URL building", () => {
    it("builds URL with accountIdentifier and custom params", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "GET", path: "/ng/api/projects", params: { orgIdentifier: "myorg" } });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.origin).toBe("https://app.harness.io");
      expect(url.pathname).toBe("/ng/api/projects");
      expect(url.searchParams.get("accountIdentifier")).toBe("test-account");
      expect(url.searchParams.get("routingId")).toBe("test-account");
      expect(url.searchParams.get("orgIdentifier")).toBe("myorg");
    });

    it("adds accountID param for log-service paths", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/gateway/log-service/blob/download" });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.get("accountID")).toBe("test-account");
      expect(url.searchParams.get("accountIdentifier")).toBe("test-account");
      expect(url.searchParams.get("routingId")).toBe("test-account");
    });

    it("merges query params already present in requestStream path", async () => {
      fetchSpy.mockResolvedValue(new Response("ok", { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_BASE_URL: "https://myhost.harness.io/gateway" }));

      await client.requestStream({
        method: "GET",
        path: "/gateway/log-service/some/blob/path?token=abc",
      });

      const urlString = fetchSpy.mock.calls[0][0] as string;
      const url = new URL(urlString);
      expect(url.pathname).toBe("/gateway/log-service/some/blob/path");
      expect(url.searchParams.get("token")).toBe("abc");
      expect(url.searchParams.get("accountIdentifier")).toBe("test-account");
      expect(url.searchParams.get("routingId")).toBe("test-account");
      expect(url.searchParams.get("accountID")).toBe("test-account");
      expect(urlString.split("?")).toHaveLength(2);
    });

    it("encodes query param for multi-word params", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/gateway/ccm/perspectives", params: { search_term: "Cost Explorer" }  });
      const urlString = fetchSpy.mock.calls[0][0] as string
      const url = new URL(urlString);
      expect(url.searchParams.get("search_term")).toBe("Cost Explorer");
      expect(urlString).toContain("search_term=Cost%20Explorer");
    });

    it("strips trailing slash from base URL", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_BASE_URL: "https://app.harness.io/" }));

      await client.request({ path: "/ng/api/test" });

      const url = fetchSpy.mock.calls[0][0] as string;
      expect(url).toContain("https://app.harness.io/ng/api/test?");
    });

    it("deduplicates /gateway when base URL ends with /gateway", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_BASE_URL: "https://myhost.harness.io/gateway" }));

      await client.request({ path: "/gateway/log-service/blob/download", params: { prefix: "test" } });

      const url = fetchSpy.mock.calls[0][0] as string;
      // Should NOT have double /gateway
      expect(url).toContain("https://myhost.harness.io/gateway/log-service/blob/download?");
      expect(url).not.toContain("/gateway/gateway/");
    });

    it("deduplicates a matching version prefix from baseUrl overrides", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({
        path: "/v1/entities",
        baseUrl: "https://registry-api.qa.harness.io/v1",
      });

      const url = fetchSpy.mock.calls[0][0] as string;
      expect(url).toContain("https://registry-api.qa.harness.io/v1/entities?");
      expect(url).not.toContain("/v1/v1/");
    });

    it("keeps /gateway path when base URL does not end with /gateway", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_BASE_URL: "https://app.harness.io" }));

      await client.request({ path: "/gateway/log-service/blob/download" });

      const url = fetchSpy.mock.calls[0][0] as string;
      expect(url).toContain("https://app.harness.io/gateway/log-service/blob/download?");
    });

    it("uses baseUrl override when provided (e.g. FME/Split.io)", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_BASE_URL: "https://app.harness.io" }));

      await client.request({
        path: "/internal/api/v2/splits/ws/ws-123",
        baseUrl: "https://api.split.io",
      });

      const url = fetchSpy.mock.calls[0][0] as string;
      expect(url).toMatch(/^https:\/\/api\.split\.io\/internal\/api\/v2\/splits\/ws\/ws-123/);
      expect(url).not.toContain("app.harness.io");
    });

    it("omits undefined and empty params", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/test", params: { a: "1", b: undefined, c: "" } });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.get("a")).toBe("1");
      expect(url.searchParams.has("b")).toBe(false);
      expect(url.searchParams.has("c")).toBe(false);
    });

    it("serializes string[] params as repeated query keys (grpc-gateway arrays)", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({
        path: "/pipeline/api/pipeline/execute/p1",
        params: { inputSetIdentifiers: ["set-a", "set-b"], orgIdentifier: "o" },
      });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.getAll("inputSetIdentifiers")).toEqual(["set-a", "set-b"]);
      expect(url.searchParams.get("orgIdentifier")).toBe("o");
    });

    it("omits routingId when headerBasedScoping is true", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/sei/api/v1/users", headerBasedScoping: true });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.has("accountIdentifier")).toBe(false);
      expect(url.searchParams.has("routingId")).toBe(false);
    });

    it("omits routingId for FME product requests", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/internal/api/v2/splits", product: "fme" });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.has("accountIdentifier")).toBe(false);
      expect(url.searchParams.has("routingId")).toBe(false);
    });

    it("uses resolved account ID for routingId", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());
      client.setAccountIdResolver(() => "resolved-account-123");

      await client.request({ path: "/ng/api/projects" });

      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.get("accountIdentifier")).toBe("resolved-account-123");
      expect(url.searchParams.get("routingId")).toBe("resolved-account-123");
    });
  });

  describe("request — headers", () => {
    it("sets x-api-key and Harness-Account headers", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ path: "/test" });

      const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
      expect(headers["x-api-key"]).toBe("pat.test-account.token.secret");
      expect(headers["Harness-Account"]).toBe("test-account");
    });

    it("uses resolved account ID for Harness-Account header", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());
      client.setAccountIdResolver(() => "resolved-account");

      await client.request({ path: "/test" });

      const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
      expect(headers["Harness-Account"]).toBe("resolved-account");
    });

    it("sets Content-Type to application/json for object body", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "POST", path: "/test", body: { key: "value" } });

      const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
      expect(headers["Content-Type"]).toBe("application/json");
    });

    it("sets Content-Type to application/yaml for string body", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "PUT", path: "/test", body: "pipeline:\n  name: test" });

      const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
      expect(headers["Content-Type"]).toBe("application/yaml");
    });

    it("allows Content-Type override via options.headers", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "POST", path: "/test", body: "data", headers: { "Content-Type": "text/plain" } });

      const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
      expect(headers["Content-Type"]).toBe("text/plain");
    });
  });

  describe("request — success", () => {
    it("returns parsed JSON on 200", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ data: { id: "p1" } }), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      const result = await client.request<{ data: { id: string } }>({ path: "/test" });
      expect(result.data.id).toBe("p1");
    });
  });

  describe("request — error handling", () => {
    it("throws HarnessApiError with parsed message on 400", async () => {
      fetchSpy.mockResolvedValue(new Response(
        JSON.stringify({ message: "Invalid input", code: "INVALID", correlationId: "corr-1" }),
        { status: 400 },
      ));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        const e = err as HarnessApiError;
        expect(e.message).toBe("Invalid input");
        expect(e.statusCode).toBe(400);
        expect(e.harnessCode).toBe("INVALID");
        expect(e.correlationId).toBe("corr-1");
      }
    });

    it("throws HarnessApiError with raw body on non-JSON error", async () => {
      fetchSpy.mockResolvedValue(new Response("Bad Gateway", { status: 502 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      await expect(client.request({ path: "/test" })).rejects.toThrow(/HTTP 502: Bad Gateway/);
    });

    it("returns actionable message for HTML 403 (proxy/WAF block)", async () => {
      const html = '<!doctype html><meta charset="utf-8"><meta name=viewport content="width=device-width, initial-scale=1"><title>403</title>403 Forbidden';
      fetchSpy.mockResolvedValue(new Response(html, { status: 403 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        const e = err as HarnessApiError;
        expect(e.statusCode).toBe(403);
        expect(e.message).toContain("HTTP 403 Forbidden");
        expect(e.message).toContain("HARNESS_ACCOUNT_ID");
        // Should NOT contain raw HTML tags
        expect(e.message).not.toContain("<");
      }
    });

    it("returns actionable message for HTML 401", async () => {
      const html = "<html><body>Unauthorized</body></html>";
      fetchSpy.mockResolvedValue(new Response(html, { status: 401 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        const e = err as HarnessApiError;
        expect(e.statusCode).toBe(401);
        expect(e.message).toContain("HARNESS_API_KEY");
        expect(e.message).not.toContain("<");
      }
    });

    it("strips JS redirect page from 401 error message", async () => {
      const redirectHtml = `<html><head><script>
        function redirectPage() {
          const signInPath = "auth/#/signin";
          const returnUrl = encodeURIComponent(window.location.href);
          window.location.href = signInPath + "?returnUrl=" + returnUrl;
        }
        redirectPage();
      </script></head><body></body></html>`;
      fetchSpy.mockResolvedValue(new Response(redirectHtml, { status: 401 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        const e = err as HarnessApiError;
        expect(e.statusCode).toBe(401);
        expect(e.message).toContain("HARNESS_API_KEY");
        expect(e.message).not.toContain("redirectPage");
        expect(e.message).not.toContain("signInPath");
        expect(e.message).not.toContain("function");
        expect(e.message).not.toContain("<script");
      }
    });

    it("strips garbage JSON message containing redirect JS", async () => {
      const body = JSON.stringify({
        message: 'Harness Redirect function redirectPage() { const signInPath = "auth/#/signin"; const returnUrl = encodeURIComponent(window.location.href) }',
      });
      fetchSpy.mockResolvedValue(new Response(body, { status: 401 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        const e = err as HarnessApiError;
        expect(e.statusCode).toBe(401);
        expect(e.message).toContain("HARNESS_API_KEY");
        expect(e.message).not.toContain("redirectPage");
        expect(e.message).not.toContain("signInPath");
      }
    });

    it("does not retry on 400", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ message: "bad" }), { status: 400 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      await expect(client.request({ path: "/test" })).rejects.toThrow(HarnessApiError);
      expect(fetchSpy).toHaveBeenCalledTimes(1);
    });

    it("does not retry on 401", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ message: "unauthorized" }), { status: 401 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      await expect(client.request({ path: "/test" })).rejects.toThrow(HarnessApiError);
      expect(fetchSpy).toHaveBeenCalledTimes(1);
    });
  });

  describe("request — retry logic", () => {
    it("retries on 500 and succeeds", async () => {
      fetchSpy
        .mockResolvedValueOnce(new Response(JSON.stringify({ message: "fail" }), { status: 500 }))
        .mockResolvedValueOnce(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      const result = await client.request<{ data: string }>({ path: "/test" });
      expect(result.data).toBe("ok");
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    });

    it("retries on 429 and succeeds", async () => {
      fetchSpy
        .mockResolvedValueOnce(new Response(JSON.stringify({ message: "rate limited" }), { status: 429 }))
        .mockResolvedValueOnce(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      const result = await client.request<{ data: string }>({ path: "/test" });
      expect(result.data).toBe("ok");
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    });

    it("throws after exhausting retries on 503", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ message: "unavailable" }), { status: 503 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 1 }));

      await expect(client.request({ path: "/test" })).rejects.toThrow(HarnessApiError);
      // initial + 1 retry = 2 calls
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    });

    it("does not retry on 500 when retryPolicy is 'do_not_retry'", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ message: "fail" }), { status: 500 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      await expect(client.request({ path: "/test", retryPolicy: "do_not_retry" })).rejects.toThrow(HarnessApiError);
      expect(fetchSpy).toHaveBeenCalledTimes(1);
    });

    it("retries on 500 when retryPolicy is 'safe'", async () => {
      fetchSpy
        .mockResolvedValueOnce(new Response(JSON.stringify({ message: "fail" }), { status: 500 }))
        .mockResolvedValueOnce(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      const result = await client.request<{ data: string }>({ path: "/test", retryPolicy: "safe" });
      expect(result.data).toBe("ok");
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    });

    it("retries when retryPolicy is undefined (default behavior)", async () => {
      fetchSpy
        .mockResolvedValueOnce(new Response(JSON.stringify({ message: "fail" }), { status: 502 }))
        .mockResolvedValueOnce(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 2 }));

      const result = await client.request<{ data: string }>({ path: "/test" });
      expect(result.data).toBe("ok");
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    });
  });

  describe("request — timeout", () => {
    it("throws HarnessApiError with 408 on timeout", async () => {
      fetchSpy.mockImplementation(() => new Promise((_, reject) => {
        setTimeout(() => {
          const err = new Error("The operation was aborted");
          err.name = "AbortError";
          reject(err);
        }, 10);
      }));
      const client = new HarnessClient(makeConfig({ HARNESS_API_TIMEOUT_MS: 1, HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(408);
        expect((err as HarnessApiError).message).toContain("timed out");
      }
    });
  });

  describe("request — network errors", () => {
    it("wraps fetch errors as HarnessApiError with 502", async () => {
      fetchSpy.mockRejectedValue(new Error("DNS resolution failed"));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(502);
        expect((err as HarnessApiError).message).toContain("DNS resolution failed");
      }
    });
  });

  describe("request — abort signal", () => {
    it("throws 499 immediately when signal is already aborted", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));
      const controller = new AbortController();
      controller.abort();

      try {
        await client.request({ path: "/test", signal: controller.signal });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(499);
        expect((err as HarnessApiError).message).toContain("cancelled");
      }
      // fetch should NOT have been called
      expect(fetchSpy).not.toHaveBeenCalled();
    });

    it("throws 499 when signal aborts during request (no retry)", async () => {
      const controller = new AbortController();
      fetchSpy.mockImplementation(() => {
        // Abort mid-request
        controller.abort();
        const err = new Error("The operation was aborted");
        err.name = "AbortError";
        return Promise.reject(err);
      });
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 3 }));

      try {
        await client.request({ path: "/test", signal: controller.signal });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(499);
        expect((err as HarnessApiError).message).toContain("cancelled");
      }
      // Should NOT retry — only 1 call
      expect(fetchSpy).toHaveBeenCalledTimes(1);
    });

    it("passes signal through to fetch", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig());
      const controller = new AbortController();

      await client.request({ path: "/test", signal: controller.signal });

      // The signal passed to fetch should be a combined signal (AbortSignal.any)
      const fetchOptions = fetchSpy.mock.calls[0][1] as RequestInit;
      expect(fetchOptions.signal).toBeDefined();
    });
  });

  describe("request — non-JSON responses", () => {
    it("throws clear error for HTML response (proxy error page)", async () => {
      const html = "<html><body><h1>502 Bad Gateway</h1></body></html>";
      fetchSpy.mockResolvedValue(new Response(html, { status: 200, headers: { "Content-Type": "text/html" } }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(502);
        expect((err as HarnessApiError).message).toContain("Non-JSON response");
        expect((err as HarnessApiError).message).toContain("502 Bad Gateway");
      }
    });

    it("throws clear error for empty response body", async () => {
      fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
      const client = new HarnessClient(makeConfig({ HARNESS_MAX_RETRIES: 0 }));

      try {
        await client.request({ path: "/test" });
        expect.fail("should have thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HarnessApiError);
        expect((err as HarnessApiError).statusCode).toBe(502);
        expect((err as HarnessApiError).message).toContain("Empty response body");
      }
    });

    it("parses valid JSON response normally", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ data: "ok" }), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      const result = await client.request<{ data: string }>({ path: "/test" });
      expect(result.data).toBe("ok");
    });
  });

  describe("request — body serialization", () => {
    it("sends JSON-stringified body for objects", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "POST", path: "/test", body: { key: "val" } });

      const body = fetchSpy.mock.calls[0][1]?.body as string;
      expect(JSON.parse(body)).toEqual({ key: "val" });
    });

    it("sends raw string body as-is", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({ method: "PUT", path: "/test", body: "raw yaml content" });

      const body = fetchSpy.mock.calls[0][1]?.body as string;
      expect(body).toBe("raw yaml content");
    });

    it("sends empty string body (pipeline execute uses YAML + inputSetIdentifiers query)", async () => {
      fetchSpy.mockResolvedValue(new Response(JSON.stringify({ status: "SUCCESS" }), { status: 200 }));
      const client = new HarnessClient(makeConfig());

      await client.request({
        method: "POST",
        path: "/pipeline/api/pipeline/execute/MyPipeline",
        headers: { "Content-Type": "application/yaml" },
        body: "",
        params: {
          orgIdentifier: "default",
          projectIdentifier: "PM_Signoff",
          inputSetIdentifiers: "mcp_default_runtime_inputs",
        },
      });

      const init = fetchSpy.mock.calls[0][1] as RequestInit;
      expect(init.body).toBe("");
      const headers = init.headers as Record<string, string>;
      expect(headers["Content-Type"]).toBe("application/yaml");
      const url = new URL(fetchSpy.mock.calls[0][0] as string);
      expect(url.searchParams.get("inputSetIdentifiers")).toBe("mcp_default_runtime_inputs");
    });
  });
});
