import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterAll, beforeAll, expect, test } from "@effect/vitest";
import { connectionIdentifier } from "@executor-js/sdk/shared";

process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-"));

// Identity comes from request headers so a single handler can serve many
// distinct identities concurrently — the setup that would expose a cross-fiber
// identity leak if the per-request executor's binding were shared rather than
// request-scoped. Each identity is its own (org, user): in v2 the org is the
// tenant (catalog partition) and the user is the acting subject (drives
// `owner: "user"` rows).
let handler!: (request: Request) => Promise<Response>;
let dispose: () => Promise<void> = async () => {};

beforeAll(async () => {
  const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app");
  const app = await makeSelfHostTestApp({
    identity: headerIdentityLayer,
  });
  handler = app.handler;
  dispose = app.dispose;
});
afterAll(() => dispose());

const TINY_SPEC = JSON.stringify({
  openapi: "3.0.0",
  info: { title: "Tiny", version: "1.0.0" },
  servers: [{ url: "https://httpbin.org" }],
  paths: {
    "/get": {
      get: {
        operationId: "httpGet",
        summary: "GET",
        responses: { "200": { description: "ok" } },
      },
    },
  },
});

const headersFor = (userId: string, organizationId: string): Record<string, string> => ({
  "x-test-user": userId,
  "x-test-org": organizationId,
  "content-type": "application/json",
});

const connectionNameForUser = (userId: string): string =>
  String(connectionIdentifier(`conn-${userId}`));

// Seed, as the given identity, a tenant-scoped integration plus a user-owned
// connection named after the subject. Tenant isolation means each identity's
// catalog is independent; subject isolation means the user connection is private.
const seedIdentity = async (userId: string, organizationId: string): Promise<void> => {
  const headers = headersFor(userId, organizationId);
  const add = await handler(
    new Request("http://localhost/api/openapi/specs", {
      method: "POST",
      headers,
      body: JSON.stringify({
        spec: { kind: "blob", value: TINY_SPEC },
        slug: "iso",
        baseUrl: "",
      }),
    }),
  );
  expect(add.status).toBe(200);
  const conn = await handler(
    new Request("http://localhost/api/connections", {
      method: "POST",
      headers,
      body: JSON.stringify({
        owner: "user",
        name: `conn-${userId}`,
        integration: "iso",
        template: "bearer",
        value: `token-${userId}`,
      }),
    }),
  );
  expect(conn.status).toBe(200);
};

const listConnectionAddresses = async (
  userId: string,
  organizationId: string,
): Promise<{ status: number; addresses: string[] }> => {
  const res = await handler(
    new Request("http://localhost/api/connections", {
      headers: { "x-test-user": userId, "x-test-org": organizationId },
    }),
  );
  if (res.status !== 200) return { status: res.status, addresses: [] };
  const body = (await res.json()) as ReadonlyArray<{ address: string }>;
  return { status: res.status, addresses: body.map((c) => c.address) };
};

test("concurrent requests with distinct identities get disjoint, correct executor bindings", async () => {
  // 6 identities, each its own (org, user). Seed each sequentially, then read
  // them back over the one long-lived SQLite handle with all 6 distinct
  // identities in flight at once, repeated across rounds.
  //
  // The leak this guards against is a logic bug (a shared rather than
  // request-scoped binding), so it surfaces whenever distinct identities are
  // concurrent at all; 6 simultaneous is plenty. We deliberately bound
  // in-flight requests to those 6 instead of firing an unbounded 48-wide burst:
  // every query serializes through the single libSQL connection, so a 48-fiber
  // pile-up starves the event loop under CI load and times out (the flake this
  // test was hitting). Same 48 total reads, same assertions, bounded pressure.
  const identities = Array.from({ length: 6 }, (_, i) => ({
    userId: `user-${i}`,
    organizationId: `org-${i}`,
  }));

  for (const id of identities) {
    await seedIdentity(id.userId, id.organizationId);
  }

  const rounds = 8;
  const observed: Array<{ userId: string; status: number; addresses: string[] }> = [];
  for (let round = 0; round < rounds; round++) {
    const roundResults = await Promise.all(
      identities.map(async (id) => ({
        userId: id.userId,
        ...(await listConnectionAddresses(id.userId, id.organizationId)),
      })),
    );
    observed.push(...roundResults);
  }

  expect(observed).toHaveLength(rounds * identities.length);
  observed.forEach(({ userId, status, addresses }) => {
    // Each response reflects ONLY its own request's identity — no bleed. The
    // subject's own user connection is present, and no OTHER subject's is.
    expect(status).toBe(200);
    expect(addresses.some((a) => a.includes(connectionNameForUser(userId)))).toBe(true);
    const otherUsers = identities.map((id) => id.userId).filter((u) => u !== userId);
    for (const other of otherUsers) {
      expect(addresses.some((a) => a.includes(connectionNameForUser(other)))).toBe(false);
    }
  });
});

test("a request with no identity is rejected", async () => {
  const res = await handler(new Request("http://localhost/api/connections"));
  // The header provider returns no principal -> the middleware's unauthenticated
  // path fires.
  expect(res.status).toBeGreaterThanOrEqual(400);
});
