/**
 * Rule factory functions for `@arcjet/guard`.
 *
 * Each exported function creates a concrete `RuleWithConfig*` type.
 * Calling the returned value with input produces the corresponding
 * `RuleWithInput*` ready for `.guard()`.
 *
 * @packageDocumentation
 */

import { nativeEntityTypes } from "./convert.ts";
import { symbolArcjetInternal } from "./symbol.ts";
import type {
  ArcjetMetadata,
  Decision,
  InternalDecision,
  InternalResult,
  RuleResult,
  RuleResultCustom,
  RuleResultError,
  RuleResultFixedWindow,
  RuleResultPromptInjection,
  RuleResultModerateContent,
  RuleResultSensitiveInfo,
  RuleResultSlidingWindow,
  RuleResultTokenBucket,
  TokenBucketConfig,
  TokenBucketInput,
  FixedWindowConfig,
  FixedWindowInput,
  SlidingWindowConfig,
  SlidingWindowInput,
  DetectPromptInjectionConfig,
  DetectPromptInjectionInput,
  ExperimentalModerateContentConfig,
  ExperimentalModerateContentInput,
  LocalDetectSensitiveInfoConfig,
  LocalDetectSensitiveInfoInput,
  SensitiveInfoEntityType,
  LocalCustomConfig,
  LocalCustomInput,
  RuleWithConfigTokenBucket,
  RuleWithInputTokenBucket,
  RuleWithConfigFixedWindow,
  RuleWithInputFixedWindow,
  RuleWithConfigSlidingWindow,
  RuleWithInputSlidingWindow,
  RuleWithConfigPromptInjection,
  RuleWithInputPromptInjection,
  RuleWithConfigModerateContent,
  RuleWithInputModerateContent,
  RuleWithConfigSensitiveInfo,
  RuleWithInputSensitiveInfo,
  RuleWithConfigCustom,
  RuleWithInputCustom,
  CustomEvaluateFn,
  CustomEvaluateResult,
} from "./types.ts";

/** Generate a random opaque identifier. */
function randomId(): string {
  return crypto.randomUUID();
}

/** Type guard for decisions carrying internal correlation data. */
function isInternalDecision(d: Decision): d is InternalDecision {
  return symbolArcjetInternal in d;
}

/** Extract internal results from a decision (empty array if absent). */
function getInternalResults(decision: Decision): readonly InternalResult[] {
  return isInternalDecision(decision) ? decision[symbolArcjetInternal].results : [];
}

/**
 * Find a single non-error result matching the given correlation IDs.
 *
 * Errored results ({@link RuleResultError}) are excluded — they are surfaced
 * only via `errorResult()`. This is the error/non-error split: a non-error
 * accessor must never return an errored result up-cast to the rule's own type.
 */
function findResult<T extends RuleResult>(
  decision: Decision,
  configId: string,
  inputId: string,
): T | null {
  const match = getInternalResults(decision).find(
    (r) =>
      r[symbolArcjetInternal].configId === configId &&
      r[symbolArcjetInternal].inputId === inputId &&
      r.type !== "RULE_ERROR",
  );
  if (!match) return null;
  const result: unknown = match;
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- non-error result matched by correlation IDs; RULE_ERROR excluded above
  return result as T;
}

/** Find all non-error results for a given configId. */
function findResults<T extends RuleResult>(decision: Decision, configId: string): T[] {
  return getInternalResults(decision)
    .filter((r) => r[symbolArcjetInternal].configId === configId && r.type !== "RULE_ERROR")
    .map((r): T => {
      const result: unknown = r;
      // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- non-error results matched by configId; RULE_ERROR excluded above
      return result as T;
    });
}

/** Find the first denied result for a given configId. */
function findDeniedResult<T extends RuleResult>(decision: Decision, configId: string): T | null {
  return findResults<T>(decision, configId).find((r) => r.conclusion === "DENY") ?? null;
}

