import { afterEach, describe, expect, test } from "bun:test";
import * as http from "node:http";
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 } from "../src/errors.ts";
import { httpTransport } from "../src/http.ts";
import { implement } from "../src/server.ts";
import { inProcessTransport } from "../src/transport.ts";
import { handleRpcRequest } from "../../dev-server/src/rpc-dispatch.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;
});

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

function wire(allowed: boolean) {
  const service = implement(
    billing,
    {
      createInvoice: async ({ amountCents }, ctx) => ({
        invoiceId: `inv_${amountCents}`,
        forSubject: ctx.subject?.subjectId ?? "anon",
      }),
    },
    { selfApp: "billing", checkPermission: async () => allowed },
  );
  return inProcessTransport({
    "billing/createInvoice": (payload, identity) =>
      service.invoke("createInvoice", payload, identity),
  });
}

function httpWire(allowed: boolean) {
  const service = implement(
    billing,
    {
      createInvoice: async ({ amountCents }, ctx) => ({
        invoiceId: `inv_${amountCents}`,
        forSubject: ctx.subject?.subjectId ?? "anon",
      }),
    },
    { selfApp: "billing", checkPermission: async () => allowed },
  );
  return httpTransport({
    resolveOrigin: () => "http://billing.internal",
    fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
      const request = new Request(input, init);
      return (await handleRpcRequest(
        request,
        new URL(request.url),
        new Map([["billing", service]]),
      ))!;
    }) as typeof fetch,
  });
}

// packages/csr's actions.test.ts deletes globalThis.fetch (and restores its
// own captured copy) around each of its tests, and packages/csr's
// reactive.test.ts's "cache invalidation refetches matching client Async
// boundaries" test replaces globalThis.fetch with a mock and never restores
// it at all. bun test runs test files sequentially — importing a file and
// running its tests before moving to the next — rather than importing every
// file up front, so a module-level `const realFetch = fetch` captured here
// would already observe whatever packages/csr left behind by the time this
// file (which sorts after csr) is loaded. The variable under test below is
// `resolveOrigin`, not `fetch`, so instead of depending on the shared
// `globalThis.fetch` at all, this makes a real request over a real socket
// using node:http directly. That keeps the assertion about
// httpTransport()'s default origin resolution intact regardless of what any
// other suite does to the global `fetch` binding.
function realFetch(url: string, init: RequestInit): Promise<Response> {
  return new Promise((resolve, reject) => {
    const target = new URL(url);
    const req = http.request(
      {
        hostname: target.hostname,
        port: target.port,
        path: `${target.pathname}${target.search}`,
        method: init.method ?? "GET",
        headers: init.headers as Record<string, string>,
      },
      (res) => {
        const chunks: Buffer[] = [];
        res.on("data", (chunk: Buffer) => chunks.push(chunk));
        res.on("error", reject);
        res.on("end", () => {
          resolve(
            new Response(Buffer.concat(chunks), {
              status: res.statusCode ?? 500,
            }),
          );
        });
      },
    );
    req.on("error", reject);
    if (typeof init.body === "string") req.write(init.body);
    req.end();
  });
}

describe("RPC integration", () => {
  test("propagates identity and validates the callee permission", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const client = serviceClient(billing, {
      app: "billing",
      as: { user: { id: "u1" }, tenant: { id: "acme" }, locals: {} } as unknown as Context,
      transport: wire(true),
    });
    expect(await client.createInvoice({ amountCents: 250 })).toEqual({
      invoiceId: "inv_250",
      forSubject: "u1",
    });
  });

  test("denies when the callee permission check refuses", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const client = serviceClient(billing, {
      app: "billing",
      as: { user: { id: "u1" }, locals: {} } as unknown as Context,
      transport: wire(false),
    });
    await expect(client.createInvoice({ amountCents: 1 })).rejects.toMatchObject({
      code: RPC_ERROR_CODES.denied,
    });
  });

  test("uses the real HTTP transport and private dispatcher end to end", async () => {
    process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
    process.env.WRNEXUS_APP_NAME = "web";
    const client = serviceClient(billing, {
      app: "billing",
      as: { user: { id: "u1" }, locals: {} } as unknown as Context,
      transport: httpWire(true),
    });
    expect(await client.createInvoice({ amountCents: 7 })).toEqual({
      invoiceId: "inv_7",
      forSubject: "u1",
    });
    await expect(client.createInvoice({ amountCents: "bad" } as never)).rejects.toMatchObject({
      code: RPC_ERROR_CODES.invalid,
    });
  });

  test("the default httpTransport reaches the callee over a real socket via the internal origin", async () => {
    const service = implement(
      billing,
      {
        createInvoice: async ({ amountCents }, ctx) => ({
          invoiceId: `inv_${amountCents}`,
          forSubject: ctx.subject?.subjectId ?? "anon",
        }),
      },
      { selfApp: "billing", checkPermission: async () => true },
    );
    const server = Bun.serve({
      port: 0,
      hostname: "127.0.0.1",
      async fetch(req) {
        const url = new URL(req.url);
        const res = await handleRpcRequest(req, url, new Map([["billing", service]]));
        return res ?? new Response("Not found", { status: 404 });
      },
    });
    const originalWorkspace = process.env.WRNEXUS_WORKSPACE_ORIGINS;
    const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
    try {
      // The workspace (public) origin deliberately points somewhere that
      // cannot serve the RPC — the gateway 404s the RPC prefix on any
      // request that arrives at a public origin. Only the internal-origin
      // map points at the real server. If httpTransport() ever falls back
      // to the public origin by default again, this call fails.
      process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
        billing: "http://127.0.0.1:1", // unroutable — nothing listens here
      });
      process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
        billing: `http://127.0.0.1:${server.port}`,
      });
      const client = serviceClient(billing, {
        app: "billing",
        // no resolveOrigin override — uses the real default; fetch is pinned
        // to the node:http-backed implementation above (see comment there).
        transport: httpTransport({ fetch: realFetch as unknown as typeof fetch }),
      });
      expect(await client.createInvoice({ amountCents: 42 })).toEqual({
        invoiceId: "inv_42",
        forSubject: "anon",
      });
    } finally {
      server.stop(true);
      if (originalWorkspace === undefined) delete process.env.WRNEXUS_WORKSPACE_ORIGINS;
      else process.env.WRNEXUS_WORKSPACE_ORIGINS = originalWorkspace;
      if (originalInternal === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
      else process.env.WRNEXUS_INTERNAL_ORIGINS = originalInternal;
    }
  });
});
