// ---------------------------------------------------------------------------
// WorkOS AuthKit — Effect-native sealed session management
// ---------------------------------------------------------------------------

import { env } from "cloudflare:workers";
import { Context, Data, Effect, Layer, Option, Schema } from "effect";
import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
import { parseCookie } from "./cookies";
import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";

const COOKIE_NAME = "wos-session";
const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";

type RawWorkOS = WorkOS & {
  readonly get: (
    path: string,
    options?: { readonly query?: Record<string, unknown> },
  ) => Promise<{
    readonly data: unknown;
  }>;
  readonly post: (
    path: string,
    entity: unknown,
    options?: { readonly idempotencyKey?: string },
  ) => Promise<{ readonly data: unknown }>;
};

type WorkOSListMetadata = {
  readonly before?: string | null;
  readonly after?: string | null;
};

type WorkOSAutoPaginatable<Resource> = {
  readonly object: "list";
  readonly data: Resource[];
  readonly listMetadata: WorkOSListMetadata;
  readonly autoPagination: () => Promise<Resource[]>;
};

export type WorkOSCollectedList<Resource> = {
  readonly object: "list";
  readonly data: Resource[];
  readonly listMetadata: {
    readonly before: string | null;
    readonly after: string | null;
  };
};

const RawWorkOSListMetadata = Schema.Struct({
  before: Schema.optional(Schema.NullOr(Schema.String)),
  after: Schema.optional(Schema.NullOr(Schema.String)),
});

const RawWorkOSListResponse = Schema.Struct({
  data: Schema.Array(Schema.Unknown),
  listMetadata: Schema.optional(RawWorkOSListMetadata),
  list_metadata: Schema.optional(RawWorkOSListMetadata),
});

const decodeRawWorkOSListResponse = Schema.decodeUnknownOption(RawWorkOSListResponse);

const completedListMetadata = {
  before: null,
  after: null,
} as const;

const nextCursorFromRawList = (response: typeof RawWorkOSListResponse.Type): string | null =>
  response.listMetadata?.after ?? response.list_metadata?.after ?? null;

export const collectWorkOSList = async <Resource>(
  response: WorkOSAutoPaginatable<Resource>,
): Promise<WorkOSCollectedList<Resource>> => {
  const data = response.listMetadata.after ? await response.autoPagination() : response.data;
  return {
    object: "list",
    data,
    listMetadata: completedListMetadata,
  };
};

export const collectRawWorkOSList = async (
  loadPage: (after?: string) => Promise<unknown>,
): Promise<WorkOSCollectedList<unknown>> => {
  const first = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage()));
  if (!first) {
    return {
      object: "list",
      data: [],
      listMetadata: completedListMetadata,
    };
  }

  const data = [...first.data];
  let after = nextCursorFromRawList(first);

  while (after) {
    const next = Option.getOrNull(decodeRawWorkOSListResponse(await loadPage(after)));
    if (!next) break;
    data.push(...next.data);
    after = nextCursorFromRawList(next);
  }

  return {
    object: "list",
    data,
    listMetadata: completedListMetadata,
  };
};

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

/**
 * Optional base-URL override for the WorkOS API (`WORKOS_API_URL`) — points
 * the REAL SDK at a WorkOS emulator in tests/dev. Unset in production, where
 * the SDK uses api.workos.com. Sealed-session crypto, JWKS verification, and
 * every endpoint follow this host, so the whole auth stack runs against the
 * emulator with zero code substitution.
 */
export const workosApiUrlOptions = (
  url: string | undefined,
): { apiHostname?: string; port?: number; https?: boolean } => {
  if (!url) return {};
  const parsed = new URL(url);
  return {
    apiHostname: parsed.hostname,
    ...(parsed.port ? { port: Number(parsed.port) } : {}),
    https: parsed.protocol === "https:",
  };
};

// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------

