import assert from "node:assert/strict";
import { createHash } from "node:crypto";

const PROTECTED_INTERFACE_TERM =
  /(?:^|_)(?:access|custod(?:y|ian)?|disput(?:e|es)|arbitrat(?:e|ion)|adjudicat(?:e|ion)|eligib(?:le|ility)|qualif(?:y|ied|ication)|pric(?:e|ing)|rout(?:e|er|ing)|settle(?:ment)?|slash(?:ed|ing)?|penalt(?:y|ies)|forfeit(?:ed|ure)?|payment|payout|recipient|destination|value_transfer)(?:_|$)/i;

function stripRustTrivia(source, preserveStrings = false) {
  let result = "";
  let state = "code";
  for (let index = 0; index < source.length; index += 1) {
    const current = source[index];
    const next = source[index + 1];
    if (state === "code" && current === "/" && next === "/") {
      state = "line-comment";
      result += "  ";
      index += 1;
    } else if (state === "code" && current === "/" && next === "*") {
      state = "block-comment";
      result += "  ";
      index += 1;
    } else if (state === "line-comment" && current === "\n") {
      state = "code";
      result += "\n";
    } else if (state === "block-comment" && current === "*" && next === "/") {
      state = "code";
      result += "  ";
      index += 1;
    } else if (state === "code" && current === '"') {
      state = "string";
      result += preserveStrings ? current : " ";
    } else if (state === "string" && current === "\\" && next !== undefined) {
      result += preserveStrings ? `${current}${next}` : "  ";
      index += 1;
    } else if (state === "string" && current === '"') {
      state = "code";
      result += preserveStrings ? current : " ";
    } else {
      result +=
        state === "code" || (state === "string" && preserveStrings)
          ? current
          : current === "\n"
            ? "\n"
            : " ";
    }
  }
  assert.equal(state === "block-comment", false, "unterminated Rust block comment");
  assert.equal(state === "string", false, "unterminated Rust string");
  return result;
}

function balancedBody(text, openingIndex) {
  assert.equal(text[openingIndex], "{");
  let depth = 0;
  for (let index = openingIndex; index < text.length; index += 1) {
    if (text[index] === "{") depth += 1;
    if (text[index] === "}") {
      depth -= 1;
      if (depth === 0) return text.slice(openingIndex + 1, index);
    }
  }
  assert.fail("unbalanced Rust interface body");
}

function splitTopLevelLegacy(value) {
  const entries = [];
  let start = 0;
  let depth = 0;
  for (let index = 0; index < value.length; index += 1) {
    if ("<([{".includes(value[index])) depth += 1;
    if (">)]}".includes(value[index])) depth -= 1;
    if (value[index] === "," && depth === 0) {
      entries.push(value.slice(start, index));
      start = index + 1;
    }
  }
  entries.push(value.slice(start));
  return entries.map((entry) => entry.trim()).filter(Boolean);
}

function splitTopLevel(value) {
  const entries = [];
  let start = 0;
  let depth = 0;
  let inString = false;
  for (let index = 0; index < value.length; index += 1) {
    const current = value[index];
    if (inString && current === "\\") {
      index += 1;
      continue;
    }
    if (current === '"') {
      inString = !inString;
      continue;
    }
    if (inString) continue;
    if ("<([{".includes(current)) depth += 1;
    if (">)]}".includes(current)) depth -= 1;
    if (current === "," && depth === 0) {
      entries.push(value.slice(start, index));
      start = index + 1;
    }
  }
  assert.equal(inString, false, "unterminated string in Rust interface list");
  assert.equal(depth, 0, "unbalanced Rust interface list");
  entries.push(value.slice(start));
  return entries.map((entry) => entry.trim()).filter(Boolean);
}

function normalizeType(value) {
  return value.replace(/\s+/g, "");
}

function canonicalSeedPath(path) {
  return path
    .split(".")
    .map((segment) => snakeCase(segment))
    .join(".");
}

