import assert from "node:assert/strict";
import { describe, test } from "node:test";

import type { SensitiveInfoEntities } from "@arcjet/analyze";
import { create } from "@bufbuild/protobuf";

import {
  conclusionFromProto,
  reasonFromCase,
  reasonFromProto,
  resultFromProto,
  ruleToProto,
  decisionFromProto,
} from "./convert.ts";
import {
  type GuardResponse,
  GuardResponseSchema,
  GuardDecisionSchema,
  GuardRuleResultSchema,
  BillingSchema,
  ResultTokenBucketSchema,
  ResultFixedWindowSchema,
  ResultSlidingWindowSchema,
  ResultPromptInjectionSchema,
  ResultModerateContentSchema,
  ResultLocalSensitiveInfoSchema,
  ResultLocalCustomSchema,
  ResultErrorSchema,
  ResultNotRunSchema,
  GuardConclusion,
  GuardReason,
  GuardRuleType,
  GuardRuleMode,
} from "./proto/proto/decide/v2/decide_pb.js";
import {
  tokenBucket,
  fixedWindow,
  slidingWindow,
  detectPromptInjection,
  experimental_moderateContent,
  localDetectSensitiveInfo,
  defineCustomRule,
} from "./rules.ts";
import { symbolArcjetInternal } from "./symbol.ts";
import type { SensitiveInfoBackend, SensitiveInfoBackendOptions } from "./types.ts";

describe("conclusionFromProto", () => {
  test("ALLOW maps to 'ALLOW'", () => {
    assert.equal(conclusionFromProto(GuardConclusion.ALLOW), "ALLOW");
  });

  test("DENY maps to 'DENY'", () => {
    assert.equal(conclusionFromProto(GuardConclusion.DENY), "DENY");
  });

  test("UNSPECIFIED maps to 'ALLOW' (fail-open)", () => {
    assert.equal(conclusionFromProto(GuardConclusion.UNSPECIFIED), "ALLOW");
  });
});

describe("reasonFromCase", () => {
  test("tokenBucket → RATE_LIMIT", () => {
    assert.equal(reasonFromCase("tokenBucket"), "RATE_LIMIT");
  });

  test("fixedWindow → RATE_LIMIT", () => {
    assert.equal(reasonFromCase("fixedWindow"), "RATE_LIMIT");
  });

  test("slidingWindow → RATE_LIMIT", () => {
    assert.equal(reasonFromCase("slidingWindow"), "RATE_LIMIT");
  });

  test("promptInjection → PROMPT_INJECTION", () => {
    assert.equal(reasonFromCase("promptInjection"), "PROMPT_INJECTION");
  });

  test("moderateContent → MODERATE_CONTENT", () => {
    assert.equal(reasonFromCase("moderateContent"), "MODERATE_CONTENT");
  });

  test("localSensitiveInfo → SENSITIVE_INFO", () => {
    assert.equal(reasonFromCase("localSensitiveInfo"), "SENSITIVE_INFO");
  });

  test("localCustom → CUSTOM", () => {
    assert.equal(reasonFromCase("localCustom"), "CUSTOM");
  });

  test("error → ERROR", () => {
    assert.equal(reasonFromCase("error"), "ERROR");
  });

  test("notRun → NOT_RUN", () => {
    assert.equal(reasonFromCase("notRun"), "NOT_RUN");
  });

  test("undefined → UNKNOWN", () => {
    const undef: string | undefined = void 0;
    assert.equal(reasonFromCase(undef), "UNKNOWN");
  });

  test("unrecognized string → UNKNOWN", () => {
    assert.equal(reasonFromCase("somethingNew"), "UNKNOWN");
  });
});

describe("reasonFromProto", () => {
  test("RATE_LIMIT maps to 'RATE_LIMIT'", () => {
    assert.equal(reasonFromProto(GuardReason.RATE_LIMIT), "RATE_LIMIT");
  });

  test("PROMPT_INJECTION maps to 'PROMPT_INJECTION'", () => {
    assert.equal(reasonFromProto(GuardReason.PROMPT_INJECTION), "PROMPT_INJECTION");
  });

  test("SENSITIVE_INFO maps to 'SENSITIVE_INFO'", () => {
    assert.equal(reasonFromProto(GuardReason.SENSITIVE_INFO), "SENSITIVE_INFO");
  });

  test("CUSTOM maps to 'CUSTOM'", () => {
    assert.equal(reasonFromProto(GuardReason.CUSTOM), "CUSTOM");
  });

  test("ERROR maps to 'ERROR'", () => {
    assert.equal(reasonFromProto(GuardReason.ERROR), "ERROR");
  });

  test("NOT_RUN maps to 'NOT_RUN'", () => {
    assert.equal(reasonFromProto(GuardReason.NOT_RUN), "NOT_RUN");
  });

  test("UNSPECIFIED maps to 'UNKNOWN'", () => {
    assert.equal(reasonFromProto(GuardReason.UNSPECIFIED), "UNKNOWN");
  });
});