const make = Effect.gen(function* () {
  const apiKey = env.WORKOS_API_KEY;
  const clientId = env.WORKOS_CLIENT_ID;
  const cookiePassword = env.WORKOS_COOKIE_PASSWORD;

  if (!cookiePassword || cookiePassword.length < 32) {
    return yield* new WorkOSConfigurationError({
      message: INVALID_COOKIE_PASSWORD_MESSAGE,
    });
  }

  const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });

  const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
    withServiceLogging(
      "workos",
      () => new WorkOSError(),
      tryPromiseService(() => fn(workos)),
    );

  const authenticateSealedSession = (sessionData: string) =>
    Effect.gen(function* () {
      if (!sessionData) return null;

      const session = workos.userManagement.loadSealedSession({
        sessionData,
        cookiePassword,
      });

      const result = yield* use(() => session.authenticate());

      if (result.authenticated) {
        return {
          userId: result.user.id,
          email: result.user.email,
          firstName: result.user.firstName,
          lastName: result.user.lastName,
          avatarUrl: result.user.profilePictureUrl,
          organizationId: result.organizationId,
          sessionId: result.sessionId,
          refreshedSession: undefined as string | undefined,
        };
      }

      if (result.reason === "no_session_cookie_provided") return null;

      // Try refreshing
      const refreshed = yield* use(() => session.refresh()).pipe(
        Effect.orElseSucceed(() => ({ authenticated: false as const })),
      );

      if (!refreshed.authenticated || !("sealedSession" in refreshed) || !refreshed.sealedSession)
        return null;

      return {
        userId: refreshed.user.id,
        email: refreshed.user.email,
        firstName: refreshed.user.firstName,
        lastName: refreshed.user.lastName,
        avatarUrl: refreshed.user.profilePictureUrl,
        organizationId: refreshed.organizationId,
        sessionId: refreshed.sessionId,
        refreshedSession: refreshed.sealedSession,
      };
    });

  return {
    getAuthorizationUrl: (redirectUri: string, state?: string) =>
      workos.userManagement.getAuthorizationUrl({
        provider: "authkit",
        redirectUri,
        clientId,
        ...(state ? { state } : {}),
      }),

    authenticateWithCode: (code: string) =>
      use((wos) =>
        wos.userManagement.authenticateWithCode({
          code,
          clientId,
          session: { sealSession: true, cookiePassword },
        }),
      ),

    /** Create a new organization in WorkOS. */
    createOrganization: (name: string) =>
      use((wos) => wos.organizations.createOrganization({ name })),

    /** Add a user to an organization. */
    createMembership: (organizationId: string, userId: string, roleSlug?: string) =>
      use((wos) =>
        wos.userManagement.createOrganizationMembership({
          organizationId,
          userId,
          ...(roleSlug ? { roleSlug } : {}),
        }),
      ),

    /** List organization memberships for a user. */
    listUserMemberships: (userId: string) =>
      use(async (wos) =>
        collectWorkOSList(
          await wos.userManagement.listOrganizationMemberships({
            userId,
            statuses: ["active", "pending"],
          }),
        ),
      ),

    /**
     * Refresh a sealed session, optionally switching to a new organization.
     * Returns the new sealed session string or null if refresh failed.
     */
    refreshSession: (sessionData: string, organizationId?: string) =>
      Effect.gen(function* () {
        const session = workos.userManagement.loadSealedSession({
          sessionData,
          cookiePassword,
        });
        const refreshed = yield* use(() =>
          session.refresh(organizationId ? { organizationId } : undefined),
        );
        if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null;
        return refreshed.sealedSession ?? null;
      }),

    /**
     * Authenticate a sealed session string. Returns the user info plus
     * any refreshed session that needs to be set on the response.
     * Returns null if the session is missing or invalid.
     */
    authenticateSealedSession,

    /** Authenticate from a Request — convenience wrapper around `authenticateSealedSession`. */
    authenticateRequest: (request: Request) =>
      Effect.gen(function* () {
        const sessionData = parseCookie(request.headers.get("cookie"), COOKIE_NAME);
        if (!sessionData) return null;
        return yield* authenticateSealedSession(sessionData);
      }),

    /**
     * Validate an AuthKit API key. The SDK version installed here exposes
     * organization-owned key types, while WorkOS's API also returns user-owned
     * keys. Keep this boundary unknown and decode the precise app shape in
     * auth/api-keys.ts.
     */
    validateApiKey: (value: string) =>
      use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise<unknown>),

    listUserApiKeys: (userId: string, organizationId: string) =>
      use(async (wos) => {
        const raw = wos as RawWorkOS;
        return collectRawWorkOSList(async (after) => {
          const response = await raw.get(`/user_management/users/${userId}/api_keys`, {
            query: {
              organization_id: organizationId,
              limit: 100,
              ...(after ? { after } : {}),
            },
          });
          return response.data;
        });
      }),

    createUserApiKey: (params: { userId: string; organizationId: string; name: string }) =>
      use(async (wos) => {
        const raw = wos as RawWorkOS;
        const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, {
          name: params.name,
          organization_id: params.organizationId,
        });
        return response.data;
      }),

    deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)),

    /** List organization memberships with user details. */
    listOrgMembers: (organizationId: string) =>
      use(async (wos) =>
        collectWorkOSList(
          await wos.userManagement.listOrganizationMemberships({
            organizationId,
            statuses: ["active", "pending"],
          }),
        ),
      ),

    /** Get a user's membership in an organization. */
    getUserOrgMembership: (organizationId: string, userId: string) =>
      use(async (wos) => {
        const response = await wos.userManagement.listOrganizationMemberships({
          organizationId,
          userId,
          statuses: ["active", "pending"],
        });
        return response.data[0] ?? null;
      }),

    /** Get a user by ID. */
    getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),

    /** Send an organization invitation. */
    sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
      use((wos) =>
        wos.userManagement.sendInvitation({
          email: params.email,
          organizationId: params.organizationId,
          roleSlug: params.roleSlug,
        }),
      ),

    /**
     * Pending invitations for an organization (i.e. not yet accepted, revoked,
     * or expired). The SDK's `state` filter doesn't reliably narrow at the
     * API level, so we filter after.
     */
    listPendingInvitations: (organizationId: string) =>
      use(async (wos) =>
        collectWorkOSList(
          await wos.userManagement.listInvitations({
            organizationId,
          }),
        ),
      ).pipe(
        Effect.map((response) => ({
          ...response,
          data: response.data.filter((i) => i.state === "pending"),
        })),
      ),

    /** List invitations for an email address (across all orgs). */
    listInvitationsByEmail: (email: string) =>
      use(async (wos) =>
        collectWorkOSList(
          await wos.userManagement.listInvitations({
            email,
          }),
        ),
      ),

    /** Accept an invitation; returns the (now accepted) invitation. */
    acceptInvitation: (invitationId: string) =>
      use((wos) => wos.userManagement.acceptInvitation(invitationId)),

    /** Remove an organization membership. */
    deleteOrgMembership: (membershipId: string) =>
      use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),

    /** Get the role for a membership. */
    getOrgMembership: (membershipId: string) =>
      use((wos) => wos.userManagement.getOrganizationMembership(membershipId)),

    /** Update a membership's role. */
    updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>
      use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })),

    /** List available roles for an organization. */
    listOrgRoles: (organizationId: string) =>
      use((wos) => wos.organizations.listOrganizationRoles({ organizationId })),

    /** Get an organization (includes domains). */
    getOrganization: (organizationId: string) =>
      use((wos) => wos.organizations.getOrganization(organizationId)),

    /** Update an organization. */
    updateOrganization: (organizationId: string, name: string) =>
      use((wos) => wos.organizations.updateOrganization({ organization: organizationId, name })),

    /** Generate an Admin Portal link for domain verification. */
    generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) =>
      use((wos) =>
        wos.portal.generateLink({
          organization: organizationId,
          intent: GeneratePortalLinkIntent.DomainVerification,
          returnUrl,
        }),
      ),

    /** Get a domain by ID. */
    getOrganizationDomain: (domainId: string) =>
      use((wos) => wos.organizationDomains.get(domainId)),

    /** Delete a domain claim. */
    deleteOrganizationDomain: (domainId: string) =>
      use((wos) => wos.organizationDomains.delete(domainId)),
  };
});

export type WorkOSClientService = Effect.Success<typeof make>;

export class WorkOSClient extends Context.Service<WorkOSClient, WorkOSClientService>()(
  "@executor-js/cloud/WorkOSClient",
) {
  static Default = Layer.effect(this)(make).pipe(
    Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }),
  );
}

// The boot-scoped WorkOS client root — the one neutral service the stateless
// HTTP path AND the MCP session Durable Object both build on (each merges it
// with its own DB + telemetry layers). Named here, beside the client it aliases,
// so a focused backend consumer (the DO, the miniflare test worker) imports just
// this root rather than the whole `api/layers.ts` HTTP assembly. It names NO
// billing service, so the DO — which never bills — does not transitively require
// one. (This used to live in a standalone `api/core-shared-services.ts` purely to
// keep `@tanstack/react-start` out of the DO bundle; that coupling is gone now
// that `handlers.ts` no longer imports react-start, so the alias moved home.)
export const CoreSharedServices = WorkOSClient.Default;
