/**
 * Proto ↔ SDK conversion functions for `@arcjet/guard`.
 *
 * This module converts between the generated protobuf types and the
 * public SDK types defined in `./types.ts`. Callers should never need
 * to import this module directly.
 *
 * @packageDocumentation
 */

import { detectSensitiveInfo, type SensitiveInfoEntity } from "@arcjet/analyze";
import { create } from "@bufbuild/protobuf";

import { type ArcjetMetadata, type LocalWarning, encodeMetadata } from "./metadata.ts";
import {
  type Billing as ProtoBilling,
  type GuardRule,
  type GuardPolicyEvaluation,
  type GuardPolicyRuleResult as ProtoGuardPolicyRuleResult,
  type GuardRuleResult as ProtoGuardRuleResult,
  type GuardRuleSubmission,
  type GuardResponse as ProtoGuardResponse,
  type RuleLocalSensitiveInfo,
  type RuleLocalCustom,
  GuardRuleSubmissionSchema,
  GuardRuleSchema,
  RuleTokenBucketSchema,
  RuleFixedWindowSchema,
  RuleSlidingWindowSchema,
  RuleDetectPromptInjectionSchema,
  RuleModerateContentSchema,
  RuleLocalSensitiveInfoSchema,
  RuleLocalCustomSchema,
  ResultLocalSensitiveInfoSchema,
  GuardSensitiveInfoEntitySchema,
  ResultLocalCustomSchema,
  ResultErrorSchema,
  EntityListSchema,
  GuardConclusion,
  GuardPolicyStatus,
  GuardReason,
  GuardRuleMode,
  GuardRuleExecution,
  GuardStringMatchOperator,
} from "./proto/proto/decide/v2/decide_pb.js";
import { symbolArcjetInternal } from "./symbol.ts";
import type {
  Billing,
  Conclusion,
  Decision,
  InternalDecision,
  InternalResult,
  Reason,
  RuleResult,
  RuleResultError,
  PolicyEvaluation,
  PolicyRuleResult,
  RuleWithInput,
  SensitiveInfoBackend,
  SensitiveInfoEntityType,
  Warning,
} from "./types.ts";

