// ---------------------------------------------------------------------------
// Regression for https://github.com/RhysSullivan/executor/pull/468 — the
// cloud API v4 routing refactor wired DbService.Live (and other I/O-holding
// services) into `Layer.provideMerge` of an `HttpRouter.toWebHandler` app.
// `toWebHandler` builds the layer ONCE at worker boot and reuses the
// resolved Context for every request, so `Effect.acquireRelease` runs only
// at boot. On Cloudflare Workers that means the postgres.js socket (a
// `Writable` I/O object) is opened in request 1's context and reused by
// request 2, which the runtime forbids:
//
//   StorageError: FumaDB findMany select failed:
//     Cannot perform I/O on behalf of a different request. (I/O type: Writable)
//
// The only primitive that actually rebuilds per request is a custom
// `HttpRouter.middleware` whose per-request handler does
// `Layer.build(layer)` inside `Effect.scoped`. `provideMerge` runs the
// layer at boot; `HttpRouter.provideRequest` (despite its name) also runs
// the layer at boot — its `Layer.build` lives in the *outer* middleware
// effect, which executes at layer-construction time. Only an explicit
// `Effect.scoped` inside the per-request handler creates a fresh scope
// for `acquireRelease`.
// ---------------------------------------------------------------------------

import { describe, it, expect } from "@effect/vitest";
import { Context, Effect, Layer } from "effect";
import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http";

import { requestScopedMiddleware } from "@executor-js/api/server";

import { RequestScopedServicesLive } from "./api/layers";
import { makeApiLive } from "./api/router";

class Counter extends Context.Service<Counter, { readonly id: number }>()("test/Counter") {}

const makeCounterLive = (counts: { acquires: number; releases: number }, acquireDelayMs = 0) =>
  Layer.effect(Counter)(
    Effect.acquireRelease(
      Effect.gen(function* () {
        // Yield to the event loop inside acquire to force concurrent
        // request fibers to overlap on the shared boot MemoMap.
        if (acquireDelayMs > 0) {
          yield* Effect.sleep(`${acquireDelayMs} millis`);
        }
        counts.acquires += 1;
        return { id: counts.acquires };
      }),
      () =>
        Effect.sync(() => {
          counts.releases += 1;
        }),
    ),
  );

const Routes = HttpRouter.add(
  "GET",
  "/",
  Effect.gen(function* () {
    const c = yield* Counter;
    return HttpServerResponse.jsonUnsafe({ id: c.id });
  }),
);

