import {
  Context,
  Data,
  Effect,
  Layer,
  Predicate,
  Ref,
  Schedule,
  Schema as EffectSchema,
  Scope,
} from "effect";
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import type { GraphQLSchema } from "graphql";
import {
  createSchema,
  createYoga,
  type GraphQLParams,
  type YogaInitialContext,
} from "graphql-yoga";
import { OAuthTestServer, serveTestHttpApp } from "@executor-js/sdk/testing";

const GraphqlRequestPayload = EffectSchema.Struct({
  query: EffectSchema.optional(EffectSchema.String),
  variables: EffectSchema.optional(EffectSchema.Record(EffectSchema.String, EffectSchema.Unknown)),
  operationName: EffectSchema.optional(EffectSchema.NullOr(EffectSchema.String)),
});

type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type;

export interface GraphqlTestRequest {
  readonly url: string;
  readonly method: string;
  readonly path: string;
  readonly headers: Readonly<Record<string, string>>;
  readonly payload: GraphqlRequestPayload;
}

export interface GraphqlTestContext {
  readonly request: GraphqlTestRequest;
}

export interface GraphqlTestServerOptions {
  readonly schema: GraphQLSchema;
  readonly path?: string;
  readonly auth?: {
    readonly validateAuthorization: (authorization: string | null) => Effect.Effect<boolean>;
    readonly wwwAuthenticate?: string;
  };
}

export interface GraphqlTestServerShape {
  readonly endpoint: string;
  readonly schema: GraphQLSchema;
  readonly requests: Effect.Effect<readonly GraphqlTestRequest[]>;
  readonly clearRequests: Effect.Effect<void>;
}

export class GraphqlTestRecorderTimeoutError extends Data.TaggedError(
  "GraphqlTestRecorderTimeoutError",
)<{
  readonly message: string;
}> {}

/** Poll the request recorder until `predicate` matches. Yoga captures requests
 *  asynchronously (`captureRequest` runs via `Effect.runPromise` in the handler
 *  context), so connect/invoke can resolve before the ref updates under load. */
export const waitForRecordedRequests = (
  requests: Effect.Effect<readonly GraphqlTestRequest[]>,
  predicate: (requests: readonly GraphqlTestRequest[]) => boolean,
  message = "timed out waiting for matching recorded GraphQL requests",
): Effect.Effect<readonly GraphqlTestRequest[], GraphqlTestRecorderTimeoutError> =>
  requests.pipe(
    Effect.filterOrFail(
      (all) => predicate(all),
      () => new GraphqlTestRecorderTimeoutError({ message }),
    ),
    Effect.retry(Schedule.both(Schedule.spaced("50 millis"), Schedule.recurs(100))),
  );

class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
  readonly address: unknown;
}> {}

class GraphqlTestServerHandlerError extends Data.TaggedError("GraphqlTestServerHandlerError")<{
  readonly cause: unknown;
}> {}

const headersFromRequest = (headers: Headers): Readonly<Record<string, string>> =>
  Object.fromEntries(headers.entries());

const payloadFromParams = (params: GraphQLParams): GraphqlRequestPayload => ({
  query: params.query,
  variables:
    typeof params.variables === "object" && params.variables !== null
      ? params.variables
      : undefined,
  operationName: params.operationName ?? null,
});

const captureRequest = (
  initial: YogaInitialContext,
  requests: Ref.Ref<readonly GraphqlTestRequest[]>,
) => {
  const url = new URL(initial.request.url);
  const captured: GraphqlTestRequest = {
    url: initial.request.url,
    method: initial.request.method,
    path: url.pathname,
    headers: headersFromRequest(initial.request.headers),
    payload: payloadFromParams(initial.params),
  };
  return Effect.runPromise(
    Ref.update(requests, (all) => [...all, captured]).pipe(Effect.as(captured)),
  );
};

export const serveGraphqlTestServer = (
  options: GraphqlTestServerOptions,
): Effect.Effect<
  GraphqlTestServerShape,
  GraphqlTestServerAddressError | GraphqlTestServerHandlerError,
  Scope.Scope
> =>
  Effect.gen(function* () {
    const requests = yield* Ref.make<readonly GraphqlTestRequest[]>([]);
    const path = options.path ?? "/graphql";

    const yoga = createYoga<Record<string, never>, GraphqlTestContext>({
      schema: options.schema,
      graphqlEndpoint: path,
      graphiql: false,
      landingPage: false,
      logging: false,
      maskedErrors: false,
      context: (initial) =>
        captureRequest(initial, requests).then((request) => ({
          request,
        })),
    });

    const server = yield* serveTestHttpApp((request) =>
      Effect.gen(function* () {
        if (options.auth) {
          const accepted = yield* options.auth.validateAuthorization(
            request.headers.authorization ?? null,
          );
          if (!accepted) {
            const responseOptions = options.auth.wwwAuthenticate
              ? {
                  status: 401,
                  headers: { "www-authenticate": options.auth.wwwAuthenticate },
                }
              : { status: 401 };
            return HttpServerResponse.jsonUnsafe(
              { errors: [{ message: "Unauthorized" }] },
              responseOptions,
            );
          }
        }
        const webRequest = yield* HttpServerRequest.toWeb(request);
        const response = yield* Effect.promise(() => Promise.resolve(yoga.handle(webRequest, {})));
        return HttpServerResponse.fromWeb(response);
      }).pipe(
        Effect.catch(() =>
          Effect.succeed(
            HttpServerResponse.text("GraphQL test server failed", {
              status: 500,
              contentType: "text/plain",
            }),
          ),
        ),
      ),
    ).pipe(
      Effect.mapError((error) =>
        Predicate.isTagged(error, "TestHttpServerAddressError")
          ? new GraphqlTestServerAddressError({ address: error.address })
          : new GraphqlTestServerHandlerError({ cause: error.cause }),
      ),
    );

    return {
      endpoint: server.url(path),
      schema: options.schema,
      requests: Ref.get(requests),
      clearRequests: Ref.set(requests, []),
    };
  });