/** Hash a string with SHA-256 and return the hex digest. */
async function sha256Hex(text: string): Promise<string> {
  const data = new TextEncoder().encode(text);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

/** No-op logger satisfying the `AnalyzeContext` contract. */
const noopLog = {
  debug(): void {},
  info(): void {},
  warn(): void {},
  error(): void {},
};

function billingFromProto(billing?: ProtoBilling): Billing | undefined {
  return billing ? { unit: billing.unit, count: billing.count } : undefined;
}

/** Minimal context for `@arcjet/analyze` — only `log` is used for sensitive info. */
const analyzeContext = { log: noopLog, characteristics: [] as string[] };

/**
 * The {@link SensitiveInfoEntityType} values the bundled WASM engine detects
 * natively. Every other declared type is only detected when a
 * {@link SensitiveInfoBackend} that supports it is configured; listing one
 * without such a backend is a configuration error (see `rules.ts`).
 *
 * Keep in sync with the native tags mapped in {@link stringToEntity}.
 *
 * @internal
 */
export const nativeEntityTypes: ReadonlySet<SensitiveInfoEntityType> = new Set([
  "EMAIL",
  "PHONE_NUMBER",
  "IP_ADDRESS",
  "CREDIT_CARD_NUMBER",
]);

/**
 * Convert an SDK entity type string to an analyze entity tag.
 *
 * The four types the WebAssembly engine understands map to their native tag;
 * every other {@link SensitiveInfoEntityType} (detected only by an alternative
 * {@link SensitiveInfoBackend}) is carried as `{ tag: "custom", val }`. This is
 * the inverse of {@link entityToString}.
 */
function stringToEntity(s: SensitiveInfoEntityType): SensitiveInfoEntity {
  // The four types the bundled WASM engine understands map to their native tag.
  // Listed explicitly (rather than via a `switch` default) so an unrecognized
  // string can only reach the `custom` branch below, never a WASM tag.
  if (s === "EMAIL") return { tag: "email" };
  if (s === "PHONE_NUMBER") return { tag: "phone-number" };
  if (s === "IP_ADDRESS") return { tag: "ip-address" };
  if (s === "CREDIT_CARD_NUMBER") return { tag: "credit-card-number" };
  // Every other declared type (detected only by an alternative backend such as
  // `@arcjet/sensitive-info-rampart`) is carried as `{ tag: "custom", val }`.
  return { tag: "custom", val: s };
}

/**
 * Every declared {@link SensitiveInfoEntityType}. Used to validate the plain
 * type strings a third-party {@link SensitiveInfoBackend} returns via a
 * `{ tag: "custom" }` entity, so a misbehaving backend cannot inject arbitrary
 * strings into `detectedEntityTypes` (and the union that downstream user code
 * switches on).
 *
 * Keep in sync with the {@link SensitiveInfoEntityType} union in `./types.ts`.
 */
const knownEntityTypes = new Set<string>([
  "EMAIL",
  "PHONE_NUMBER",
  "IP_ADDRESS",
  "CREDIT_CARD_NUMBER",
  "GIVEN_NAME",
  "SURNAME",
  "SSN",
  "URL",
  "TAX_ID",
  "BANK_ACCOUNT",
  "ROUTING_NUMBER",
  "GOVERNMENT_ID",
  "PASSPORT",
  "DRIVERS_LICENSE",
  "BUILDING_NUMBER",
  "STREET_NAME",
  "SECONDARY_ADDRESS",
  "CITY",
  "STATE",
  "ZIP_CODE",
]);

/** Type guard: whether `value` is a declared {@link SensitiveInfoEntityType}. */
export function isSensitiveInfoEntityType(value: string): value is SensitiveInfoEntityType {
  return knownEntityTypes.has(value);
}

/**
 * Convert an analyze entity tag back to an SDK entity type string, or
 * `undefined` when a backend returns a `custom` value outside the declared
 * {@link SensitiveInfoEntityType} union.
 */
function entityToString(e: SensitiveInfoEntity): SensitiveInfoEntityType | undefined {
  switch (e.tag) {
    case "email":
      return "EMAIL";
    case "phone-number":
      return "PHONE_NUMBER";
    case "ip-address":
      return "IP_ADDRESS";
    case "credit-card-number":
      return "CREDIT_CARD_NUMBER";
    case "custom":
      // Carried by an alternative backend (such as `@arcjet/sensitive-info-rampart`)
      // as the plain entity type string (e.g. `"GIVEN_NAME"`). Validate it
      // rather than trusting the backend; unknown values are dropped by the
      // caller.
      return isSensitiveInfoEntityType(e.val) ? e.val : undefined;
  }
}

/**
 * Default sensitive-info backend backed by the `@arcjet/analyze` WebAssembly
 * engine.
 *
 * Used when a `localDetectSensitiveInfo` rule does not configure a `backend`.
 * This preserves the existing behavior — local detection of email addresses,
 * phone numbers, IP addresses, and credit card numbers.
 */
const wasmSensitiveInfoBackend: SensitiveInfoBackend = {
  detect(context, value, entities, options) {
    return detectSensitiveInfo(
      // `detectSensitiveInfo` only reads `log`, but its context type also
      // requires `characteristics`, so we supply an empty list.
      { log: context.log, characteristics: [] },
      value,
      entities,
      options?.contextWindowSize ?? 1,
      options?.detect,
    );
  },
};

/**
 * Map a proto `GuardConclusion` to the SDK `Conclusion` string.
 * Unrecognized values default to `"ALLOW"` (fail-open).
 *
 * @internal
 */
export function conclusionFromProto(c: GuardConclusion): Conclusion {
  switch (c) {
    case GuardConclusion.ALLOW:
    case GuardConclusion.UNSPECIFIED:
      return "ALLOW";
    case GuardConclusion.DENY:
      return "DENY";
  }
}

/**
 * Map a proto result's oneof `case` to a broad SDK `Reason`.
 *
 * @internal
 */
export function reasonFromCase(caseName: string | undefined): Reason {
  switch (caseName) {
    case "tokenBucket":
    case "fixedWindow":
    case "slidingWindow":
      return "RATE_LIMIT";
    case "promptInjection":
      return "PROMPT_INJECTION";
    case "moderateContent":
      return "MODERATE_CONTENT";
    case "localSensitiveInfo":
      return "SENSITIVE_INFO";
    case "localCustom":
      return "CUSTOM";
    case "error":
      return "ERROR";
    case "notRun":
      return "NOT_RUN";
    case undefined:
      return "UNKNOWN";
    default:
      return "UNKNOWN";
  }
}

/**
 * Map a proto `GuardReason` enum to the SDK `Reason` string.
 *
 * Used for the decision-level reason provided by the server, which
 * follows a fixed priority (SensitiveInfo > RateLimit > PromptInjection > Custom).
 *
 * @internal
 */
export function reasonFromProto(r: GuardReason): Reason {
  switch (r) {
    case GuardReason.RATE_LIMIT:
      return "RATE_LIMIT";
    case GuardReason.PROMPT_INJECTION:
      return "PROMPT_INJECTION";
    case GuardReason.MODERATE_CONTENT:
      return "MODERATE_CONTENT";
    case GuardReason.SENSITIVE_INFO:
      return "SENSITIVE_INFO";
    case GuardReason.INPUT_CONSTRAINT:
      return "INPUT_CONSTRAINT";
    case GuardReason.CUSTOM:
      return "CUSTOM";
    case GuardReason.ERROR:
      return "ERROR";
    case GuardReason.NOT_RUN:
      return "NOT_RUN";
    case GuardReason.UNSPECIFIED:
      return "UNKNOWN";
    default:
      return "UNKNOWN";
  }
}

/**
 * Convert a single proto `GuardRuleResult` to the SDK `RuleResult`.
 *
 * Each result variant carries its own conclusion and typed fields.
 * `ResultError` results are mapped to `RuleResultError` with
 * `conclusion: "ALLOW"` (fail-open). `ResultNotRun` results are mapped
 * to `RuleResultNotRun` with `conclusion: "ALLOW"`.
 *
 * @internal
 */
export function resultFromProto(pr: ProtoGuardRuleResult): RuleResult {
  // Per-rule warnings have no data source yet — the Decide service does not
  // emit per-rule diagnostics. Always empty until then.
  const warnings: readonly Warning[] = [];
  switch (pr.result.case) {
    case undefined: {
      return {
        conclusion: "ALLOW" as const,
        reason: "UNKNOWN",
        type: "UNKNOWN",
        warnings,
      };
    }
    case "tokenBucket": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "RATE_LIMIT",
        type: "TOKEN_BUCKET",
        warnings,
        remainingTokens: v.remainingTokens,
        maxTokens: v.maxTokens,
        resetAtUnixSeconds: v.resetAtUnixSeconds,
        refillRate: v.refillRate,
        refillIntervalSeconds: v.refillIntervalSeconds,
      };
    }
    case "fixedWindow": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "RATE_LIMIT",
        type: "FIXED_WINDOW",
        warnings,
        remainingRequests: v.remainingRequests,
        maxRequests: v.maxRequests,
        resetAtUnixSeconds: v.resetAtUnixSeconds,
        windowSeconds: v.windowSeconds,
      };
    }
    case "slidingWindow": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "RATE_LIMIT",
        type: "SLIDING_WINDOW",
        warnings,
        remainingRequests: v.remainingRequests,
        maxRequests: v.maxRequests,
        resetAtUnixSeconds: v.resetAtUnixSeconds,
        intervalSeconds: v.intervalSeconds,
      };
    }
    case "promptInjection": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "PROMPT_INJECTION",
        type: "PROMPT_INJECTION",
        warnings,
        billing: billingFromProto(v.billing),
      };
    }
    case "moderateContent": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "MODERATE_CONTENT",
        type: "MODERATE_CONTENT",
        warnings,
        detected: v.detected,
        billing: billingFromProto(v.billing),
      };
    }
    case "localSensitiveInfo": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "SENSITIVE_INFO",
        type: "SENSITIVE_INFO",
        warnings,
        detectedEntityTypes: v.detectedEntityTypes,
      };
    }
    case "localCustom": {
      const v = pr.result.value;
      return {
        conclusion: conclusionFromProto(v.conclusion),
        reason: "CUSTOM",
        type: "CUSTOM",
        warnings,
        data: Object.fromEntries(Object.entries(v.data)),
      };
    }
    case "error": {
      const v = pr.result.value;
      return {
        conclusion: "ALLOW" as const,
        reason: "ERROR",
        type: "RULE_ERROR",
        warnings,
        message: v.message || "Unknown error",
        code: v.code || "UNKNOWN",
      };
    }
    case "notRun": {
      return {
        conclusion: "ALLOW" as const,
        reason: "NOT_RUN",
        type: "NOT_RUN",
        warnings,
      };
    }
    default: {
      return {
        conclusion: "ALLOW" as const,
        reason: "UNKNOWN",
        type: "UNKNOWN",
        warnings,
      };
    }
  }
}