describe("HttpRouter.toWebHandler request scoping", () => {
  it("Layer.provideMerge of a scoped layer captures the boot scope (the bug)", async () => {
    const counts = { acquires: 0, releases: 0 };
    const App = Routes.pipe(
      Layer.provideMerge(makeCounterLive(counts)),
      Layer.provideMerge(HttpServer.layerServices),
    );
    const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;

    const a = await handler(new Request("http://test.local/"));
    const b = await handler(new Request("http://test.local/"));

    // Same id => the resource was acquired once at boot and shared.
    // On Cloudflare Workers this is the I/O-isolation crash mode.
    expect(await a.json()).toEqual({ id: 1 });
    expect(await b.json()).toEqual({ id: 1 });
    expect(counts.acquires).toBe(1);
  });

  it("HttpRouter.provideRequest is misleadingly named — it also captures boot scope", async () => {
    const counts = { acquires: 0, releases: 0 };
    const App = Routes.pipe(
      HttpRouter.provideRequest(makeCounterLive(counts)),
      Layer.provideMerge(HttpServer.layerServices),
    );
    const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;

    const a = await handler(new Request("http://test.local/"));
    const b = await handler(new Request("http://test.local/"));

    // `provideRequest` runs `Layer.build` in the OUTER middleware effect,
    // which fires at layer-construction time — same lifetime as the boot
    // scope. Both requests see the same acquired resource.
    expect(await a.json()).toEqual({ id: 1 });
    expect(await b.json()).toEqual({ id: 1 });
    expect(counts.acquires).toBe(1);
  });

  it("requestScopedMiddleware runs acquireRelease per request (the fix)", async () => {
    const counts = { acquires: 0, releases: 0 };
    const App = Routes.pipe(
      Layer.provide(requestScopedMiddleware(makeCounterLive(counts)).layer),
      Layer.provideMerge(HttpServer.layerServices),
    );
    const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;

    const a = await handler(new Request("http://test.local/"));
    const b = await handler(new Request("http://test.local/"));

    expect(await a.json()).toEqual({ id: 1 });
    expect(await b.json()).toEqual({ id: 2 });
    expect(counts.acquires).toBe(2);
    expect(counts.releases).toBe(2);
  });

  // Concurrent regression: Cloudflare Workers serves multiple in-flight
  // requests from the same isolate. `Layer.build(layer)` (used by
  // `requestScopedMiddleware`) inherits the boot-level `CurrentMemoMap`
  // installed by `HttpRouter.toWebHandler`, so two requests that race
  // through the middleware before either's scope closes BOTH reuse the
  // first request's memoized layer build — sharing one postgres.js socket
  // across two request handlers, which the runtime forbids:
  //   "Cannot perform I/O on behalf of a different request"
  //
  // The fix must give each request a fresh MemoMap so memoization is
  // request-local. Without it, this test acquires only once (and would
  // crash in prod on the second concurrent request's I/O).
  it("requestScopedMiddleware does NOT share a build across concurrent requests", async () => {
    const counts = { acquires: 0, releases: 0 };
    // 5ms async sleep inside acquire forces the two request fibers to
    // overlap on the layer build, the same shape as Cloudflare Workers
    // serving multiple in-flight requests from one isolate.
    const App = Routes.pipe(
      Layer.provide(requestScopedMiddleware(makeCounterLive(counts, 5)).layer),
      Layer.provideMerge(HttpServer.layerServices),
    );
    const handler = HttpRouter.toWebHandler(App, { disableLogger: true }).handler;

    const [a, b] = await Promise.all([
      handler(new Request("http://test.local/")),
      handler(new Request("http://test.local/")),
    ]);

    const aBody = (await a.json()) as { id: number };
    const bBody = (await b.json()) as { id: number };
    // Two concurrent requests must see two distinct acquired counters.
    // Otherwise both fibers share one postgres socket -> Cloudflare
    // Workers I/O isolation crash in prod.
    expect(new Set([aBody.id, bBody.id])).toEqual(new Set([1, 2]));
    expect(counts.acquires).toBe(2);
    expect(counts.releases).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// Regression test against the prod handler factory. If anyone reverts
// `makeApiLive` back to wiring `RequestScopedServicesLive` via
// `Layer.provideMerge`, this test fails — the counter only increments
// once at boot instead of once per request.
// ---------------------------------------------------------------------------

describe("makeApiLive (prod handler factory) request scoping", () => {
  it("rebuilds RequestScopedServicesLive per request", async () => {
    const counts = { acquires: 0, releases: 0 };
    // Wrap the real per-request layer with an `acquireRelease` counter.
    // `requestScopedMiddleware` calls `Layer.build` per request, so this
    // counter increments per request iff the wiring is correct.
    const trackedRsLive = Layer.effectDiscard(
      Effect.acquireRelease(
        Effect.sync(() => {
          counts.acquires += 1;
        }),
        () =>
          Effect.sync(() => {
            counts.releases += 1;
          }),
      ),
    ).pipe(Layer.provideMerge(RequestScopedServicesLive));

    const handler = HttpRouter.toWebHandler(makeApiLive(trackedRsLive), {
      disableLogger: true,
    }).handler;

    // Hit a protected route. ExecutionStackMiddleware short-circuits with
    // 403 (no session cookie) but not before `requestScopedMiddleware`
    // has built the per-request layer. We don't care about the response —
    // only that the layer was built once per request. `/integrations` is a
    // v2 protected route (the old `/scope` group was removed).
    await handler(new Request("http://test.local/integrations"), Context.empty());
    await handler(new Request("http://test.local/integrations"), Context.empty());

    expect(counts.acquires).toBe(2);
    expect(counts.releases).toBe(2);
  });
});