/**
 * Find the errored result for one specific submission, matched by both
 * correlation IDs. Returns only {@link RuleResultError} — never a non-error
 * result.
 */
function findErrorResult(
  decision: Decision,
  configId: string,
  inputId: string,
): RuleResultError | null {
  const match = getInternalResults(decision).find(
    (r) =>
      r[symbolArcjetInternal].configId === configId &&
      r[symbolArcjetInternal].inputId === inputId &&
      r.type === "RULE_ERROR",
  );
  if (!match) return null;
  const result: unknown = match;
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- matched on type === "RULE_ERROR" above
  return result as RuleResultError;
}

/**
 * Find the first errored result for a given configId. Mirrors
 * {@link findDeniedResult}: if multiple invocations of the same rule errored,
 * returns one arbitrarily. There is deliberately no `errorResults()` plural —
 * retrieve per-submission via the bound input's `errorResult()`.
 */
function findErrorResultByConfig(decision: Decision, configId: string): RuleResultError | null {
  const match = getInternalResults(decision).find(
    (r) => r[symbolArcjetInternal].configId === configId && r.type === "RULE_ERROR",
  );
  if (!match) return null;
  const result: unknown = match;
  // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- matched on type === "RULE_ERROR" above
  return result as RuleResultError;
}

/**
 * Create a token bucket rate limiting rule.
 *
 * Use this when requests have variable cost — for example, an LLM
 * endpoint where each call consumes a different number of tokens.
 * The bucket refills at a steady rate and allows bursts up to
 * `maxTokens`, so users can spend tokens quickly but are throttled
 * once the bucket drains.
 *
 * Returns a configured rule that can be called with per-request input
 * (key + optional requested token count) to produce a `RuleWithInput`
 * ready for `.guard()`.
 *
 * @example
 * ```ts
 * const limit = tokenBucket({ bucket: "user-tokens", refillRate: 10, intervalSeconds: 60, maxTokens: 100 });
 * const decision = await arcjet.guard({
 *   label: "api.chat",
 *   rules: [limit({ key: userId })],
 * });
 * ```
 */
