import { afterEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { buildRouter } from "@wrnexus/router";
import { defineService, implement, procedure } from "@wrnexus/rpc";
import { v } from "@wrnexus/validation";
import { createHandlers, type RuntimeDeps } from "../src/runtime.ts";

const roots: string[] = [];
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));

const greeter = defineService({
  name: "greeter",
  procedures: {
    greet: procedure
      .input(v.object({ name: v.string() }))
      .output<{ message: string }>()
      .build(),
  },
});

function makeHandlers(app: string, loadModule: RuntimeDeps["loadModule"]) {
  return createHandlers({
    mode: "production",
    hmr: false,
    router: buildRouter(app),
    loadModule,
    getMiddleware: async () => [],
    assets: { serve: async () => null },
  } satisfies RuntimeDeps);
}

test("a bad file under app/services does not break unrelated routes", async () => {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-bad-service-"));
  roots.push(root);
  const app = join(root, "app");
  mkdirSync(join(app, "pages"), { recursive: true });
  mkdirSync(join(app, "services"), { recursive: true });
  writeFileSync(join(app, "pages/home.ts"), "export default () => '';\n");
  // A co-located helper with no default export — the natural thing a
  // developer drops next to a real service.
  writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n");

  const handlers = makeHandlers(app, async (file) => {
    const normalized = file.replace(/\\/g, "/");
    if (normalized.endsWith("services/types.ts")) return {}; // no default export
    if (normalized.endsWith("pages/home.ts")) return { default: () => "<p>home</p>" };
    throw new Error(`unexpected module: ${file}`);
  });

  const res = await handlers.fetch(new Request("https://example.test/home"), {
    upgrade: () => false,
  });
  expect(await res!.text()).toContain("home");
});

test("the RPC path returns a structured failure instead of throwing when service load fails", async () => {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-load-fail-"));
  roots.push(root);
  const app = join(root, "app");
  mkdirSync(join(app, "services"), { recursive: true });
  writeFileSync(join(app, "services/types.ts"), "export type Foo = string;\n");

  const handlers = makeHandlers(app, async () => ({})); // no default export

  const res = await handlers.fetch(
    new Request("https://example.test/__wrnexus/rpc/types/greet", {
      method: "POST",
      headers: { "content-type": "application/json", "x-wrnexus-internal": "1" },
      body: "{}",
    }),
    { upgrade: () => false },
  );
  expect(res).toBeDefined();
  expect(res!.status).toBeLessThan(500);
  const body = await res!.json();
  expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" });
});

test("a request after a load failure re-attempts rather than serving a cached rejection", async () => {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-retry-"));
  roots.push(root);
  const app = join(root, "app");
  mkdirSync(join(app, "services"), { recursive: true });
  writeFileSync(join(app, "services/greeter.ts"), "export default {};\n");

  let attempt = 0;
  const handlers = makeHandlers(app, async () => {
    attempt += 1;
    if (attempt === 1) return {}; // fails: no default export
    return {
      default: implement(
        greeter,
        { greet: async ({ name }) => ({ message: `Hi ${name}` }) },
        {
          selfApp: "greeter",
        },
      ),
    };
  });

  const call = () =>
    handlers.fetch(
      new Request("https://example.test/__wrnexus/rpc/greeter/greet", {
        method: "POST",
        headers: { "content-type": "application/json", "x-wrnexus-internal": "1" },
        body: JSON.stringify({ name: "Ada" }),
      }),
      { upgrade: () => false },
    );

  const first = await call();
  expect(await first!.json()).toMatchObject({ ok: false, code: "RPC_UNKNOWN" });

  const second = await call();
  expect(await second!.json()).toEqual({ ok: true, value: { message: "Hi Ada" } });
  expect(attempt).toBe(2);
});

test("a contract name that does not match its mounted filename fails loudly, naming both", async () => {
  const root = mkdtempSync(join(tmpdir(), "wrnexus-rpc-name-mismatch-"));
  roots.push(root);
  const app = join(root, "app");
  mkdirSync(join(app, "services"), { recursive: true });
  writeFileSync(join(app, "services/greeter.ts"), "export default {};\n");

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

  const handlers = makeHandlers(app, async () => ({ default: service }));

  // The typed client would call /__wrnexus/rpc/billing/... (contract name),
  // but the file mounts as "greeter" — the client's request 404s as unknown.
  const res = await handlers.fetch(
    new Request("https://example.test/__wrnexus/rpc/billing/createInvoice", {
      method: "POST",
      headers: { "content-type": "application/json", "x-wrnexus-internal": "1" },
      body: JSON.stringify({ amountCents: 5 }),
    }),
    { upgrade: () => false },
  );
  const body = await res!.json();
  expect(body).toMatchObject({ ok: false, code: "RPC_UNKNOWN" });

  // And the actual mounted name ("greeter") also fails: the implementation's
  // contract does not match the filename it was mounted under.
  const res2 = await handlers.fetch(
    new Request("https://example.test/__wrnexus/rpc/greeter/createInvoice", {
      method: "POST",
      headers: { "content-type": "application/json", "x-wrnexus-internal": "1" },
      body: JSON.stringify({ amountCents: 5 }),
    }),
    { upgrade: () => false },
  );
  const body2 = await res2!.json();
  expect(body2).toMatchObject({ ok: false, code: "RPC_UNKNOWN" });
});