function accountTypeName(type) {
  return type.match(/^(?:Account|AccountLoader)<'info,([a-zA-Z_]\w*)>$/)?.[1] ?? null;
}

function sourceSeed(expression, fieldsByName) {
  const value = expression.trim();
  const byteString = value.match(/^b("(?:\\.|[^"\\])*")$/);
  if (byteString) {
    let decoded;
    try {
      decoded = JSON.parse(byteString[1]);
    } catch {
      assert.fail(`unsupported Rust byte-string PDA seed ${value}`);
    }
    return { kind: "const", value: [...Buffer.from(decoded, "utf8")] };
  }

  const keyAccount = value.match(/^([a-zA-Z_]\w*)\.key\(\)\.as_ref\(\)$/);
  if (keyAccount) return { kind: "account", path: canonicalSeedPath(keyAccount[1]) };

  const accountPath = value.match(
    /^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+?)(?:\.to_le_bytes\(\))?\.as_ref\(\)$/,
  );
  assert.ok(accountPath, `unsupported canonical PDA seed expression ${value}`);
  const path = canonicalSeedPath(accountPath[1]);
  const root = accountPath[1].split(".")[0];
  const account = accountTypeName(fieldsByName.get(root) ?? "");
  return {
    kind: "account",
    path,
    ...(account ? { account: snakeCase(account) } : {}),
  };
}