function policyResultFromProto(pr: ProtoGuardPolicyRuleResult): PolicyRuleResult {
  const warnings: readonly Warning[] = [];
  let result: RuleResult;
  switch (pr.result.case) {
    case "promptInjection":
      result = {
        conclusion: conclusionFromProto(pr.result.value.conclusion),
        reason: "PROMPT_INJECTION",
        type: "PROMPT_INJECTION",
        warnings,
      };
      break;
    case "localSensitiveInfo":
      result = {
        conclusion: conclusionFromProto(pr.result.value.conclusion),
        reason: "SENSITIVE_INFO",
        type: "SENSITIVE_INFO",
        warnings,
        detectedEntityTypes: pr.result.value.detectedEntityTypes,
      };
      break;
    case "allowedStringValues":
    case "deniedStringValues":
    case "stringLength":
      result = {
        conclusion: conclusionFromProto(pr.result.value.conclusion),
        reason: "INPUT_CONSTRAINT",
        type:
          pr.result.case === "allowedStringValues"
            ? "ALLOWED_STRING_VALUES"
            : pr.result.case === "deniedStringValues"
              ? "DENIED_STRING_VALUES"
              : "STRING_LENGTH",
        ...(pr.result.case === "stringLength"
          ? {}
          : {
              matchOperator:
                pr.result.value.matchOperator === GuardStringMatchOperator.EMAIL_DOMAIN
                  ? ("EMAIL_DOMAIN" as const)
                  : pr.result.value.matchOperator === GuardStringMatchOperator.UNSPECIFIED ||
                      pr.result.value.matchOperator === GuardStringMatchOperator.EXACT
                    ? ("EXACT" as const)
                    : ("UNKNOWN" as const),
            }),
        warnings,
      };
      break;
    case "stringListMembership":
      result = {
        conclusion: conclusionFromProto(pr.result.value.conclusion),
        reason: "INPUT_CONSTRAINT",
        type: "STRING_LIST_MEMBERSHIP",
        matched: pr.result.value.matched,
        warnings,
      };
      break;
    case "error":
      result = {
        conclusion: "ALLOW",
        reason: "ERROR",
        type: "RULE_ERROR",
        warnings,
        message: pr.result.value.message || "Unknown error",
        code: pr.result.value.code || "UNKNOWN",
      };
      break;
    case "notRun":
      result = { conclusion: "ALLOW", reason: "NOT_RUN", type: "NOT_RUN", warnings };
      break;
    case undefined:
      result = { conclusion: "ALLOW", reason: "UNKNOWN", type: "UNKNOWN", warnings };
  }
  return {
    policyId: pr.policyId,
    policyRevision: pr.policyRevision,
    ruleId: pr.ruleId,
    mode: pr.mode === GuardRuleMode.DRY_RUN ? "DRY_RUN" : "LIVE",
    execution:
      pr.execution === GuardRuleExecution.SDK
        ? "SDK"
        : pr.execution === GuardRuleExecution.SERVER
          ? "SERVER"
          : "UNKNOWN",
    source: "REMOTE",
    result,
  };
}