export const serveGraphqlFailureTestServer = (options: {
  readonly status: number;
  readonly body: string;
  readonly contentType?: string;
  readonly path?: string;
}) =>
  serveTestHttpApp(() =>
    Effect.succeed(
      HttpServerResponse.text(options.body, {
        status: options.status,
        contentType: options.contentType ?? "text/plain",
      }),
    ),
  ).pipe(
    Effect.map((server) => ({
      endpoint: server.url(options.path ?? "/graphql"),
      httpClientLayer: server.httpClientLayer,
    })),
  );

export class GraphqlTestServer extends Context.Service<GraphqlTestServer, GraphqlTestServerShape>()(
  "@executor-js/plugin-graphql/testing/GraphqlTestServer",
) {
  static readonly layer = (options: GraphqlTestServerOptions) =>
    Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options));

  static readonly layerWithOAuth = (options: Omit<GraphqlTestServerOptions, "auth">) =>
    Layer.effect(
      GraphqlTestServer,
      Effect.gen(function* () {
        const oauth = yield* OAuthTestServer;
        return yield* serveGraphqlTestServer({
          ...options,
          auth: {
            validateAuthorization: oauth.acceptsAuthorizationHeader,
            wwwAuthenticate: 'Bearer error="invalid_token"',
          },
        });
      }),
    );
}

const stringArgument = (
  args: Readonly<Record<string, unknown>>,
  key: string,
  fallback: string,
): string => {
  const value = args[key];
  return typeof value === "string" ? value : fallback;
};

export const makeGreetingGraphqlSchema = (
  options: { readonly includeMutation?: boolean } = {},
): GraphQLSchema => {
  const includeMutation = options.includeMutation ?? true;
  return createSchema<GraphqlTestContext>({
    typeDefs: /* GraphQL */ `
      type Query {
        hello(name: String): String
      }

      ${
        includeMutation
          ? /* GraphQL */ `
              type Mutation {
                setGreeting(message: String!): String
              }
            `
          : ""
      }
    `,
    resolvers: {
      Query: {
        hello: (_source: unknown, args: Readonly<Record<string, unknown>>) =>
          `Hello ${stringArgument(args, "name", "world")}`,
      },
      ...(includeMutation
        ? {
            Mutation: {
              setGreeting: (_source: unknown, args: Readonly<Record<string, unknown>>) =>
                stringArgument(args, "message", ""),
            },
          }
        : {}),
    },
  });
};

// A small GitLab-shaped schema that reproduces the two failure modes from issue
// #1146 when the plugin auto-generates selection sets against it:
//   1. `metadata.featureFlags(names: [String!]!)` has a REQUIRED nested argument
//      the generator cannot fill.
//   2. `currentUser` nests connections deep enough that composite leaf fields
//      (`author`, `assignees`) sit past the recursion depth cap, so a naive
//      generator emits them with no sub-selection.
// graphql-yoga validates with graphql-js, exactly like the @emulators/gitlab
// surface, so a generated operation that validates clean here is valid GraphQL.
export const makeGitlab1146Schema = (): GraphQLSchema =>
  createSchema<GraphqlTestContext>({
    typeDefs: /* GraphQL */ `
      type Query {
        metadata: Metadata
        currentUser: User
      }

      type Metadata {
        version: String
        revision: String
        enterprise: Boolean
        featureFlags(names: [String!]!): [FeatureFlag!]!
        kas: Kas
      }

      type FeatureFlag {
        name: String
        enabled: Boolean
      }

      type Kas {
        enabled: Boolean
        externalUrl: String
      }

      type User {
        active: Boolean
        admin: Boolean
        mergeRequests: MergeRequestConnection
      }

      type MergeRequestConnection {
        count: Int
        nodes: [MergeRequest!]
        pageInfo: PageInfo
      }

      type PageInfo {
        hasNextPage: Boolean
        endCursor: String
      }

      type MergeRequest {
        id: ID
        title: String
        author: Author
        assignees: AssigneeConnection
      }

      type Author {
        name: String
        username: String
      }

      type AssigneeConnection {
        count: Int
        nodes: [Author!]
      }
    `,
  });

export const TestLayers = {
  greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }),
  greetingWithOAuth: () =>
    GraphqlTestServer.layerWithOAuth({ schema: makeGreetingGraphqlSchema() }),
};