describe("resultFromProto", () => {
  test("tokenBucket result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      configId: "cfg_1",
      inputId: "inp_1",
      type: GuardRuleType.TOKEN_BUCKET,
      result: {
        case: "tokenBucket",
        value: create(ResultTokenBucketSchema, {
          conclusion: GuardConclusion.ALLOW,
          remainingTokens: 95,
          maxTokens: 100,
          resetAtUnixSeconds: 60,
          refillRate: 10,
          refillIntervalSeconds: 60,
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "TOKEN_BUCKET");
    assert.equal(result.reason, "RATE_LIMIT");
    assert.equal(result.conclusion, "ALLOW");
    if (result.type === "TOKEN_BUCKET") {
      assert.equal(result.remainingTokens, 95);
      assert.equal(result.maxTokens, 100);
      assert.equal(result.resetAtUnixSeconds, 60);
      assert.equal(result.refillRate, 10);
      assert.equal(result.refillIntervalSeconds, 60);
    }
  });

  test("fixedWindow result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.FIXED_WINDOW,
      result: {
        case: "fixedWindow",
        value: create(ResultFixedWindowSchema, {
          conclusion: GuardConclusion.DENY,
          remainingRequests: 0,
          maxRequests: 100,
          resetAtUnixSeconds: 1800,
          windowSeconds: 3600,
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "FIXED_WINDOW");
    assert.equal(result.conclusion, "DENY");
    if (result.type === "FIXED_WINDOW") {
      assert.equal(result.remainingRequests, 0);
      assert.equal(result.maxRequests, 100);
      assert.equal(result.windowSeconds, 3600);
    }
  });

  test("slidingWindow result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.SLIDING_WINDOW,
      result: {
        case: "slidingWindow",
        value: create(ResultSlidingWindowSchema, {
          conclusion: GuardConclusion.ALLOW,
          remainingRequests: 450,
          maxRequests: 500,
          resetAtUnixSeconds: 30,
          intervalSeconds: 60,
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "SLIDING_WINDOW");
    if (result.type === "SLIDING_WINDOW") {
      assert.equal(result.remainingRequests, 450);
      assert.equal(result.intervalSeconds, 60);
    }
  });

  test("promptInjection result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.PROMPT_INJECTION,
      result: {
        case: "promptInjection",
        value: create(ResultPromptInjectionSchema, {
          conclusion: GuardConclusion.DENY,
          detected: true,
          billing: create(BillingSchema, {
            unit: "tokens",
            count: 18_446_744_073_709_551_615n,
          }),
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "PROMPT_INJECTION");
    assert.equal(result.reason, "PROMPT_INJECTION");
    assert.equal(result.conclusion, "DENY");
    if (result.type === "PROMPT_INJECTION") {
      assert.deepEqual(result.billing, {
        unit: "tokens",
        count: 18_446_744_073_709_551_615n,
      });
    }
  });

  test("moderateContent result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.MODERATE_CONTENT,
      result: {
        case: "moderateContent",
        value: create(ResultModerateContentSchema, {
          conclusion: GuardConclusion.DENY,
          detected: true,
          billing: create(BillingSchema, { unit: "text_units", count: 456n }),
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "MODERATE_CONTENT");
    assert.equal(result.reason, "MODERATE_CONTENT");
    assert.equal(result.conclusion, "DENY");
    if (result.type === "MODERATE_CONTENT") {
      assert.equal(result.detected, true);
      assert.deepEqual(result.billing, { unit: "text_units", count: 456n });
    }
  });

  test("billing preserves an explicitly present empty message", () => {
    const pr = create(GuardRuleResultSchema, {
      result: {
        case: "promptInjection",
        value: create(ResultPromptInjectionSchema, {
          billing: create(BillingSchema),
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "PROMPT_INJECTION");
    if (result.type === "PROMPT_INJECTION") {
      assert.deepEqual(result.billing, { unit: "", count: 0n });
    }
  });

  test("billing is undefined when absent", () => {
    const pr = create(GuardRuleResultSchema, {
      result: {
        case: "promptInjection",
        value: create(ResultPromptInjectionSchema),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "PROMPT_INJECTION");
    if (result.type === "PROMPT_INJECTION") {
      assert.equal(result.billing, undefined);
    }
  });

  test("localSensitiveInfo result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.LOCAL_SENSITIVE_INFO,
      result: {
        case: "localSensitiveInfo",
        value: create(ResultLocalSensitiveInfoSchema, {
          conclusion: GuardConclusion.DENY,
          detected: true,
          detectedEntityTypes: ["PHONE_NUMBER", "EMAIL"],
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "SENSITIVE_INFO");
    if (result.type === "SENSITIVE_INFO") {
      assert.deepEqual(result.detectedEntityTypes, ["PHONE_NUMBER", "EMAIL"]);
    }
  });

  test("localCustom result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.LOCAL_CUSTOM,
      result: {
        case: "localCustom",
        value: create(ResultLocalCustomSchema, {
          conclusion: GuardConclusion.ALLOW,
          data: { key: "value" },
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "CUSTOM");
    if (result.type === "CUSTOM") {
      assert.deepEqual(result.data, { key: "value" });
    }
  });

  test("error result maps to RULE_ERROR with fail-open", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.TOKEN_BUCKET,
      result: {
        case: "error",
        value: create(ResultErrorSchema, {
          message: "evaluator timeout",
          code: "TIMEOUT",
        }),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "RULE_ERROR");
    assert.equal(result.conclusion, "ALLOW");
    if (result.type === "RULE_ERROR") {
      assert.equal(result.message, "evaluator timeout");
      assert.equal(result.code, "TIMEOUT");
    }
  });

  test("error result with empty message defaults", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      result: {
        case: "error",
        value: create(ResultErrorSchema, {}),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "RULE_ERROR");
    if (result.type === "RULE_ERROR") {
      assert.equal(result.message, "Unknown error");
      assert.equal(result.code, "UNKNOWN");
    }
  });

  test("notRun result", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      result: {
        case: "notRun",
        value: create(ResultNotRunSchema, {}),
      },
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "NOT_RUN");
    assert.equal(result.conclusion, "ALLOW");
  });

  test("undefined case maps to UNKNOWN", () => {
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_1",
      type: GuardRuleType.UNSPECIFIED,
    });

    const result = resultFromProto(pr);
    assert.equal(result.type, "UNKNOWN");
    assert.equal(result.reason, "UNKNOWN");
    assert.equal(result.conclusion, "ALLOW");
  });

  test("unrecognized oneof case maps to UNKNOWN via default branch", () => {
    // Force a result with a case string that doesn't match any known variant.
    // This exercises the `default:` branch in resultFromProto.
    const pr = create(GuardRuleResultSchema, {
      resultId: "gres_future",
      type: GuardRuleType.UNSPECIFIED,
    });
    // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- intentionally forging unknown case for testing
    (pr as { result: { case: string; value: unknown } }).result = {
      case: "futureRuleType",
      value: {},
    };

    const result = resultFromProto(pr);
    assert.equal(result.type, "UNKNOWN");
    assert.equal(result.reason, "UNKNOWN");
    assert.equal(result.conclusion, "ALLOW");
  });
});

describe("ruleToProto", () => {
  test("converts token bucket rule to proto", async () => {
    const rule = tokenBucket({
      bucket: "test.bucket",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1", requested: 5 });

    const proto = await ruleToProto(input);

    assert.equal(proto.configId, input[symbolArcjetInternal].configId);
    assert.equal(proto.inputId, input[symbolArcjetInternal].inputId);
    assert.equal(proto.mode, GuardRuleMode.LIVE);
    assert.equal(proto.rule?.rule.case, "tokenBucket");

    if (proto.rule?.rule.case === "tokenBucket") {
      const v = proto.rule.rule.value;
      assert.equal(v.configRefillRate, 10);
      assert.equal(v.configIntervalSeconds, 60);
      assert.equal(v.configMaxTokens, 100);
      assert.equal(v.configBucket, "test.bucket");
      assert.equal(
        v.inputKeyHash,
        "79b0aa0042b3c05617c378046a6553ec2cd81e9995959a6012f9b497a18ec82b",
      );
      assert.equal(v.inputRequested, 5);
    }
  });

  test("token bucket defaults requested to 1", async () => {
    const rule = tokenBucket({
      bucket: "test.bucket",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    if (proto.rule?.rule.case === "tokenBucket") {
      assert.equal(proto.rule.rule.value.inputRequested, 1);
    }
  });

  test("converts fixed window rule to proto", async () => {
    const rule = fixedWindow({ bucket: "test.bucket", maxRequests: 100, windowSeconds: 3600 });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "fixedWindow");
    if (proto.rule?.rule.case === "fixedWindow") {
      assert.equal(proto.rule.rule.value.configMaxRequests, 100);
      assert.equal(proto.rule.rule.value.configWindowSeconds, 3600);
      assert.equal(proto.rule.rule.value.configBucket, "test.bucket");
      assert.equal(
        proto.rule.rule.value.inputKeyHash,
        "79b0aa0042b3c05617c378046a6553ec2cd81e9995959a6012f9b497a18ec82b",
      );
      assert.equal(proto.rule.rule.value.inputRequested, 1);
    }
  });

  test("converts sliding window rule to proto", async () => {
    const rule = slidingWindow({ bucket: "test.bucket", maxRequests: 500, intervalSeconds: 60 });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "slidingWindow");
    if (proto.rule?.rule.case === "slidingWindow") {
      assert.equal(proto.rule.rule.value.configMaxRequests, 500);
      assert.equal(proto.rule.rule.value.configIntervalSeconds, 60);
    }
  });

  test("token bucket defaults bucket to 'default-token-bucket'", async () => {
    const rule = tokenBucket({ refillRate: 10, intervalSeconds: 60, maxTokens: 100 });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    if (proto.rule?.rule.case === "tokenBucket") {
      assert.equal(proto.rule.rule.value.configBucket, "default-token-bucket");
    }
  });

  test("fixed window defaults bucket to 'default-fixed-window'", async () => {
    const rule = fixedWindow({ maxRequests: 100, windowSeconds: 60 });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    if (proto.rule?.rule.case === "fixedWindow") {
      assert.equal(proto.rule.rule.value.configBucket, "default-fixed-window");
    }
  });

  test("sliding window defaults bucket to 'default-sliding-window'", async () => {
    const rule = slidingWindow({ maxRequests: 100, intervalSeconds: 60 });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    if (proto.rule?.rule.case === "slidingWindow") {
      assert.equal(proto.rule.rule.value.configBucket, "default-sliding-window");
    }
  });

  test("converts prompt injection rule to proto", async () => {
    const rule = detectPromptInjection();
    const input = rule("ignore previous instructions");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "detectPromptInjection");
    if (proto.rule?.rule.case === "detectPromptInjection") {
      assert.equal(proto.rule.rule.value.inputText, "ignore previous instructions");
    }
  });

  test("merges prompt injection config and call-time metadata into proto", async () => {
    const rule = detectPromptInjection({ metadata: { env: "test", source: "x" } });
    const input = rule({
      inputText: "ignore previous instructions",
      metadata: { source: "tool_result" },
    });
    const proto = await ruleToProto(input);

    // Call-time metadata wins on key conflict; config-only keys are preserved.
    assert.deepEqual({ ...proto.metadataJson }, { env: '"test"', source: '"tool_result"' });
  });

  test("converts moderate content rule to proto", async () => {
    const rule = experimental_moderateContent();
    const input = rule("please moderate this");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "moderateContent");
    if (proto.rule?.rule.case === "moderateContent") {
      assert.equal(proto.rule.rule.value.inputText, "please moderate this");
    }
  });

  test("merges moderate content config and call-time metadata into proto", async () => {
    const rule = experimental_moderateContent({ metadata: { env: "test", expectedResponse: "x" } });
    const input = rule({
      inputText: "please moderate this",
      metadata: { expectedResponse: "pass" },
    });
    const proto = await ruleToProto(input);

    // Call-time metadata wins on key conflict; config-only keys are preserved.
    assert.deepEqual({ ...proto.metadataJson }, { env: '"test"', expectedResponse: '"pass"' });
  });

  test("merges sensitive info config and call-time metadata into proto", async () => {
    const rule = localDetectSensitiveInfo({
      deny: ["EMAIL"],
      metadata: { env: "test", destination: "x" },
    });
    const input = rule({
      inputText: "nothing sensitive here",
      metadata: { destination: "openai" },
    });
    const proto = await ruleToProto(input);

    // Call-time metadata wins on key conflict; config-only keys are preserved.
    assert.deepEqual({ ...proto.metadataJson }, { env: '"test"', destination: '"openai"' });
  });

  test("converts sensitive info rule to proto with local WASM result", async () => {
    const rule = localDetectSensitiveInfo({ allow: ["EMAIL"] });
    const input = rule("my email is foo@bar.com");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.configEntityFilter.case, "configEntitiesAllow");
      assert.deepEqual(proto.rule.rule.value.configEntityFilter.value?.entities, ["EMAIL"]);
      // The text is hashed, not sent directly
      assert.ok(proto.rule.rule.value.inputTextHash);
      assert.notEqual(proto.rule.rule.value.inputTextHash, "my email is foo@bar.com");
      // Local WASM detection should have run
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        // EMAIL is allowed so no denied entities → ALLOW
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.ALLOW);
        // Allowed findings remain private: the projected result contains only
        // denied detection state and evidence.
        assert.equal(proto.rule.rule.value.localResult.value.detected, false);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, []);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntities, []);
      }
      assert.ok(proto.rule.rule.value.resultDurationMs !== undefined);
    }
  });

  test("sensitive info WASM denies when entity is in deny list", async () => {
    const rule = localDetectSensitiveInfo({ deny: ["EMAIL"] });
    const input = rule("my email is foo@bar.com");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.configEntityFilter.case, "configEntitiesDeny");
      assert.deepEqual(proto.rule.rule.value.configEntityFilter.value?.entities, ["EMAIL"]);
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.DENY);
        assert.equal(proto.rule.rule.value.localResult.value.detected, true);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, ["EMAIL"]);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntities, [
          {
            $typeName: "proto.decide.v2.GuardSensitiveInfoEntity",
            type: "EMAIL",
            start: 12,
            end: 23,
          },
        ]);
        assert.equal(
          "value" in proto.rule.rule.value.localResult.value.detectedEntities[0],
          false,
          "the matched sensitive value must never be sent to Decide",
        );
      }
    }
  });

  test("sensitive info WASM allows when no sensitive info detected", async () => {
    const rule = localDetectSensitiveInfo({ deny: ["EMAIL"] });
    const input = rule("nothing sensitive here");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.ALLOW);
        assert.equal(proto.rule.rule.value.localResult.value.detected, false);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, []);
      }
    }
  });

  test("uses a configured backend instead of the WASM engine", async () => {
    const calls: Array<{
      value: string;
      entities: SensitiveInfoEntities;
      options: SensitiveInfoBackendOptions | undefined;
    }> = [];

    const backend: SensitiveInfoBackend = {
      detect(_context, value, entities, options) {
        calls.push({ value, entities, options });
        return Promise.resolve({
          allowed: [],
          denied: [{ start: 0, end: 4, identifiedType: { tag: "custom", val: "GIVEN_NAME" } }],
        });
      },
    };

    const rule = localDetectSensitiveInfo({ deny: ["GIVEN_NAME"], backend });
    const input = rule("Jane went to the store");
    const proto = await ruleToProto(input);

    // The backend received the raw text and the deny entities (a Rampart-only
    // type carried as a `custom` analyze entity).
    assert.equal(calls.length, 1);
    assert.equal(calls[0]?.value, "Jane went to the store");
    assert.equal(calls[0]?.entities.tag, "deny");
    assert.deepEqual(calls[0]?.entities.val, [{ tag: "custom", val: "GIVEN_NAME" }]);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.deepEqual(proto.rule.rule.value.configEntityFilter.value?.entities, ["GIVEN_NAME"]);
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.DENY);
        assert.equal(proto.rule.rule.value.localResult.value.detected, true);
        // The custom entity is surfaced back as its plain type string.
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, [
          "GIVEN_NAME",
        ]);
      }
    }
  });

  test("deduplicates repeated denied entity types", async () => {
    const backend: SensitiveInfoBackend = {
      detect() {
        return Promise.resolve({
          allowed: [],
          denied: [
            { start: 0, end: 11, identifiedType: { tag: "custom", val: "SSN" } },
            { start: 16, end: 27, identifiedType: { tag: "custom", val: "SSN" } },
          ],
        });
      },
    };
    const rule = localDetectSensitiveInfo({ deny: ["SSN"], backend });
    const proto = await ruleToProto(rule("431-55-9928 and 623-84-1157"));

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, ["SSN"]);
        assert.equal(proto.rule.rule.value.localResult.value.detectedEntities.length, 2);
      }
    }
  });

  test("passes allow-list entities to the backend", async () => {
    let received: SensitiveInfoEntities | undefined;

    const backend: SensitiveInfoBackend = {
      detect(_context, _value, entities) {
        received = entities;
        // Deny a type not in the allow list (allow-list semantics).
        return Promise.resolve({
          allowed: [],
          denied: [{ start: 0, end: 7, identifiedType: { tag: "custom", val: "SURNAME" } }],
        });
      },
    };

    const rule = localDetectSensitiveInfo({ allow: ["GIVEN_NAME"], backend });
    const input = rule("Jackson");
    const proto = await ruleToProto(input);

    assert.equal(received?.tag, "allow");
    assert.deepEqual(received?.val, [{ tag: "custom", val: "GIVEN_NAME" }]);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.configEntityFilter.case, "configEntitiesAllow");
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.DENY);
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, ["SURNAME"]);
      }
    }
  });

  test("backend errors are captured as a rule error result", async () => {
    const backend: SensitiveInfoBackend = {
      detect() {
        return Promise.reject(new Error("model failed to load"));
      },
    };

    const rule = localDetectSensitiveInfo({ deny: ["SSN"], backend });
    const input = rule("123-45-6789");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.localResult.case, "resultError");
      if (proto.rule.rule.value.localResult.case === "resultError") {
        assert.equal(proto.rule.rule.value.localResult.value.message, "model failed to load");
        assert.equal(proto.rule.rule.value.localResult.value.code, "SENSITIVE_INFO_ERROR");
      }
      assert.ok(proto.rule.rule.value.resultDurationMs !== undefined);
    }
  });

  test("drops backend custom entities outside the known type union", async () => {
    const backend: SensitiveInfoBackend = {
      detect() {
        return Promise.resolve({
          allowed: [],
          denied: [
            // A valid declared type is kept...
            { start: 0, end: 4, identifiedType: { tag: "custom" as const, val: "GIVEN_NAME" } },
            // ...but an arbitrary string from a misbehaving backend is dropped.
            {
              start: 5,
              end: 9,
              identifiedType: { tag: "custom" as const, val: "NOT_A_REAL_TYPE" },
            },
          ],
        });
      },
    };

    const rule = localDetectSensitiveInfo({ deny: ["GIVEN_NAME"], backend });
    const input = rule("Jane Doe");
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localSensitiveInfo");
    if (proto.rule?.rule.case === "localSensitiveInfo") {
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        // The unknown type never reaches detectedEntityTypes.
        assert.deepEqual(proto.rule.rule.value.localResult.value.detectedEntityTypes, [
          "GIVEN_NAME",
        ]);
      }
    }
  });

  test("converts custom rule to proto", async () => {
    const rule = defineCustomRule({ evaluate: () => ({ conclusion: "ALLOW" as const }) })({
      data: { threshold: "0.5" },
    });
    const input = rule({ data: { score: "0.8" } });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localCustom");
    if (proto.rule?.rule.case === "localCustom") {
      assert.deepEqual(Object.fromEntries(Object.entries(proto.rule.rule.value.configData)), {
        threshold: "0.5",
      });
      assert.deepEqual(Object.fromEntries(Object.entries(proto.rule.rule.value.inputData)), {
        score: "0.8",
      });
      // evaluate fn is present → localResult is computed
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
    }
  });

  test("custom rule with sync evaluate — DENY", async () => {
    const rule = defineCustomRule({
      evaluate: (config, input) => {
        const score = Number(input["score"] ?? "0");
        const threshold = Number(config["threshold"] ?? "0");
        return score > threshold
          ? { conclusion: "DENY" as const, data: { reason: "score too high" } }
          : { conclusion: "ALLOW" as const };
      },
    })({ data: { threshold: "0.5" } });
    const input = rule({ data: { score: "0.8" } });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localCustom");
    if (proto.rule?.rule.case === "localCustom") {
      const value = proto.rule.rule.value;
      assert.equal(value.localResult.case, "resultComputed");
      if (value.localResult.case === "resultComputed") {
        assert.equal(value.localResult.value.conclusion, GuardConclusion.DENY);
        assert.deepEqual(Object.fromEntries(Object.entries(value.localResult.value.data)), {
          reason: "score too high",
        });
      }
      assert.ok(value.resultDurationMs !== undefined);
    }
  });

  test("custom rule with sync evaluate — ALLOW", async () => {
    const rule = defineCustomRule({
      evaluate: (config, input) => {
        const score = Number(input["score"] ?? "0");
        const threshold = Number(config["threshold"] ?? "0");
        return score > threshold
          ? { conclusion: "DENY" as const }
          : { conclusion: "ALLOW" as const, data: { margin: String(threshold - score) } };
      },
    })({ data: { threshold: "0.5" } });
    const input = rule({ data: { score: "0.3" } });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localCustom");
    if (proto.rule?.rule.case === "localCustom") {
      const value = proto.rule.rule.value;
      assert.equal(value.localResult.case, "resultComputed");
      if (value.localResult.case === "resultComputed") {
        assert.equal(value.localResult.value.conclusion, GuardConclusion.ALLOW);
      }
    }
  });

  test("custom rule with async evaluate", async () => {
    const rule = defineCustomRule({
      evaluate: async (_config, input) => {
        await new Promise((resolve) => setTimeout(resolve, 1));
        return input["action"] === "block"
          ? { conclusion: "DENY" as const }
          : { conclusion: "ALLOW" as const };
      },
    })({ data: {} });
    const input = rule({ data: { action: "block" } });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localCustom");
    if (proto.rule?.rule.case === "localCustom") {
      assert.equal(proto.rule.rule.value.localResult.case, "resultComputed");
      if (proto.rule.rule.value.localResult.case === "resultComputed") {
        assert.equal(proto.rule.rule.value.localResult.value.conclusion, GuardConclusion.DENY);
      }
    }
  });

  test("custom rule evaluate throws → resultError", async () => {
    const rule = defineCustomRule({
      evaluate: () => {
        throw new Error("boom");
      },
    })({ data: {} });
    const input = rule({ data: {} });
    const proto = await ruleToProto(input);

    assert.equal(proto.rule?.rule.case, "localCustom");
    if (proto.rule?.rule.case === "localCustom") {
      const value = proto.rule.rule.value;
      assert.equal(value.localResult.case, "resultError");
      if (value.localResult.case === "resultError") {
        assert.equal(value.localResult.value.message, "boom");
        assert.equal(value.localResult.value.code, "CUSTOM_EVAL_ERROR");
      }
      assert.ok(value.resultDurationMs !== undefined);
    }
  });

  test("DRY_RUN mode is mapped to proto", async () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
      mode: "DRY_RUN",
    });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    assert.equal(proto.mode, GuardRuleMode.DRY_RUN);
  });

  test("label is mapped to proto", async () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
      label: "my-rule",
    });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    assert.equal(proto.label, "my-rule");
  });

  test("metadata is mapped to proto", async () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
      metadata: { env: "test" },
    });
    const input = rule({ key: "user_1" });
    const proto = await ruleToProto(input);

    assert.deepEqual({ ...proto.metadataJson }, { env: '"test"' });
  });
});