function policyEvaluationFromProto(
  evaluation: GuardPolicyEvaluation | undefined,
): PolicyEvaluation | undefined {
  if (evaluation === undefined) return undefined;
  const statuses: Record<number, PolicyEvaluation["status"]> = {
    [GuardPolicyStatus.NOT_CONFIGURED]: "NOT_CONFIGURED",
    [GuardPolicyStatus.APPLIED]: "APPLIED",
    [GuardPolicyStatus.INCOMPLETE]: "INCOMPLETE",
    [GuardPolicyStatus.UNAVAILABLE]: "UNAVAILABLE",
  };
  return {
    revision: evaluation.revision,
    status: statuses[evaluation.status] ?? "UNKNOWN",
    refreshRequired: evaluation.refreshRequired,
  };
}

/**
 * Convert a `RuleWithInput` to a proto `GuardRuleSubmission`.
 *
 * Switches on the `type` discriminant so TypeScript narrows config/input
 * automatically — no casts required.
 */
export async function ruleToProto(
  rule: RuleWithInput,
  signal?: AbortSignal,
  options?: {
    /** Index of this rule in the submission, used to prefix warning messages. */
    ruleIndex?: number;
    /**
     * Sink for metadata keys the SDK could not encode. `GuardRuleSubmission` has
     * no `local_warnings` field of its own, so per-rule client-side diagnostics
     * ride on the request envelope.
     */
    warningsOut?: LocalWarning[];
  },
): Promise<GuardRuleSubmission> {
  const mode = rule.config.mode === "DRY_RUN" ? GuardRuleMode.DRY_RUN : GuardRuleMode.LIVE;

  const guardRule = await ruleBodyToProto(rule, signal);

  const { metadataJson, localWarnings } = encodeMetadata(
    ruleMetadata(rule),
    `rules[${options?.ruleIndex ?? 0}].`,
  );
  options?.warningsOut?.push(...localWarnings);

  const submission: Parameters<typeof create<typeof GuardRuleSubmissionSchema>>[1] = {
    configId: rule[symbolArcjetInternal].configId,
    inputId: rule[symbolArcjetInternal].inputId,
    metadataJson,
    rule: guardRule,
    mode,
  };
  if (rule.config.label !== undefined) {
    submission.label = rule.config.label;
  }
  return create(GuardRuleSubmissionSchema, submission);
}