function extractPdaMetadata(constraints, fieldsByName) {
  const match = /\bseeds\s*=\s*\[/.exec(constraints);
  if (!match) return null;
  const opening = match.index + match[0].lastIndexOf("[");
  let depth = 0;
  let inString = false;
  let closing = -1;
  for (let index = opening; index < constraints.length; index += 1) {
    const current = constraints[index];
    if (inString && current === "\\") {
      index += 1;
      continue;
    }
    if (current === '"') {
      inString = !inString;
      continue;
    }
    if (inString) continue;
    if (current === "[") depth += 1;
    if (current === "]") {
      depth -= 1;
      if (depth === 0) {
        closing = index;
        break;
      }
    }
  }
  assert.notEqual(closing, -1, "unbalanced PDA seed list");
  const seeds = splitTopLevel(constraints.slice(opening + 1, closing)).map((seed) =>
    sourceSeed(seed, fieldsByName),
  );
  const programExpression = /\bseeds::program\s*=\s*([^,\n]+)/.exec(constraints)?.[1];
  return {
    seeds,
    program: programExpression ? sourceSeed(programExpression, fieldsByName) : null,
  };
}

function extractInstructions(source) {
  const marker = source.indexOf("#[program]");
  assert.notEqual(marker, -1, "Anchor program marker is required");
  const module = source.indexOf("pub mod", marker);
  const opening = source.indexOf("{", module);
  const body = balancedBody(source, opening);
  const instructions = [];
  const pattern = /\bpub\s+fn\s+([a-zA-Z_]\w*)\s*\(/g;
  let match;
  while ((match = pattern.exec(body)) !== null) {
    const argsStart = pattern.lastIndex;
    let depth = 1;
    let cursor = argsStart;
    while (cursor < body.length && depth > 0) {
      if (body[cursor] === "(") depth += 1;
      if (body[cursor] === ")") depth -= 1;
      cursor += 1;
    }
    assert.equal(depth, 0, `unbalanced arguments for ${match[1]}`);
    const rawArguments = splitTopLevel(body.slice(argsStart, cursor - 1));
    const contextArgument = rawArguments.find((argument) => /^_?ctx\s*:/.test(argument));
    assert.ok(contextArgument, `${match[1]} must declare an Anchor Context`);
    const contextSeparator = contextArgument.indexOf(":");
    const context = normalizeType(contextArgument.slice(contextSeparator + 1));
    const args = rawArguments
      .filter((argument) => argument !== contextArgument)
      .map((argument) => {
        const separator = argument.indexOf(":");
        assert.notEqual(separator, -1, `untyped instruction argument: ${argument}`);
        return [
          argument.slice(0, separator).trim(),
          normalizeType(argument.slice(separator + 1)),
        ];
      });
    instructions.push({ name: match[1], context, args });
    pattern.lastIndex = cursor;
  }
  return instructions;
}

function extractRecords(source) {
  const records = [];
  const pattern =
    /#\s*\[\s*(account|event)\s*\]\s*pub\s+struct\s+([a-zA-Z_]\w*)\s*\{/g;
  let match;
  while ((match = pattern.exec(source)) !== null) {
    const opening = pattern.lastIndex - 1;
    const body = balancedBody(source, opening);
    const fields = splitTopLevel(body).map((field) => {
      const fieldMatch = field.match(/\bpub\s+([a-zA-Z_]\w*)\s*:\s*([\s\S]+)$/);
      assert.ok(fieldMatch, `unrecognized public field in ${match[2]}: ${field}`);
      return [fieldMatch[1], normalizeType(fieldMatch[2])];
    });
    records.push({ kind: match[1], name: match[2], fields });
    pattern.lastIndex = opening + body.length + 2;
  }
  const accountsPattern =
    /#\s*\[\s*derive\s*\(\s*Accounts\s*\)\s*\]\s*pub\s+struct\s+([a-zA-Z_]\w*)(?:\s*<[^>]+>)?\s*\{/g;
  while ((match = accountsPattern.exec(source)) !== null) {
    const opening = accountsPattern.lastIndex - 1;
    const body = balancedBody(source, opening);
    const accountMetadata = [];
    const parsedFields = splitTopLevel(body).map((field) => {
      const fieldMatch = field.match(/\bpub\s+([a-zA-Z_]\w*)\s*:\s*([\s\S]+)$/);
      assert.ok(fieldMatch, `unrecognized account-context field in ${match[1]}: ${field}`);
      const name = fieldMatch[1];
      const type = normalizeType(fieldMatch[2]);
      const constraints = field.slice(0, fieldMatch.index);
      return { name, type, constraints };
    });
    const fieldsByName = new Map(parsedFields.map(({ name, type }) => [name, type]));
    const fields = parsedFields.map(({ name, type, constraints }) => {
      accountMetadata.push({
        name,
        writable: /\b(?:mut|init|zero|realloc)\b|\bclose\s*=/.test(constraints),
        signer: /^Signer</.test(type) || /\bsigner\b/.test(constraints),
        pda: extractPdaMetadata(constraints, fieldsByName),
        relations: [],
      });
      return [name, type];
    });
    // Anchor records `has_one = target` on the target account's IDL entry,
    // pointing back to the source account that contains the checked Pubkey.
    // Preserve that direction rather than attaching `target` to the constrained
    // source account.
    for (const { name, constraints } of parsedFields) {
      for (const relation of constraints.matchAll(
        /\bhas_one\s*=\s*([a-zA-Z_]\w*)/g,
      )) {
        const target = accountMetadata.find(
          (metadata) => metadata.name === relation[1],
        );
        assert.ok(target, `has_one target ${relation[1]} is missing from ${match[1]}`);
        target.relations.push(name);
      }
    }
    const record = { kind: "accounts", name: match[1], fields };
    // Keep the original structural digest stable. Full source hashes already pin
    // constraints, while this derived metadata is used for generated-IDL checks.
    Object.defineProperty(record, "accountMetadata", {
      value: accountMetadata,
      enumerable: false,
    });
    records.push(record);
    accountsPattern.lastIndex = opening + body.length + 2;
  }
  return records;
}

function extractDefinedTypes(source) {
  const definitions = [];
  const enumPattern =
    /#\s*\[\s*derive\s*\([^)]*\bAnchorSerialize\b[^)]*\)\s*\]\s*pub\s+enum\s+([a-zA-Z_]\w*)\s*\{/g;
  let match;
  while ((match = enumPattern.exec(source)) !== null) {
    const opening = enumPattern.lastIndex - 1;
    const body = balancedBody(source, opening);
    const variants = splitTopLevel(body).map((variant) => {
      const simple = variant.match(/^([a-zA-Z_]\w*)$/);
      assert.ok(simple, `unsupported enum variant shape in ${match[1]}: ${variant}`);
      return simple[1];
    });
    definitions.push({ kind: "enum", name: match[1], variants });
    enumPattern.lastIndex = opening + body.length + 2;
  }
  return definitions;
}

function extractErrorCodes(source) {
  const errors = [];
  const pattern =
    /#\s*\[\s*error_code\s*\]\s*pub\s+enum\s+([a-zA-Z_]\w*)\s*\{/g;
  let match;
  while ((match = pattern.exec(source)) !== null) {
    const opening = pattern.lastIndex - 1;
    const body = balancedBody(source, opening);
    for (const variant of splitTopLevel(body)) {
      const name = variant.match(/([a-zA-Z_]\w*)$/)?.[1];
      assert.ok(name, `unsupported error variant in ${match[1]}: ${variant}`);
      errors.push(name);
    }
    pattern.lastIndex = opening + body.length + 2;
  }
  return errors;
}

export function extractSourceInterface(source) {
  const code = stripRustTrivia(source);
  const codeWithStrings = stripRustTrivia(source, true);
  const sourceInterface = {
    instructions: extractInstructions(code),
    records: extractRecords(codeWithStrings),
  };
  Object.defineProperty(sourceInterface, "definedTypes", {
    value: extractDefinedTypes(code),
    enumerable: false,
  });
  Object.defineProperty(sourceInterface, "errors", {
    value: extractErrorCodes(codeWithStrings),
    enumerable: false,
  });
  return sourceInterface;
}

export function sourceInterfaceDigest(source) {
  return createHash("sha256")
    .update(JSON.stringify(extractSourceInterface(source)))
    .digest("hex");
}

function canonicalIdentifiers(sourceInterface) {
  return new Set([
    ...sourceInterface.instructions.flatMap((instruction) => [
      instruction.name,
      ...instruction.args.flatMap(([name, type]) => [name, type]),
    ]),
    ...sourceInterface.records.flatMap((record) => [
      record.name,
      ...record.fields.flatMap(([name, type]) => [name, type]),
    ]),
    ...(sourceInterface.errors ?? []),
  ]);
}

export function assertNoProtectedInterfaceDrift(
  candidateInterface,
  canonicalInterface,
  label,
) {
  const allowed = canonicalIdentifiers(canonicalInterface);
  for (const identifier of canonicalIdentifiers(candidateInterface)) {
    if (PROTECTED_INTERFACE_TERM.test(identifier)) {
      assert.ok(allowed.has(identifier), `${label} adds protected interface identifier ${identifier}`);
    }
  }
}

function snakeCase(value) {
  return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}

function normalizeIdlType(type) {
  if (typeof type === "string") return type.toLowerCase();
  assert.ok(type && typeof type === "object", `invalid IDL type ${JSON.stringify(type)}`);
  if (Object.hasOwn(type, "vec")) return { vec: normalizeIdlType(type.vec) };
  if (Object.hasOwn(type, "option")) return { option: normalizeIdlType(type.option) };
  if (Object.hasOwn(type, "array")) {
    assert.ok(Array.isArray(type.array) && type.array.length === 2, "invalid IDL array type");
    return { array: [normalizeIdlType(type.array[0]), type.array[1]] };
  }
  if (Object.hasOwn(type, "defined")) {
    const name =
      typeof type.defined === "string" ? type.defined : type.defined?.name;
    assert.equal(typeof name, "string", "invalid IDL defined type");
    return { defined: snakeCase(name) };
  }
  assert.fail(`unsupported IDL type ${JSON.stringify(type)}`);
}

function sourceTypeToIdl(type) {
  const primitive = new Map([
    ["bool", "bool"],
    ["String", "string"],
    ["Pubkey", "pubkey"],
    ["u8", "u8"],
    ["u16", "u16"],
    ["u32", "u32"],
    ["u64", "u64"],
    ["u128", "u128"],
    ["i8", "i8"],
    ["i16", "i16"],
    ["i32", "i32"],
    ["i64", "i64"],
    ["i128", "i128"],
  ]);
  if (primitive.has(type)) return primitive.get(type);
  const vector = type.match(/^Vec<(.+)>$/);
  if (vector) return { vec: sourceTypeToIdl(vector[1]) };
  const option = type.match(/^Option<(.+)>$/);
  if (option) return { option: sourceTypeToIdl(option[1]) };
  const array = type.match(/^\[(.+);(\d+)\]$/);
  if (array) return { array: [sourceTypeToIdl(array[1]), Number(array[2])] };
  assert.match(type, /^[a-zA-Z_]\w*$/, `unsupported canonical Rust IDL type ${type}`);
  return { defined: snakeCase(type) };
}

function idlBoolean(account, modern, legacy) {
  if (Object.hasOwn(account, modern) && Object.hasOwn(account, legacy)) {
    assert.equal(account[modern], account[legacy], `conflicting ${modern}/${legacy} flags`);
  }
  return Boolean(account[modern] ?? account[legacy] ?? false);
}

function normalizeIdlSeed(seed, label) {
  assert.ok(seed && typeof seed === "object" && !Array.isArray(seed), `${label} seed is invalid`);
  assert.equal(typeof seed.kind, "string", `${label} seed kind is required`);
  if (seed.kind === "const") {
    assert.deepEqual(
      Object.keys(seed).sort(),
      ["kind", "value"],
      `${label} const seed fields are not canonical`,
    );
    assert.ok(
      Array.isArray(seed.value) &&
        seed.value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255),
      `${label} const seed value must be bytes`,
    );
    return { kind: "const", value: [...seed.value] };
  }
  if (seed.kind === "account") {
    assert.deepEqual(
      Object.keys(seed).sort(),
      Object.hasOwn(seed, "account") ? ["account", "kind", "path"] : ["kind", "path"],
      `${label} account seed fields are not canonical`,
    );
    assert.equal(typeof seed.path, "string", `${label} account seed path is required`);
    if (Object.hasOwn(seed, "account")) {
      assert.equal(typeof seed.account, "string", `${label} account seed reference is invalid`);
    }
    return {
      kind: "account",
      path: canonicalSeedPath(seed.path),
      ...(Object.hasOwn(seed, "account") ? { account: snakeCase(seed.account) } : {}),
    };
  }
  assert.fail(`${label} has unsupported PDA seed kind ${seed.kind}`);
}

function normalizeIdlPda(pda, label) {
  if (pda === undefined) return null;
  assert.ok(pda && typeof pda === "object" && !Array.isArray(pda), `${label} PDA is invalid`);
  assert.deepEqual(
    Object.keys(pda).sort(),
    Object.hasOwn(pda, "program") ? ["program", "seeds"] : ["seeds"],
    `${label} PDA fields are not canonical`,
  );
  assert.ok(Array.isArray(pda.seeds), `${label} PDA seeds are required`);
  return {
    seeds: pda.seeds.map((seed, index) => normalizeIdlSeed(seed, `${label}.seeds[${index}]`)),
    program: Object.hasOwn(pda, "program")
      ? normalizeIdlSeed(pda.program, `${label}.program`)
      : null,
  };
}

function normalizeIdlFields(fields, label) {
  assert.ok(Array.isArray(fields), `${label} fields are required`);
  return fields.map((field) => {
    assert.equal(typeof field?.name, "string", `${label} field name is required`);
    return [snakeCase(field.name), normalizeIdlType(field.type)];
  });
}

function expectedDefinedLayouts(canonicalInterface) {
  const definitions = new Map();
  for (const record of canonicalInterface.records.filter((entry) =>
    ["account", "event"].includes(entry.kind),
  )) {
    definitions.set(snakeCase(record.name), {
      kind: "struct",
      fields: record.fields.map(([name, type]) => [name, sourceTypeToIdl(type)]),
      sourceKind: record.kind,
    });
  }
  for (const definition of canonicalInterface.definedTypes ?? []) {
    definitions.set(snakeCase(definition.name), {
      kind: "enum",
      variants: definition.variants.map(snakeCase),
      sourceKind: "defined",
    });
  }
  return definitions;
}

function actualDefinedLayouts(idl, expectedNames, programName) {
  const definitions = new Map();
  const add = (name, type, sourceKind) => {
    const normalizedName = snakeCase(name);
    if (!expectedNames.has(normalizedName)) {
      assert.fail(`${programName} IDL contains unexpected defined type ${normalizedName}`);
    }
    assert.equal(definitions.has(normalizedName), false, `${programName} IDL duplicates ${normalizedName}`);
    assert.ok(type && typeof type === "object", `${programName}.${normalizedName} layout is required`);
    if (type.kind === "struct") {
      definitions.set(normalizedName, {
        kind: "struct",
        fields: normalizeIdlFields(type.fields, `${programName}.${normalizedName}`),
        sourceKind,
      });
      return;
    }
    if (type.kind === "enum") {
      assert.ok(Array.isArray(type.variants), `${programName}.${normalizedName} enum variants required`);
      definitions.set(normalizedName, {
        kind: "enum",
        variants: type.variants.map((variant) => {
          assert.equal(
            Object.hasOwn(variant, "fields"),
            false,
            `${programName}.${normalizedName} enum payload variants are not canonical`,
          );
          return snakeCase(variant.name);
        }),
        sourceKind,
      });
      return;
    }
    assert.fail(`${programName}.${normalizedName} has unsupported IDL kind ${type.kind}`);
  };

  for (const definition of idl.types ?? []) add(definition.name, definition.type, "defined");
  for (const account of idl.accounts ?? []) {
    if (account.type && !definitions.has(snakeCase(account.name))) {
      add(account.name, account.type, "account");
    }
  }
  for (const event of idl.events ?? []) {
    if (event.fields && !definitions.has(snakeCase(event.name))) {
      add(event.name, { kind: "struct", fields: event.fields }, "event");
    }
  }
  return definitions;
}

export function assertIdlInterface(idl, program, canonicalInterface) {
  assert.ok(Array.isArray(idl.instructions), `${program.name} IDL instructions are required`);
  const expected = program.allowedInstructions;
  const actual = idl.instructions.map((instruction) => snakeCase(instruction.name)).sort();
  assert.deepEqual(actual, [...expected].sort(), `${program.name} IDL instruction allowlist drift`);
  assert.deepEqual(
    (idl.errors ?? []).map((error) => snakeCase(error.name)).sort(),
    (canonicalInterface.errors ?? []).map(snakeCase).sort(),
    `${program.name} IDL error allowlist drift`,
  );

  const sourceByName = new Map(
    canonicalInterface.instructions.map((instruction) => [instruction.name, instruction]),
  );
  const recordsByName = new Map(
    canonicalInterface.records.map((record) => [record.name, record]),
  );
  for (const idlInstruction of idl.instructions) {
    const name = snakeCase(idlInstruction.name);
    const sourceInstruction = sourceByName.get(name);
    assert.ok(sourceInstruction, `${program.name} IDL contains unknown instruction ${name}`);
    const expectedArguments = sourceInstruction.args.map(([argument, type]) => [
      argument,
      sourceTypeToIdl(type),
    ]);
    const actualArguments = (idlInstruction.args ?? []).map((argument) =>
      [snakeCase(argument.name), normalizeIdlType(argument.type)],
    );
    assert.deepEqual(
      actualArguments,
      expectedArguments,
      `${program.name}.${name} IDL argument allowlist drift`,
    );

    const contextName = sourceInstruction.context.match(/^Context<([a-zA-Z_]\w*)>$/)?.[1];
    assert.ok(contextName, `${program.name}.${name} has a noncanonical Context type`);
    const contextRecord = recordsByName.get(contextName);
    assert.ok(contextRecord, `${program.name}.${name} context ${contextName} is missing`);
    const expectedAccounts = contextRecord.fields.map(([field, type], index) => {
      const metadata = contextRecord.accountMetadata?.[index] ?? {
        writable: false,
        signer: /^Signer</.test(type),
        pda: null,
        relations: [],
      };
      return {
        name: field,
        writable: metadata.writable,
        signer: metadata.signer,
        pda: metadata.pda,
        relations: metadata.relations,
      };
    });
    const actualAccounts = (idlInstruction.accounts ?? []).map((account) => ({
      name: snakeCase(account.name),
      writable: idlBoolean(account, "writable", "isMut"),
      signer: idlBoolean(account, "signer", "isSigner"),
      pda: normalizeIdlPda(account.pda, `${program.name}.${name}.${account.name}`),
      relations: (account.relations ?? []).map(snakeCase),
    }));
    assert.deepEqual(
      actualAccounts,
      expectedAccounts,
      `${program.name}.${name} IDL account allowlist drift`,
    );
  }

  const expectedLayouts = expectedDefinedLayouts(canonicalInterface);
  const expectedAccountTypes = canonicalInterface.records
    .filter((record) => record.kind === "account")
    .map((record) => snakeCase(record.name))
    .sort();
  const expectedEventTypes = canonicalInterface.records
    .filter((record) => record.kind === "event")
    .map((record) => snakeCase(record.name))
    .sort();
  assert.deepEqual(
    (idl.accounts ?? []).map((account) => snakeCase(account.name)).sort(),
    expectedAccountTypes,
    `${program.name} IDL account-type allowlist drift`,
  );
  assert.deepEqual(
    (idl.events ?? []).map((event) => snakeCase(event.name)).sort(),
    expectedEventTypes,
    `${program.name} IDL event allowlist drift`,
  );
  const actualLayouts = actualDefinedLayouts(idl, new Set(expectedLayouts.keys()), program.name);
  assert.deepEqual(
    [...actualLayouts.keys()].sort(),
    [...expectedLayouts.keys()].sort(),
    `${program.name} IDL defined account/event/type allowlist drift`,
  );
  for (const [name, expectedLayout] of expectedLayouts) {
    const actualLayout = actualLayouts.get(name);
    assert.ok(actualLayout, `${program.name} IDL layout missing for ${name}`);
    assert.equal(actualLayout.kind, expectedLayout.kind, `${program.name}.${name} IDL kind drift`);
    if (expectedLayout.kind === "struct") {
      assert.deepEqual(
        actualLayout.fields,
        expectedLayout.fields,
        `${program.name}.${name} IDL field layout drift`,
      );
    } else {
      assert.deepEqual(
        actualLayout.variants,
        expectedLayout.variants,
        `${program.name}.${name} IDL enum drift`,
      );
    }
  }

  const allowed = canonicalIdentifiers(canonicalInterface);
  const visit = (value, path = "idl") => {
    if (Array.isArray(value)) {
      value.forEach((entry, index) => visit(entry, `${path}[${index}]`));
    } else if (value && typeof value === "object") {
      for (const [key, entry] of Object.entries(value)) {
        if ((key === "name" || key === "type") && typeof entry === "string") {
          const normalized = snakeCase(entry);
          if (PROTECTED_INTERFACE_TERM.test(normalized)) {
            assert.ok(
              allowed.has(entry) || allowed.has(normalized),
              `${program.name} IDL adds protected field/type ${entry} at ${path}`,
            );
          }
        }
        visit(entry, `${path}.${key}`);
      }
    }
  };
  visit(idl);
}