/** Build a proto GuardResponse with the given conclusion, reason, and rule results. */
function makeResponse(
  conclusion: GuardConclusion,
  results: Parameters<typeof create<typeof GuardRuleResultSchema>>[1][],
  reason: GuardReason = GuardReason.UNSPECIFIED,
): GuardResponse {
  return create(GuardResponseSchema, {
    decision: create(GuardDecisionSchema, {
      id: "gdec_test123",
      conclusion,
      reason,
      ruleResults: results.map((r) => create(GuardRuleResultSchema, r)),
    }),
  });
}

describe("decisionFromProto", () => {
  test("ALLOW decision with token bucket result", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "tokenBucket",
          value: create(ResultTokenBucketSchema, {
            conclusion: GuardConclusion.ALLOW,
            remainingTokens: 95,
            maxTokens: 100,
            resetAtUnixSeconds: 60,
            refillRate: 10,
            refillIntervalSeconds: 60,
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "ALLOW");
    assert.equal(decision.id, "gdec_test123");
    assert.equal(decision.results.length, 1);
    assert.equal(decision.results[0].type, "TOKEN_BUCKET");
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), false);

    const result = input.result(decision);
    assert.ok(result);
    assert.equal(result.type, "TOKEN_BUCKET");
    assert.equal(result.remainingTokens, 95);
  });

  test("DENY decision with fixed window result", () => {
    const rule = fixedWindow({ bucket: "test", maxRequests: 100, windowSeconds: 3600 });
    const input = rule({ key: "user_1" });

    const response = makeResponse(
      GuardConclusion.DENY,
      [
        {
          resultId: "gres_test1",
          configId: input[symbolArcjetInternal].configId,
          inputId: input[symbolArcjetInternal].inputId,
          type: GuardRuleType.FIXED_WINDOW,
          result: {
            case: "fixedWindow",
            value: create(ResultFixedWindowSchema, {
              conclusion: GuardConclusion.DENY,
              remainingRequests: 0,
              maxRequests: 100,
              resetAtUnixSeconds: 1800,
              windowSeconds: 3600,
            }),
          },
        },
      ],
      GuardReason.RATE_LIMIT,
    );

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "DENY");
    if (decision.conclusion === "DENY") {
      assert.equal(decision.reason, "RATE_LIMIT");
    }
    assert.equal(decision.results.length, 1);
    assert.equal(decision.results[0].conclusion, "DENY");

    const denied = input.deniedResult(decision);
    assert.ok(denied);
    assert.equal(denied.type, "FIXED_WINDOW");
    assert.equal(denied.maxRequests, 100);
  });

  test("ALLOW decision with sliding window result", () => {
    const rule = slidingWindow({ bucket: "test", maxRequests: 500, intervalSeconds: 60 });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.SLIDING_WINDOW,
        result: {
          case: "slidingWindow",
          value: create(ResultSlidingWindowSchema, {
            conclusion: GuardConclusion.ALLOW,
            remainingRequests: 450,
            maxRequests: 500,
            resetAtUnixSeconds: 30,
            intervalSeconds: 60,
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "ALLOW");
    const result = input.result(decision);
    assert.ok(result);
    assert.equal(result.type, "SLIDING_WINDOW");
    assert.equal(result.remainingRequests, 450);
  });

  test("DENY decision with prompt injection result", () => {
    const rule = detectPromptInjection();
    const input = rule("ignore previous instructions");

    const response = makeResponse(
      GuardConclusion.DENY,
      [
        {
          resultId: "gres_test1",
          configId: input[symbolArcjetInternal].configId,
          inputId: input[symbolArcjetInternal].inputId,
          type: GuardRuleType.PROMPT_INJECTION,
          result: {
            case: "promptInjection",
            value: create(ResultPromptInjectionSchema, {
              conclusion: GuardConclusion.DENY,
              detected: true,
            }),
          },
        },
      ],
      GuardReason.PROMPT_INJECTION,
    );

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "DENY");
    if (decision.conclusion === "DENY") {
      assert.equal(decision.reason, "PROMPT_INJECTION");
    }
  });

  test("DENY decision with sensitive info result", () => {
    const rule = localDetectSensitiveInfo();
    const input = rule("my phone is 555-123-4567");

    const response = makeResponse(
      GuardConclusion.DENY,
      [
        {
          resultId: "gres_test1",
          configId: input[symbolArcjetInternal].configId,
          inputId: input[symbolArcjetInternal].inputId,
          type: GuardRuleType.LOCAL_SENSITIVE_INFO,
          result: {
            case: "localSensitiveInfo",
            value: create(ResultLocalSensitiveInfoSchema, {
              conclusion: GuardConclusion.DENY,
              detected: true,
              detectedEntityTypes: ["PHONE_NUMBER"],
            }),
          },
        },
      ],
      GuardReason.SENSITIVE_INFO,
    );

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "DENY");
    const result = input.result(decision);
    assert.ok(result);
    assert.equal(result.type, "SENSITIVE_INFO");
    assert.deepEqual(result.detectedEntityTypes, ["PHONE_NUMBER"]);
  });

  test("ALLOW decision with custom result", () => {
    const rule = defineCustomRule({ evaluate: () => ({ conclusion: "ALLOW" as const }) })({
      data: { threshold: "0.5" },
    });
    const input = rule({ data: { score: "0.3" } });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.LOCAL_CUSTOM,
        result: {
          case: "localCustom",
          value: create(ResultLocalCustomSchema, {
            conclusion: GuardConclusion.ALLOW,
            data: { key: "value" },
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "ALLOW");
    const result = input.result(decision);
    assert.ok(result);
    assert.equal(result.type, "CUSTOM");
    assert.deepEqual(result.data, { key: "value" });
  });

  test("error result is fail-open", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "error",
          value: create(ResultErrorSchema, {
            message: "evaluator timeout",
            code: "TIMEOUT",
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "ALLOW");
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), true);
    // An errored rule is a rule-level error: it fails open to ALLOW, so the
    // decision did fail open, and the errored result is inspectable.
    assert.equal(decision.hasFailedOpen(), true);
    assert.equal(decision.errorResults().length, 1);
    assert.equal(decision.errorResults()[0].code, "TIMEOUT");
    assert.equal(decision.errorResults()[0].message, "evaluator timeout");
    // No request-level diagnostics on this response.
    assert.equal(decision.warnings.length, 0);
    // Per-rule warnings channel is empty until the service populates it.
    assert.equal(decision.results[0].warnings.length, 0);
    assert.equal(decision.results[0].type, "RULE_ERROR");
    if (decision.results[0].type === "RULE_ERROR") {
      assert.equal(decision.results[0].message, "evaluator timeout");
      assert.equal(decision.results[0].code, "TIMEOUT");
    }
  });

  test("notRun result is mapped correctly", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "notRun",
          value: create(ResultNotRunSchema, {}),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);
    assert.equal(decision.results[0].type, "NOT_RUN");
    assert.equal(decision.results[0].conclusion, "ALLOW");
  });

  test("missing decision synthesizes ALLOW with error", () => {
    const response = create(GuardResponseSchema, {});
    const decision = decisionFromProto(response, []);

    assert.equal(decision.conclusion, "ALLOW");
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), true);
    // A missing decision is a decision-level error: fail-open ALLOW with a
    // single synthetic error result the caller can detect.
    assert.equal(decision.hasFailedOpen(), true);
    assert.equal(decision.errorResults().length, 1);
    assert.equal(decision.errorResults()[0].code, "NO_DECISION");
  });

  test("unrecognized result case maps to UNKNOWN", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.UNSPECIFIED,
      },
    ]);

    const decision = decisionFromProto(response, [input]);
    assert.equal(decision.results[0].type, "UNKNOWN");
    assert.equal(decision.results[0].reason, "UNKNOWN");
  });

  test("empty results array produces ALLOW with no results", () => {
    const response = makeResponse(GuardConclusion.ALLOW, []);
    const decision = decisionFromProto(response, []);

    assert.equal(decision.conclusion, "ALLOW");
    assert.equal(decision.results.length, 0);
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), false);
    // Clean ALLOW: no errored results, so it did not fail open.
    assert.equal(decision.hasFailedOpen(), false);
    assert.equal(decision.errorResults().length, 0);
    assert.equal(decision.warnings.length, 0);
  });

  test("response errors surface as decision-level warnings", () => {
    // The proto's `GuardResponse.errors` carries non-fatal request-validation
    // diagnostics. The SDK surfaces them as warnings instead of dropping them.
    const response = create(GuardResponseSchema, {
      errors: [
        create(ResultErrorSchema, { code: "AJ1100", message: "stripped invalid metadata key" }),
      ],
      decision: create(GuardDecisionSchema, {
        id: "gdec_test123",
        conclusion: GuardConclusion.ALLOW,
        reason: GuardReason.UNSPECIFIED,
        ruleResults: [],
      }),
    });

    const decision = decisionFromProto(response, []);

    // The decision is still valid (ALLOW, nothing errored) — it did NOT fail
    // open. The diagnostic is a warning.
    assert.equal(decision.conclusion, "ALLOW");
    assert.equal(decision.hasFailedOpen(), false);
    assert.equal(decision.errorResults().length, 0);
    assert.equal(decision.warnings.length, 1);
    assert.equal(decision.warnings[0].code, "AJ1100");
    assert.equal(decision.warnings[0].message, "stripped invalid metadata key");
    // The deprecated union still fires on warnings (back-compat).
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), true);
  });

  test("DENY with an errored rule does not fail open", () => {
    // Another rule denied, so the ALLOW masking an error did not happen —
    // hasFailedOpen() must be false even though a rule errored.
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(GuardConclusion.DENY, [
      {
        resultId: "gres_test1",
        configId: input[symbolArcjetInternal].configId,
        inputId: input[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "error",
          value: create(ResultErrorSchema, { message: "evaluator timeout", code: "TIMEOUT" }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [input]);

    assert.equal(decision.conclusion, "DENY");
    assert.equal(decision.hasFailedOpen(), false);
    // The errored result is still inspectable.
    assert.equal(decision.errorResults().length, 1);
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), true);
  });

  test("combined decision warning and rule error keep severity separate", () => {
    // The four-channel cell: a decision-level warning AND a rule-level error
    // on the same decision. Severity is carried by channel, not inferred.
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = create(GuardResponseSchema, {
      errors: [
        create(ResultErrorSchema, { code: "AJ1100", message: "stripped invalid metadata key" }),
      ],
      decision: create(GuardDecisionSchema, {
        id: "gdec_test123",
        conclusion: GuardConclusion.ALLOW,
        reason: GuardReason.UNSPECIFIED,
        ruleResults: [
          create(GuardRuleResultSchema, {
            resultId: "gres_test1",
            configId: input[symbolArcjetInternal].configId,
            inputId: input[symbolArcjetInternal].inputId,
            type: GuardRuleType.TOKEN_BUCKET,
            result: {
              case: "error",
              value: create(ResultErrorSchema, { message: "evaluator timeout", code: "TIMEOUT" }),
            },
          }),
        ],
      }),
    });

    const decision = decisionFromProto(response, [input]);

    // Warning channel.
    assert.equal(decision.warnings.length, 1);
    assert.equal(decision.warnings[0].code, "AJ1100");
    // Error channel.
    assert.equal(decision.errorResults().length, 1);
    assert.equal(decision.errorResults()[0].code, "TIMEOUT");
    // ALLOW masking an error → failed open.
    assert.equal(decision.hasFailedOpen(), true);
    // Deprecated union fires on both.
    // oxlint-disable-next-line typescript/no-deprecated -- back-compat coverage of the deprecated hasError()
    assert.equal(decision.hasError(), true);
  });

  test("malformed warning fields are coerced at the boundary", () => {
    // Connect-JSON on the wire can deliver a non-string where a string is
    // expected (a malformed or proxied response). The SDK validates at the
    // boundary rather than trusting the wire shape. Build a valid response via
    // `create()`, then inject non-string values through a widening view of the
    // error element (no narrowing assertion on the response itself).
    const response = create(GuardResponseSchema, {
      errors: [create(ResultErrorSchema, { code: "ok", message: "ok" })],
      decision: {
        id: "gdec_test123",
        conclusion: GuardConclusion.ALLOW,
        reason: GuardReason.UNSPECIFIED,
        ruleResults: [],
      },
    });
    // Inject non-string values through a widening view of the error element.
    const malformed: { code: unknown; message: unknown } = response.errors[0];
    malformed.code = 42;
    malformed.message = null;

    const decision = decisionFromProto(response, []);

    assert.equal(decision.warnings.length, 1);
    // Non-string code/message fall back to defaults rather than surfacing the
    // raw wire values.
    assert.equal(decision.warnings[0].code, "UNKNOWN");
    assert.equal(decision.warnings[0].message, "Unknown warning");
  });

  test("multi-rule correlation — results and deniedResult", () => {
    const rateLimit = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const rl1 = rateLimit({ key: "alice" });
    const rl2 = rateLimit({ key: "bob" });
    const prompt = detectPromptInjection();
    const pi = prompt("some text");

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_1",
        configId: rl1[symbolArcjetInternal].configId,
        inputId: rl1[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "tokenBucket",
          value: create(ResultTokenBucketSchema, {
            conclusion: GuardConclusion.ALLOW,
            remainingTokens: 95,
            maxTokens: 100,
            resetAtUnixSeconds: 60,
            refillRate: 10,
            refillIntervalSeconds: 60,
          }),
        },
      },
      {
        resultId: "gres_2",
        configId: rl2[symbolArcjetInternal].configId,
        inputId: rl2[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "tokenBucket",
          value: create(ResultTokenBucketSchema, {
            conclusion: GuardConclusion.ALLOW,
            remainingTokens: 90,
            maxTokens: 100,
            resetAtUnixSeconds: 58,
            refillRate: 10,
            refillIntervalSeconds: 60,
          }),
        },
      },
      {
        resultId: "gres_3",
        configId: pi[symbolArcjetInternal].configId,
        inputId: pi[symbolArcjetInternal].inputId,
        type: GuardRuleType.PROMPT_INJECTION,
        result: {
          case: "promptInjection",
          value: create(ResultPromptInjectionSchema, {
            conclusion: GuardConclusion.ALLOW,
            detected: false,
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [rl1, rl2, pi]);

    assert.equal(decision.results.length, 3);

    // RuleWithConfig.results() returns both token bucket results
    const rlResults = rateLimit.results(decision);
    assert.equal(rlResults.length, 2);
    assert.equal(rlResults[0].type, "TOKEN_BUCKET");
    assert.equal(rlResults[1].type, "TOKEN_BUCKET");

    // RuleWithInput.result() returns specific result
    const r1 = rl1.result(decision);
    assert.ok(r1);
    assert.equal(r1.remainingTokens, 95);
    const r2 = rl2.result(decision);
    assert.ok(r2);
    assert.equal(r2.remainingTokens, 90);

    // RuleWithConfig.deniedResult() returns null when no denials
    assert.equal(rateLimit.deniedResult(decision), null);
  });

  test("result() returns null for inputs not in the decision", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const submitted = rule({ key: "alice" });
    const notSubmitted = rule({ key: "charlie" });

    const response = makeResponse(GuardConclusion.ALLOW, [
      {
        resultId: "gres_1",
        configId: submitted[symbolArcjetInternal].configId,
        inputId: submitted[symbolArcjetInternal].inputId,
        type: GuardRuleType.TOKEN_BUCKET,
        result: {
          case: "tokenBucket",
          value: create(ResultTokenBucketSchema, {
            conclusion: GuardConclusion.ALLOW,
            remainingTokens: 95,
            maxTokens: 100,
            resetAtUnixSeconds: 60,
            refillRate: 10,
            refillIntervalSeconds: 60,
          }),
        },
      },
    ]);

    const decision = decisionFromProto(response, [submitted]);
    assert.equal(notSubmitted.result(decision), null);
  });

  test("deniedResult() on RuleWithConfig returns first deny", () => {
    const rule = tokenBucket({
      bucket: "test",
      refillRate: 10,
      intervalSeconds: 60,
      maxTokens: 100,
    });
    const input = rule({ key: "user_1" });

    const response = makeResponse(
      GuardConclusion.DENY,
      [
        {
          resultId: "gres_1",
          configId: input[symbolArcjetInternal].configId,
          inputId: input[symbolArcjetInternal].inputId,
          type: GuardRuleType.TOKEN_BUCKET,
          result: {
            case: "tokenBucket",
            value: create(ResultTokenBucketSchema, {
              conclusion: GuardConclusion.DENY,
              remainingTokens: 0,
              maxTokens: 100,
              resetAtUnixSeconds: 60,
              refillRate: 10,
              refillIntervalSeconds: 60,
            }),
          },
        },
      ],
      GuardReason.RATE_LIMIT,
    );

    const decision = decisionFromProto(response, [input]);
    const denied = rule.deniedResult(decision);
    assert.ok(denied);
    assert.equal(denied.conclusion, "DENY");
    assert.equal(denied.remainingTokens, 0);
  });
});