/**
 * Merge config-level and input-level metadata for a rule submission.
 *
 * The merge is shallow and top-level only: an input key replaces the config
 * key's whole value, never deep-merging it. Every rule's `input` is an object
 * carrying optional per-request `metadata`.
 *
 * @internal
 */
function ruleMetadata(rule: RuleWithInput): ArcjetMetadata {
  return {
    ...rule.config.metadata,
    ...rule.input.metadata,
  };
}

/**
 * Map a `RuleWithInput` into a proto `GuardRule` using discriminant narrowing.
 *
 * @internal
 */
async function ruleBodyToProto(rule: RuleWithInput, signal?: AbortSignal): Promise<GuardRule> {
  switch (rule.type) {
    case "TOKEN_BUCKET":
      return create(GuardRuleSchema, {
        rule: {
          case: "tokenBucket",
          value: create(RuleTokenBucketSchema, {
            configRefillRate: rule.config.refillRate,
            configIntervalSeconds: rule.config.intervalSeconds,
            configMaxTokens: rule.config.maxTokens,
            configBucket: rule.config.bucket ?? "default-token-bucket",
            inputKeyHash: await sha256Hex(rule.input.key),
            inputRequested: rule.input.requested ?? 1,
          }),
        },
      });
    case "FIXED_WINDOW":
      return create(GuardRuleSchema, {
        rule: {
          case: "fixedWindow",
          value: create(RuleFixedWindowSchema, {
            configMaxRequests: rule.config.maxRequests,
            configWindowSeconds: rule.config.windowSeconds,
            configBucket: rule.config.bucket ?? "default-fixed-window",
            inputKeyHash: await sha256Hex(rule.input.key),
            inputRequested: rule.input.requested ?? 1,
          }),
        },
      });
    case "SLIDING_WINDOW":
      return create(GuardRuleSchema, {
        rule: {
          case: "slidingWindow",
          value: create(RuleSlidingWindowSchema, {
            configMaxRequests: rule.config.maxRequests,
            configIntervalSeconds: rule.config.intervalSeconds,
            configBucket: rule.config.bucket ?? "default-sliding-window",
            inputKeyHash: await sha256Hex(rule.input.key),
            inputRequested: rule.input.requested ?? 1,
          }),
        },
      });
    case "PROMPT_INJECTION":
      return create(GuardRuleSchema, {
        rule: {
          case: "detectPromptInjection",
          value: create(RuleDetectPromptInjectionSchema, {
            inputText: rule.input.inputText,
          }),
        },
      });
    case "MODERATE_CONTENT":
      return create(GuardRuleSchema, {
        rule: {
          case: "moderateContent",
          value: create(RuleModerateContentSchema, {
            inputText: rule.input.inputText,
          }),
        },
      });
    case "SENSITIVE_INFO": {
      const hash = await sha256Hex(rule.input.inputText);

      const entities = rule.config.deny
        ? { tag: "deny" as const, val: rule.config.deny.map((s) => stringToEntity(s)) }
        : { tag: "allow" as const, val: (rule.config.allow ?? []).map((s) => stringToEntity(s)) };

      let localResult: RuleLocalSensitiveInfo["localResult"];
      let resultDurationMs: bigint | undefined;

      // Use the configured backend (such as `@arcjet/sensitive-info-rampart`)
      // when provided; otherwise fall back to the bundled WASM engine.
      const backend = rule.config.backend ?? wasmSensitiveInfoBackend;

      const evalStart = performance.now();
      try {
        const result = await backend.detect(analyzeContext, rule.input.inputText, entities, {
          contextWindowSize: 1,
        });
        resultDurationMs = BigInt(Math.round(performance.now() - evalStart));

        const deniedTypes = [
          ...new Set(
            result.denied
              .map((d) => entityToString(d.identifiedType))
              .filter((t): t is SensitiveInfoEntityType => t !== undefined),
          ),
        ];
        localResult = {
          case: "resultComputed" as const,
          value: create(ResultLocalSensitiveInfoSchema, {
            conclusion: result.denied.length > 0 ? GuardConclusion.DENY : GuardConclusion.ALLOW,
            // `detected`, like the evidence below, reports only recognized
            // denied entities. Allowed findings remain private and do not
            // affect the projected result.
            detected: deniedTypes.length > 0,
            detectedEntityTypes: deniedTypes,
            detectedEntities: result.denied
              .map((entity) => {
                const type = entityToString(entity.identifiedType);
                return type === undefined
                  ? undefined
                  : create(GuardSensitiveInfoEntitySchema, {
                      type,
                      start: entity.start,
                      end: entity.end,
                    });
              })
              .filter((entity) => entity !== undefined),
          }),
        };
      } catch (err: unknown) {
        resultDurationMs = BigInt(Math.round(performance.now() - evalStart));
        localResult = {
          case: "resultError" as const,
          value: create(ResultErrorSchema, {
            message: err instanceof Error ? err.message : "sensitive info detection failed",
            code: "SENSITIVE_INFO_ERROR",
          }),
        };
      }

      return create(GuardRuleSchema, {
        rule: {
          case: "localSensitiveInfo",
          value: create(RuleLocalSensitiveInfoSchema, {
            configEntityFilter: rule.config.deny
              ? {
                  case: "configEntitiesDeny" as const,
                  value: create(EntityListSchema, { entities: rule.config.deny }),
                }
              : {
                  case: "configEntitiesAllow" as const,
                  value: create(EntityListSchema, { entities: rule.config.allow ?? [] }),
                },
            inputTextHash: hash,
            localResult,
            resultDurationMs,
          }),
        },
      });
    }
    case "CUSTOM": {
      let localResult: RuleLocalCustom["localResult"] | undefined;
      let resultDurationMs: bigint | undefined;

      if (rule.evaluate) {
        const evalStart = performance.now();
        try {
          const evalResult = await rule.evaluate(
            rule.config.data ?? {},
            rule.input.data,
            signal === undefined ? {} : { signal },
          );
          resultDurationMs = BigInt(Math.round(performance.now() - evalStart));

          if (evalResult.conclusion !== "ALLOW" && evalResult.conclusion !== "DENY") {
            localResult = {
              case: "resultError" as const,
              value: create(ResultErrorSchema, {
                message: `localCustom evaluate() returned invalid conclusion "${String(evalResult.conclusion)}" — must be "ALLOW" or "DENY"`,
                code: "INVALID_CONCLUSION",
              }),
            };
          } else {
            localResult = {
              case: "resultComputed" as const,
              value: create(ResultLocalCustomSchema, {
                conclusion:
                  evalResult.conclusion === "DENY" ? GuardConclusion.DENY : GuardConclusion.ALLOW,
                data: evalResult.data ?? {},
              }),
            };
          }
        } catch (err: unknown) {
          resultDurationMs = BigInt(Math.round(performance.now() - evalStart));
          localResult = {
            case: "resultError" as const,
            value: create(ResultErrorSchema, {
              message: err instanceof Error ? err.message : "Custom rule evaluation failed",
              code: "CUSTOM_EVAL_ERROR",
            }),
          };
        }
      }

      const customValue: Parameters<typeof create<typeof RuleLocalCustomSchema>>[1] = {
        configData: rule.config.data ?? {},
        inputData: rule.input.data,
      };
      if (localResult !== undefined) {
        customValue.localResult = localResult;
      }
      if (resultDurationMs !== undefined) {
        customValue.resultDurationMs = resultDurationMs;
      }

      return create(GuardRuleSchema, {
        rule: {
          case: "localCustom",
          value: create(RuleLocalCustomSchema, customValue),
        },
      });
    }
  }
}

