import { afterEach, describe, expect, test } from "bun:test";
import type { Context } from "@wrnexus/core";
import { v } from "@wrnexus/validation";
import { serviceClient } from "../src/client.ts";
import { defineService, procedure } from "../src/contract.ts";
import { RPC_ERROR_CODES, ServiceError, failure, success } from "../src/errors.ts";
import type { RpcTarget } from "../src/transport.ts";

const originalRpcSecret = process.env.WRNEXUS_RPC_SECRET;
const originalAppName = process.env.WRNEXUS_APP_NAME;
afterEach(() => {
  if (originalRpcSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
  else process.env.WRNEXUS_RPC_SECRET = originalRpcSecret;
  if (originalAppName === undefined) delete process.env.WRNEXUS_APP_NAME;
  else process.env.WRNEXUS_APP_NAME = originalAppName;
});

function configure(appName = "web") {
  process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
  process.env.WRNEXUS_APP_NAME = appName;
}

const billing = defineService({
  name: "billing",
  procedures: {
    createInvoice: procedure
      .input(v.object({ amountCents: v.number() }))
      .output<{ invoiceId: string }>()
      .build(),
  },
});

describe("serviceClient", () => {
  test("returns the handler's value unwrapped", async () => {
    const client = serviceClient(billing, {
      transport: { call: async () => success({ invoiceId: "inv_1" }) },
    });
    expect(await client.createInvoice({ amountCents: 1 })).toEqual({ invoiceId: "inv_1" });
  });

  test("throws a ServiceError carrying code and retryable on failure", async () => {
    const client = serviceClient(billing, {
      transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") },
    });
    let caught: unknown;
    try {
      await client.createInvoice({ amountCents: 1 });
    } catch (err) {
      caught = err;
    }
    expect(caught).toBeInstanceOf(ServiceError);
    expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: true });
  });

  test("attaches an identity token when given a context and omits it for an anonymous context", async () => {
    configure("web");
    let seenIdentity: string | undefined = "unset";
    const client = serviceClient(billing, {
      as: { user: { id: "u1" }, locals: {} } as unknown as Context,
      transport: {
        call: async (_target, _payload, options) => {
          seenIdentity = options.identity;
          return success({ invoiceId: "inv_1" });
        },
      },
    });
    await client.createInvoice({ amountCents: 1 });
    expect(seenIdentity).toBeTypeOf("string");

    let seenAnon: unknown = "unset";
    const anonClient = serviceClient(billing, {
      transport: {
        call: async (_target, _payload, options) => {
          seenAnon = options.identity;
          return success({ invoiceId: "inv_1" });
        },
      },
    });
    await anonClient.createInvoice({ amountCents: 1 });
    expect(seenAnon).toBeUndefined();
  });

  test("an undeclared procedure is undefined rather than a function that throws", async () => {
    let called = false;
    const client = serviceClient(billing, {
      transport: {
        call: async () => {
          called = true;
          return success({});
        },
      },
    });
    const proxy = client as unknown as Record<string, unknown>;
    expect(proxy.deleteEverything).toBeUndefined();
    expect(called).toBe(false);
  });

  test("the proxy has no `then` escape — await/return does not trigger a call", async () => {
    let called = false;
    const client = serviceClient(billing, {
      transport: {
        call: async () => {
          called = true;
          return success({ invoiceId: "inv_1" });
        },
      },
    });
    const proxy = client as unknown as Record<string, unknown>;
    expect(proxy.then).toBeUndefined();
    expect(proxy.catch).toBeUndefined();
    expect(proxy.finally).toBeUndefined();
    // `await client` must resolve to the proxy object itself, not reject.
    const awaited = await client;
    expect(awaited).toBe(client);
    expect(called).toBe(false);
  });

  test("the signal arrives at the transport", async () => {
    let seenSignal: AbortSignal | undefined;
    const client = serviceClient(billing, {
      transport: {
        call: async (_target, _payload, options) => {
          seenSignal = options.signal;
          return success({ invoiceId: "inv_1" });
        },
      },
    });
    await client.createInvoice({ amountCents: 1 });
    expect(seenSignal).toBeInstanceOf(AbortSignal);
  });

  test("the call aborts at timeoutMs when the transport ignores the signal", async () => {
    const client = serviceClient(billing, {
      timeoutMs: 20,
      transport: {
        call: () => new Promise(() => {}), // never resolves; ignores the signal
      },
    });
    let caught: unknown;
    const start = Date.now();
    try {
      await client.createInvoice({ amountCents: 1 });
    } catch (err) {
      caught = err;
    }
    const elapsed = Date.now() - start;
    expect(caught).toBeInstanceOf(ServiceError);
    expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport });
    expect(elapsed).toBeLessThan(500);
  });

  test("the timer is cleared on both success and failure", async () => {
    const originalClearTimeout = globalThis.clearTimeout;
    let clearCount = 0;
    globalThis.clearTimeout = ((...args: Parameters<typeof clearTimeout>) => {
      clearCount++;
      return originalClearTimeout(...args);
    }) as typeof clearTimeout;
    try {
      const okClient = serviceClient(billing, {
        transport: { call: async () => success({ invoiceId: "inv_1" }) },
      });
      await okClient.createInvoice({ amountCents: 1 });
      expect(clearCount).toBe(1);

      const failClient = serviceClient(billing, {
        transport: { call: async () => failure(RPC_ERROR_CODES.transport, "down") },
      });
      await expect(failClient.createInvoice({ amountCents: 1 })).rejects.toBeInstanceOf(
        ServiceError,
      );
      expect(clearCount).toBe(2);
    } finally {
      globalThis.clearTimeout = originalClearTimeout;
    }
  });

  test("the app defaults to the service name", async () => {
    let seenTarget: RpcTarget | undefined;
    const client = serviceClient(billing, {
      transport: {
        call: async (target) => {
          seenTarget = target;
          return success({ invoiceId: "inv_1" });
        },
      },
    });
    await client.createInvoice({ amountCents: 1 });
    expect(seenTarget?.app).toBe("billing");
    expect(seenTarget?.service).toBe("billing");
  });

  test("a wire-level non-retryable failure (e.g. a 403) surfaces at the client as retryable: false", async () => {
    // RPC_TRANSPORT recomputes to retryable: true from the code alone — that
    // is correct for a 5xx, but the transport already determined a 403 is
    // NOT retryable via the bounded status check. The client must pass that
    // wire value through rather than recompute it from the code.
    const client = serviceClient(billing, {
      transport: {
        call: async () => ({
          ok: false,
          code: RPC_ERROR_CODES.transport,
          message: "Service returned 403",
          retryable: false,
        }),
      },
    });
    let caught: unknown;
    try {
      await client.createInvoice({ amountCents: 1 });
    } catch (err) {
      caught = err;
    }
    expect(caught).toBeInstanceOf(ServiceError);
    expect(caught).toMatchObject({ code: RPC_ERROR_CODES.transport, retryable: false });
  });
});