export function tokenBucket(config: TokenBucketConfig): RuleWithConfigTokenBucket {
  const configId = randomId();

  const rule = Object.assign(
    (input: TokenBucketInput): RuleWithInputTokenBucket => {
      const inputId = randomId();
      return {
        type: "TOKEN_BUCKET" as const,
        config,
        input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultTokenBucket | null {
          return findResult<RuleResultTokenBucket>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultTokenBucket | null {
          const r = findResult<RuleResultTokenBucket>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultTokenBucket[] {
          const r = findResult<RuleResultTokenBucket>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "TOKEN_BUCKET" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultTokenBucket[] {
        return findResults<RuleResultTokenBucket>(decision, configId);
      },
      result(decision: Decision): RuleResultTokenBucket | null {
        return findResults<RuleResultTokenBucket>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultTokenBucket | null {
        return findDeniedResult<RuleResultTokenBucket>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Create a fixed window rate limiting rule.
 *
 * Use this when you need a hard cap per time period — for example,
 * "100 requests per hour". The counter resets to zero at the end of
 * each window. Simple to reason about, but allows bursts at window
 * boundaries (a user could make 100 requests at 11:59 and 100 more
 * at 12:00). If that matters, use {@link slidingWindow} instead.
 *
 * Returns a configured rule that can be called with per-request input
 * (key + optional requested count) to produce a `RuleWithInput`
 * ready for `.guard()`.
 *
 * @example
 * ```ts
 * const limit = fixedWindow({ bucket: "page-views", maxRequests: 1000, windowSeconds: 3600 });
 * const decision = await arcjet.guard({
 *   label: "api.search",
 *   rules: [limit({ key: teamId })],
 * });
 * ```
 */
export function fixedWindow(config: FixedWindowConfig): RuleWithConfigFixedWindow {
  const configId = randomId();

  const rule = Object.assign(
    (input: FixedWindowInput): RuleWithInputFixedWindow => {
      const inputId = randomId();
      return {
        type: "FIXED_WINDOW" as const,
        config,
        input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultFixedWindow | null {
          return findResult<RuleResultFixedWindow>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultFixedWindow | null {
          const r = findResult<RuleResultFixedWindow>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultFixedWindow[] {
          const r = findResult<RuleResultFixedWindow>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "FIXED_WINDOW" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultFixedWindow[] {
        return findResults<RuleResultFixedWindow>(decision, configId);
      },
      result(decision: Decision): RuleResultFixedWindow | null {
        return findResults<RuleResultFixedWindow>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultFixedWindow | null {
        return findDeniedResult<RuleResultFixedWindow>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Create a sliding window rate limiting rule.
 *
 * Use this when you need smooth rate limiting without the burst-at-boundary
 * problem of fixed windows. The server interpolates between the previous
 * and current window, so "100 requests per hour" is enforced across
 * any rolling 60-minute span. Good default choice for API rate limits.
 *
 * Returns a configured rule that can be called with per-request input
 * (key + optional requested count) to produce a `RuleWithInput`
 * ready for `.guard()`.
 *
 * @example
 * ```ts
 * const limit = slidingWindow({ bucket: "event-writes", maxRequests: 500, intervalSeconds: 60 });
 * const decision = await arcjet.guard({
 *   label: "api.events",
 *   rules: [limit({ key: userId })],
 * });
 * ```
 */
export function slidingWindow(config: SlidingWindowConfig): RuleWithConfigSlidingWindow {
  const configId = randomId();

  const rule = Object.assign(
    (input: SlidingWindowInput): RuleWithInputSlidingWindow => {
      const inputId = randomId();
      return {
        type: "SLIDING_WINDOW" as const,
        config,
        input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultSlidingWindow | null {
          return findResult<RuleResultSlidingWindow>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultSlidingWindow | null {
          const r = findResult<RuleResultSlidingWindow>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultSlidingWindow[] {
          const r = findResult<RuleResultSlidingWindow>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "SLIDING_WINDOW" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultSlidingWindow[] {
        return findResults<RuleResultSlidingWindow>(decision, configId);
      },
      result(decision: Decision): RuleResultSlidingWindow | null {
        return findResults<RuleResultSlidingWindow>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultSlidingWindow | null {
        return findDeniedResult<RuleResultSlidingWindow>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Create a server-side prompt injection detection rule.
 *
 * Use this when your application passes user-supplied text to an LLM
 * and you want to block attempts to override system prompts or
 * extract hidden instructions. Also useful for scanning tool call
 * results that contain untrusted input — for example, a "fetch" tool
 * that loads a webpage which could embed injected instructions.
 *
 * Returns a configured rule that can be called with user-supplied text
 * to produce a `RuleWithInput` ready for `.guard()`. The text is sent
 * to the Arcjet Cloud API for analysis.
 *
 * @example
 * ```ts
 * const pi = detectPromptInjection();
 * const decision = await arcjet.guard({
 *   label: "tools.chat",
 *   rules: [pi(userMessage)],
 * });
 * ```
 */
export function detectPromptInjection(
  config: DetectPromptInjectionConfig = {},
): RuleWithConfigPromptInjection {
  const configId = randomId();

  const rule = Object.assign(
    (input: string | DetectPromptInjectionInput): RuleWithInputPromptInjection => {
      const inputId = randomId();
      return {
        type: "PROMPT_INJECTION" as const,
        config,
        input: typeof input === "string" ? { inputText: input } : input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultPromptInjection | null {
          return findResult<RuleResultPromptInjection>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultPromptInjection | null {
          const r = findResult<RuleResultPromptInjection>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultPromptInjection[] {
          const r = findResult<RuleResultPromptInjection>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "PROMPT_INJECTION" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultPromptInjection[] {
        return findResults<RuleResultPromptInjection>(decision, configId);
      },
      result(decision: Decision): RuleResultPromptInjection | null {
        return findResults<RuleResultPromptInjection>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultPromptInjection | null {
        return findDeniedResult<RuleResultPromptInjection>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Create a content moderation rule (experimental).
 *
 * Mirrors {@link detectPromptInjection}: returns a configured rule that can be
 * called with user-supplied text to produce a `RuleWithInput` ready for
 * `.guard()`. The text is sent to the Arcjet Cloud API for analysis.
 *
 * **Experimental.** The rule name and result shape may change. This
 * functionality may not be available yet, so while this rule is experimental
 * a call may simply return an error result. Errors are fail open, so the
 * conclusion stays `"ALLOW"` and `decision.hasFailedOpen()` reports `true`
 * (inspect the errored result with `decision.errorResults()`). Check the
 * latest version of this SDK to see whether the rule is now stable.
 *
 * Per-request metadata can be attached at call time and is merged with any
 * config-level metadata (call-time wins on key conflict).
 *
 * @example
 * ```ts
 * const moderate = experimental_moderateContent();
 * const decision = await arcjet.guard({
 *   label: "tools.chat",
 *   rules: [moderate(userMessage)],
 * });
 * ```
 *
 * @example
 * ```ts
 * // Attach per-request metadata for analytics/correlation.
 * const moderate = experimental_moderateContent({ metadata: { variant: "new" } });
 * const decision = await arcjet.guard({
 *   label: "tools.chat",
 *   rules: [moderate(userMessage, { metadata: { expectedResponse: "pass" } })],
 * });
 * ```
 */
export function experimental_moderateContent(
  config: ExperimentalModerateContentConfig = {},
): RuleWithConfigModerateContent {
  const configId = randomId();

  const rule = Object.assign(
    (input: string | ExperimentalModerateContentInput): RuleWithInputModerateContent => {
      const inputId = randomId();
      return {
        type: "MODERATE_CONTENT" as const,
        config,
        input: typeof input === "string" ? { inputText: input } : input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultModerateContent | null {
          return findResult<RuleResultModerateContent>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultModerateContent | null {
          const r = findResult<RuleResultModerateContent>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultModerateContent[] {
          const r = findResult<RuleResultModerateContent>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "MODERATE_CONTENT" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultModerateContent[] {
        return findResults<RuleResultModerateContent>(decision, configId);
      },
      result(decision: Decision): RuleResultModerateContent | null {
        return findResults<RuleResultModerateContent>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultModerateContent | null {
        return findDeniedResult<RuleResultModerateContent>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Throw if the config lists entity types the configured backend cannot detect.
 *
 * A configured {@link SensitiveInfoBackend} is trusted to detect whatever it
 * declares support for, so this only checks the default (bundled WASM) backend,
 * which detects `EMAIL`, `PHONE_NUMBER`, `IP_ADDRESS`, and `CREDIT_CARD_NUMBER`.
 * Listing any other {@link SensitiveInfoEntityType} without a `backend` that
 * supports it (such as `@arcjet/sensitive-info-rampart`) can never match, so we
 * surface it as a configuration error rather than silently doing nothing.
 */
function validateSensitiveInfoBackendSupport(config: LocalDetectSensitiveInfoConfig): void {
  // A configured backend handles its own supported entity types.
  if (config.backend !== undefined) {
    return;
  }

  const entities = config.deny ?? config.allow ?? [];
  const unsupported = [...new Set(entities)].filter(
    (entity): entity is SensitiveInfoEntityType => !nativeEntityTypes.has(entity),
  );
  if (unsupported.length === 0) {
    return;
  }

  const list = unsupported.map((entity) => `"${entity}"`).join(", ");
  const subject = unsupported.length === 1 ? "type is" : "types are";
  const object = unsupported.length === 1 ? "it" : "them";
  throw new Error(
    `\`localDetectSensitiveInfo\` config error: the ${list} ${subject} only detected ` +
      `when a \`backend\` that supports ${object} is configured (such as ` +
      `\`@arcjet/sensitive-info-rampart\`). The default backend only detects ` +
      `"EMAIL", "PHONE_NUMBER", "IP_ADDRESS", and "CREDIT_CARD_NUMBER".`,
  );
}

/**
 * Create a sensitive information detection rule.
 *
 * Use this to prevent PII (emails, phone numbers, credit card numbers)
 * from being sent to third-party services or stored in logs. The
 * detection runs locally via WASM — only a SHA-256 hash of the text
 * is transmitted to the Arcjet Cloud API, never the raw content.
 *
 * Use `allow` / `deny` in the config to control which entity types
 * trigger a denial (e.g. `{ deny: ["CREDIT_CARD_NUMBER", "PHONE_NUMBER"] }`).
 * Omitting both denies all detected entity types.
 *
 * Returns a configured rule that can be called with user-supplied text
 * to produce a `RuleWithInput` ready for `.guard()`.
 *
 * @example
 * ```ts
 * const si = localDetectSensitiveInfo({ deny: ["CREDIT_CARD_NUMBER"] });
 * const decision = await arcjet.guard({
 *   label: "tools.summary",
 *   rules: [si(userMessage)],
 * });
 * ```
 */
export function localDetectSensitiveInfo(
  config: LocalDetectSensitiveInfoConfig = {},
): RuleWithConfigSensitiveInfo {
  validateSensitiveInfoBackendSupport(config);

  const configId = randomId();

  const rule = Object.assign(
    (input: string | LocalDetectSensitiveInfoInput): RuleWithInputSensitiveInfo => {
      const inputId = randomId();
      return {
        type: "SENSITIVE_INFO" as const,
        config,
        input: typeof input === "string" ? { inputText: input } : input,
        [symbolArcjetInternal]: { configId, inputId },
        result(decision: Decision): RuleResultSensitiveInfo | null {
          return findResult<RuleResultSensitiveInfo>(decision, configId, inputId);
        },
        deniedResult(decision: Decision): RuleResultSensitiveInfo | null {
          const r = findResult<RuleResultSensitiveInfo>(decision, configId, inputId);
          return r !== null && r.conclusion === "DENY" ? r : null;
        },
        results(decision: Decision): RuleResultSensitiveInfo[] {
          const r = findResult<RuleResultSensitiveInfo>(decision, configId, inputId);
          return r === null ? [] : [r];
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResult(decision, configId, inputId);
        },
      };
    },
    {
      type: "SENSITIVE_INFO" as const,
      config,
      [symbolArcjetInternal]: { configId },
      results(decision: Decision): RuleResultSensitiveInfo[] {
        return findResults<RuleResultSensitiveInfo>(decision, configId);
      },
      result(decision: Decision): RuleResultSensitiveInfo | null {
        return findResults<RuleResultSensitiveInfo>(decision, configId)[0] ?? null;
      },
      deniedResult(decision: Decision): RuleResultSensitiveInfo | null {
        return findDeniedResult<RuleResultSensitiveInfo>(decision, configId);
      },
      errorResult(decision: Decision): RuleResultError | null {
        return findErrorResultByConfig(decision, configId);
      },
    },
  );

  return rule;
}

/**
 * Define a typed custom rule.
 *
 * Returns a factory function that creates `RuleWithConfigCustom<TData>`
 * instances. The config, input, and result data types are preserved
 * through the entire chain — from rule creation to `.result()` on
 * the decision.
 *
 * @typeParam TConfig - Shape of the config data (string values).
 * @typeParam TInput  - Shape of the per-request input data (string values).
 * @typeParam TData   - Shape of the result data returned by `evaluate`.
 *
 * @example
 * ```ts
 * const topicBlock = defineCustomRule<
 *   { blockedTopic: string },
 *   { topic: string },
 *   { matched: string }
 * >({
 *   evaluate: (config, input) => {
 *     if (input.topic === config.blockedTopic) {
 *       return { conclusion: "DENY", data: { matched: input.topic } };
 *     }
 *     return { conclusion: "ALLOW" };
 *   },
 * });
 *
 * // Create the rule config at module scope
 * const rule = topicBlock({ data: { blockedTopic: "politics" } });
 *
 * // Per request
 * const decision = await arcjet.guard({
 *   rules: [rule({ data: { topic: userTopic } })],
 * });
 * const r = rule.result(decision);
 * if (r) {
 *   r.data.matched; // string — fully typed
 * }
 * ```
 */
export function defineCustomRule<
  TConfig extends Record<string, string>,
  TInput extends Record<string, string>,
  TData extends Record<string, string> = Record<string, string>,
>(options: {
  evaluate: (
    config: Readonly<TConfig>,
    input: Readonly<TInput>,
    options: { signal?: AbortSignal },
  ) => CustomEvaluateResult<TData> | Promise<CustomEvaluateResult<TData>>;
}): (config: {
  data: TConfig;
  mode?: "LIVE" | "DRY_RUN";
  label?: string;
  metadata?: ArcjetMetadata;
}) => RuleWithConfigCustom<TData, TInput> {
  return (config) => {
    const { data, mode, label, metadata } = config;
    const configId = randomId();
    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing generic evaluate to untyped internal form
    const evaluate: CustomEvaluateFn = options.evaluate as unknown as CustomEvaluateFn;
    const configObj = {
      ...(mode === undefined ? {} : { mode }),
      ...(label === undefined ? {} : { label }),
      ...(metadata === undefined ? {} : { metadata }),
      data: data as Record<string, string>,
      evaluate,
    } satisfies LocalCustomConfig;

    const rule = Object.assign(
      (input: { data: TInput; metadata?: ArcjetMetadata }): RuleWithInputCustom<TData> => {
        const { data: inputData, metadata: inputMetadata } = input;
        const inputId = randomId();
        const inputObj: LocalCustomInput = {
          data: inputData,
          ...(inputMetadata === undefined ? {} : { metadata: inputMetadata }),
        };
        return {
          type: "CUSTOM" as const,
          config: configObj,
          input: inputObj,
          evaluate,
          [symbolArcjetInternal]: { configId, inputId },
          result(decision: Decision): RuleResultCustom<TData> | null {
            return findResult<RuleResultCustom<TData>>(decision, configId, inputId);
          },
          deniedResult(decision: Decision): RuleResultCustom<TData> | null {
            const r = findResult<RuleResultCustom<TData>>(decision, configId, inputId);
            return r !== null && r.conclusion === "DENY" ? r : null;
          },
          results(decision: Decision): RuleResultCustom<TData>[] {
            const r = findResult<RuleResultCustom<TData>>(decision, configId, inputId);
            return r === null ? [] : [r];
          },
          errorResult(decision: Decision): RuleResultError | null {
            return findErrorResult(decision, configId, inputId);
          },
        };
      },
      {
        type: "CUSTOM" as const,
        config: configObj,
        [symbolArcjetInternal]: { configId },
        results(decision: Decision): RuleResultCustom<TData>[] {
          return findResults<RuleResultCustom<TData>>(decision, configId);
        },
        result(decision: Decision): RuleResultCustom<TData> | null {
          return findResults<RuleResultCustom<TData>>(decision, configId)[0] ?? null;
        },
        deniedResult(decision: Decision): RuleResultCustom<TData> | null {
          return findDeniedResult<RuleResultCustom<TData>>(decision, configId);
        },
        errorResult(decision: Decision): RuleResultError | null {
          return findErrorResultByConfig(decision, configId);
        },
      },
    );

    return rule;
  };
}