/**
 * Coerce a value to a string with a fallback. Network data is untrusted — the
 * proto's `ResultError` fields arrive over Connect-JSON, where a malformed
 * response can put a non-string where a string is expected.
 */
function toStringOr(value: unknown, fallback: string): string {
  return typeof value === "string" ? value : fallback;
}

/**
 * Convert the proto `GuardResponse.errors` payload (non-fatal request
 * validation diagnostics) into decision-level {@link Warning}s, validating
 * each entry at the SDK boundary.
 */
function warningsFromProto(
  errors: readonly { code: unknown; message: unknown }[],
): readonly Warning[] {
  return errors.map((e) => ({
    code: toStringOr(e.code, "UNKNOWN"),
    message: toStringOr(e.message, "Unknown warning"),
  }));
}

/**
 * Build the shared diagnostic members every decision carries — `warnings` plus
 * the derived `errorResults()` / `hasFailedOpen()` / `hasError()` helpers — from
 * a conclusion, its results, and any decision-level warnings.
 *
 * `errorResults()` scans `results` for `RuleResultError` (which includes the
 * synthetic error result used when a request could not be processed). It is
 * computed once and closed over so `hasFailedOpen()` and `errorResults()` share
 * a single scan rather than re-filtering on each call. `hasError()` is the
 * deprecated conflated union (warnings ∪ errors).
 *
 * @internal
 */
export function decisionMembers(
  conclusion: Conclusion,
  results: readonly RuleResult[],
  warnings: readonly Warning[],
  additionalErrors: readonly RuleResultError[] = [],
): {
  warnings: readonly Warning[];
  errorResults: () => readonly RuleResultError[];
  hasFailedOpen: () => boolean;
  hasError: () => boolean;
} {
  // Compute once; both accessors close over the same array.
  const errored = [
    ...results.filter((r): r is RuleResultError => r.type === "RULE_ERROR"),
    ...additionalErrors,
  ];
  const errorResults = (): readonly RuleResultError[] => errored;
  return {
    warnings,
    errorResults,
    hasFailedOpen: (): boolean => conclusion === "ALLOW" && errored.length > 0,
    hasError: (): boolean => warnings.length > 0 || errored.length > 0,
  };
}

/**
 * Convert a proto `GuardResponse` to the SDK `Decision`.
 *
 * Correlates proto results back to SDK rule instances using
 * `config_id` and `input_id`.
 */
export function decisionFromProto(
  response: ProtoGuardResponse,
  _rules: readonly RuleWithInput[],
  localWarnings: readonly Warning[] = [],
): Decision {
  // Top-level diagnostics are decision-level warnings; present whether or not a
  // decision came back. The server persists `local_warnings` but never echoes
  // them, so the SDK appends its own drops here — a dropped metadata key is
  // never silent.
  const warnings = [...warningsFromProto(response.errors), ...localWarnings];
  const proto = response.decision;
  if (!proto) {
    // No usable decision — synthesize a fail-open ALLOW carrying an error
    // result so `hasFailedOpen()` / `errorResults()` see the failure.
    const errorResult: InternalResult = {
      conclusion: "ALLOW",
      reason: "ERROR",
      type: "RULE_ERROR",
      warnings: [],
      message: "No decision in response",
      code: "NO_DECISION",
      [symbolArcjetInternal]: { configId: "", inputId: "" },
    };
    const results = [errorResult];
    const d: InternalDecision = {
      conclusion: "ALLOW" as const,
      id: "",
      results,
      ...decisionMembers("ALLOW", results, warnings),
      [symbolArcjetInternal]: { results },
    };
    return d;
  }

  const internalResults: InternalResult[] = [];

  for (const protoResult of proto.ruleResults) {
    const result = resultFromProto(protoResult);
    internalResults.push({
      ...result,
      [symbolArcjetInternal]: {
        configId: protoResult.configId,
        inputId: protoResult.inputId,
      },
    });
  }

  const results: readonly RuleResult[] = internalResults;
  const policyErrors = policyErrorsFromProto(proto.policyEvaluation);
  const policyEvaluation = policyEvaluationFromProto(proto.policyEvaluation);
  const policyResults = proto.policyRuleResults.map(policyResultFromProto);

  const conclusion = conclusionFromProto(proto.conclusion);
  const reason = reasonFromProto(proto.reason);

  if (conclusion === "DENY") {
    const d: InternalDecision = {
      conclusion: "DENY" as const,
      reason,
      id: proto.id,
      results,
      ...(policyEvaluation !== undefined && { policyEvaluation }),
      policyResults,
      ...decisionMembers("DENY", results, warnings, policyErrors),
      [symbolArcjetInternal]: { results: internalResults },
    };
    return d;
  }

  const d: InternalDecision = {
    conclusion: "ALLOW" as const,
    id: proto.id,
    results,
    ...(policyEvaluation !== undefined && { policyEvaluation }),
    policyResults,
    ...decisionMembers("ALLOW", results, warnings, policyErrors),
    [symbolArcjetInternal]: { results: internalResults },
  };
  return d;
}

function policyErrorsFromProto(evaluation: GuardPolicyEvaluation | undefined): RuleResultError[] {
  if (evaluation === undefined) return [];
  let message: string | undefined;
  switch (evaluation.status) {
    case GuardPolicyStatus.INCOMPLETE:
      message = "Remote Guard policy could not be fully evaluated";
      break;
    case GuardPolicyStatus.UNAVAILABLE:
      message = "Remote Guard policy is unavailable";
      break;
    case GuardPolicyStatus.UNSPECIFIED:
    case GuardPolicyStatus.NOT_CONFIGURED:
    case GuardPolicyStatus.APPLIED:
      return [];
  }
  return [
    {
      conclusion: "ALLOW",
      reason: "ERROR",
      type: "RULE_ERROR",
      warnings: [],
      code: "REMOTE_POLICY_UNAVAILABLE",
      message,
    },
  ];
}
