import { decryptWithAesGcm, encryptWithAesGcm, hashForLookup } from "./aes.js";
import { createKmsService } from "./kms.js";
import { isTenantDataEncryptionEnabled, isEncryptionDebugEnabled } from "./toggles.js";
import { createLogger } from "../logger/index.js";
const logger = createLogger("shared").child({ component: "tenant-encryption" });
const MAP_MISS_TTL_MS = 5 * 60 * 1e3;
const DEK_CACHE_TTL_MS = 15 * 60 * 1e3;
function cacheKey(key) {
  return [
    "encmap",
    key.entityId.toLowerCase(),
    key.tenantId ?? "null",
    key.organizationId ?? "null"
  ].join(":");
}
function debug(event, payload) {
  if (!isEncryptionDebugEnabled()) return;
  try {
    logger.debug(event, payload);
  } catch {
  }
}
const toSnakeCase = (value) => value.replace(/([A-Z])/g, "_$1").replace(/__/g, "_").toLowerCase();
const toCamelCase = (value) => value.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
function findKey(obj, key) {
  const candidates = [key, toSnakeCase(key), toCamelCase(key)];
  for (const candidate of candidates) {
    if (Object.prototype.hasOwnProperty.call(obj, candidate)) return candidate;
  }
  return null;
}
function parseDecryptedFieldValue(decrypted) {
  if (decrypted.length === 0) return decrypted;
  const first = decrypted[0];
  if (first !== "{" && first !== "[") return decrypted;
  try {
    return JSON.parse(decrypted);
  } catch {
    return decrypted;
  }
}
function isEncryptedWithDek(value, dek) {
  if (typeof value !== "string") return false;
  const parts = value.split(":");
  if (parts.length !== 4 || parts[3] !== "v1") return false;
  return decryptWithAesGcm(value, dek.key) !== null;
}
function normalizeEncryptedFieldNames(fields) {
  if (!Array.isArray(fields)) return [];
  return fields.map((rule) => rule.field).filter((field) => typeof field === "string" && field.trim().length > 0);
}
function readEncryptedFieldsJson(row) {
  const raw = row.fields_json ?? row.fieldsJson;
  if (Array.isArray(raw)) return raw;
  if (typeof raw === "string") {
    try {
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }
  return [];
}
function getSqlConnection(em) {
  const source = em;
  const conn = source.getConnection?.();
  if (!conn || typeof conn !== "object") return null;
  const candidate = conn;
  if (typeof candidate.execute !== "function") return null;
  return candidate;
}
class TenantDataEncryptionService {
  constructor(em, opts) {
    this.em = em;
    this.memoryCache = TenantDataEncryptionService.globalMemoryCache;
    this.dekCache = TenantDataEncryptionService.globalDekCache;
    this.inflightDeks = TenantDataEncryptionService.globalInflightDeks;
    this.inflightMaps = TenantDataEncryptionService.globalInflightMaps;
    this.missCache = TenantDataEncryptionService.globalMissCache;
    this.cache = opts?.cache;
    this.kms = opts?.kms ?? createKmsService();
    this.systemDefaultMaps = new Map(
      (opts?.defaultEncryptionMaps ?? []).filter((map) => map.keyScope === "system").map((map) => [map.entityId, map])
    );
  }
  static {
    this.globalMemoryCache = /* @__PURE__ */ new Map();
  }
  static {
    this.globalInflightMaps = /* @__PURE__ */ new Map();
  }
  static {
    this.globalDekCache = /* @__PURE__ */ new Map();
  }
  static {
    this.globalInflightDeks = /* @__PURE__ */ new Map();
  }
  static {
    this.globalMissCache = /* @__PURE__ */ new Map();
  }
  isEnabled() {
    return isTenantDataEncryptionEnabled() && this.kms.isHealthy();
  }
  isDekExpired(dek) {
    return Date.now() - dek.fetchedAt > DEK_CACHE_TTL_MS;
  }
  async getDek(tenantId) {
    if (!tenantId) return null;
    const cached = this.dekCache.get(tenantId);
    if (cached && !this.isDekExpired(cached)) return cached;
    if (cached) this.dekCache.delete(tenantId);
    const dek = await this.kms.getTenantDek(tenantId);
    if (!dek) {
      debug("\u{1F50E} dek.miss", { tenantId });
    } else {
      debug("\u2705 dek.hit", { tenantId });
    }
    if (dek) this.dekCache.set(tenantId, dek);
    return dek;
  }
  async resolveDekForEncrypt(tenantId) {
    const existing = await this.getDek(tenantId);
    if (existing || !tenantId) return existing ?? null;
    if (typeof this.kms.createTenantDek !== "function") return existing ?? null;
    const pending = this.inflightDeks.get(tenantId);
    if (pending) return pending;
    const creation = (async () => {
      const created = await this.kms.createTenantDek(tenantId);
      if (created) this.dekCache.set(tenantId, created);
      return created ?? null;
    })();
    this.inflightDeks.set(tenantId, creation);
    try {
      return await creation;
    } finally {
      this.inflightDeks.delete(tenantId);
    }
  }
  async createDek(tenantId) {
    const dek = await this.kms.createTenantDek(tenantId);
    if (dek) this.dekCache.set(tenantId, dek);
    return dek;
  }
  async fetchMap(key) {
    const conn = getSqlConnection(this.em);
    if (!conn) return null;
    const sql = `
      select entity_id, fields_json
      from encryption_maps
      where entity_id = ?
        and tenant_id is not distinct from ?
        and organization_id is not distinct from ?
        and is_active = true
        and deleted_at is null
      limit 1
    `;
    const rows = await conn.execute(sql, [key.entityId, key.tenantId ?? null, key.organizationId ?? null]);
    const row = Array.isArray(rows) && rows.length && rows[0] && typeof rows[0] === "object" ? rows[0] : null;
    if (!row) return null;
    return {
      entityId: String(row.entity_id ?? row.entityId ?? key.entityId),
      fields: readEncryptedFieldsJson(row)
    };
  }
  applySystemDefault(record, entityId) {
    const declared = this.systemDefaultMaps.get(entityId);
    if (!declared) return record;
    const fields = declared.fields.map((field) => ({
      field: field.field,
      hashField: field.hashField ?? null
    }));
    const declaredFields = new Set(fields.map((field) => field.field));
    for (const field of record?.fields ?? []) {
      if (!declaredFields.has(field.field)) fields.push(field);
    }
    return {
      entityId,
      keyScope: "system",
      fields
    };
  }
  async getMap(key) {
    const shouldSkipLookup = (tag) => {
      const expiresAt = this.missCache.get(tag);
      if (!expiresAt) return false;
      if (expiresAt > Date.now()) return true;
      this.missCache.delete(tag);
      return false;
    };
    const recordMiss = (tag) => {
      this.missCache.set(tag, Date.now() + MAP_MISS_TTL_MS);
    };
    const candidates = [
      key,
      { entityId: key.entityId, tenantId: key.tenantId ?? null, organizationId: null },
      { entityId: key.entityId, tenantId: null, organizationId: null }
    ];
    for (const candidate of candidates) {
      const tag = cacheKey(candidate);
      if (shouldSkipLookup(tag)) continue;
      if (this.inflightMaps.has(tag)) {
        const pending2 = this.inflightMaps.get(tag);
        const resolved = await pending2;
        if (resolved) return this.applySystemDefault(resolved, key.entityId);
      }
      const mem = this.memoryCache.get(tag);
      if (mem) return this.applySystemDefault(mem, key.entityId);
      if (this.cache && typeof this.cache.get === "function") {
        const cached = await this.cache.get(tag);
        if (cached) return this.applySystemDefault(cached, key.entityId);
      }
      const pending = this.fetchMap(candidate);
      this.inflightMaps.set(tag, pending);
      const loaded = await pending;
      this.inflightMaps.delete(tag);
      if (!loaded) {
        recordMiss(tag);
        debug("\u{1F50D} encmap.miss", {
          entityId: candidate.entityId,
          tenantId: candidate.tenantId,
          organizationId: candidate.organizationId
        });
        continue;
      }
      this.missCache.delete(tag);
      this.memoryCache.set(tag, loaded);
      if (this.cache && typeof this.cache.set === "function") {
        await this.cache.set(tag, loaded, { ttl: 300 });
      }
      return this.applySystemDefault(loaded, key.entityId);
    }
    return this.applySystemDefault(null, key.entityId);
  }
  async fetchAllOrganizationFieldNames(entityId, tenantId) {
    const conn = getSqlConnection(this.em);
    if (!conn) return [];
    const sql = `
      select fields_json
      from encryption_maps
      where entity_id = ?
        and tenant_id is not distinct from ?
        and organization_id is not null
        and is_active = true
        and deleted_at is null
    `;
    const rows = await conn.execute(sql, [entityId, tenantId]);
    if (!Array.isArray(rows) || rows.length === 0) return [];
    const names = /* @__PURE__ */ new Set();
    for (const row of rows) {
      if (!row || typeof row !== "object") continue;
      for (const field of normalizeEncryptedFieldNames(readEncryptedFieldsJson(row))) {
        names.add(field);
      }
    }
    return Array.from(names);
  }
  async invalidateMap(entityId, tenantId, organizationId) {
    const tag = cacheKey({ entityId, tenantId, organizationId });
    this.memoryCache.delete(tag);
    this.inflightMaps.delete(tag);
    this.missCache.delete(tag);
    if (this.cache && typeof this.cache.delete === "function") {
      await this.cache.delete(tag);
    }
  }
  // Force a flush of a tenant's cached DEK across the service-level cache and the
  // underlying KMS cache so an operator can pick up a rotated/revoked key without
  // a process restart (#2746).
  invalidateDek(tenantId) {
    this.dekCache.delete(tenantId);
    this.inflightDeks.delete(tenantId);
    this.kms.invalidateDek?.(tenantId);
  }
  /**
   * Lists the fields an encryption map marks as encrypted at rest.
   *
   * `isEnabled()` folds the environment toggle together with KMS health, so by default an
   * unhealthy KMS reports "nothing is encrypted" — which is safe for write paths but wrong for
   * readers that must decide whether a stored column holds ciphertext. `ignoreRuntimeHealth`
   * answers the on-disk question instead: it consults the map even when the KMS cannot currently
   * resolve a DEK, so callers can fail closed rather than treat ciphertext as plaintext (#4622).
   */
  async getEncryptedFieldNames(entityId, tenantId, organizationId, options) {
    if (options?.ignoreRuntimeHealth) {
      if (!isTenantDataEncryptionEnabled()) return [];
    } else if (!this.isEnabled()) {
      return [];
    }
    const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
    const fields = new Set(normalizeEncryptedFieldNames(map?.fields));
    if (organizationId == null) {
      for (const field of await this.fetchAllOrganizationFieldNames(entityId, tenantId ?? null)) {
        fields.add(field);
      }
    }
    return Array.from(fields);
  }
  encryptFields(obj, fields, dek) {
    const clone = { ...obj };
    for (const rule of fields) {
      const key = findKey(clone, rule.field);
      if (!key) continue;
      const value = clone[key];
      if (value === null || value === void 0) continue;
      if (isEncryptedWithDek(value, dek)) continue;
      const serialized = typeof value === "string" ? value : JSON.stringify(value);
      const payload = encryptWithAesGcm(serialized, dek.key);
      clone[key] = payload.value;
      if (rule.hashField) {
        const hashKey = findKey(clone, rule.hashField) ?? rule.hashField;
        clone[hashKey] = hashForLookup(serialized);
      }
    }
    return clone;
  }
  decryptFields(obj, fields, dek) {
    const clone = { ...obj };
    const maybeDecrypt = (payload) => {
      const first = decryptWithAesGcm(payload, dek.key);
      if (first === null) return null;
      const parts = first.split(":");
      if (parts.length === 4 && parts[3] === "v1") {
        const second = decryptWithAesGcm(first, dek.key);
        return second ?? first;
      }
      return first;
    };
    for (const rule of fields) {
      const key = findKey(clone, rule.field);
      if (!key) continue;
      const value = clone[key];
      if (typeof value !== "string") continue;
      const decrypted = maybeDecrypt(value);
      if (decrypted === null) continue;
      clone[key] = decrypted;
    }
    return clone;
  }
  async encryptEntityPayload(entityId, payload, tenantId, organizationId) {
    if (!this.isEnabled()) {
      debug("\u26AA\uFE0F encrypt.skip.disabled", { entityId, tenantId });
      return payload;
    }
    const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
    if (!map || !map.fields?.length) {
      debug("\u26AA\uFE0F encrypt.skip.no-map", { entityId, tenantId });
      return payload;
    }
    const keyId = map.keyScope === "system" ? `system:${entityId}` : tenantId ?? null;
    const dek = await this.resolveDekForEncrypt(keyId);
    if (!dek) {
      debug("\u26A0\uFE0F encrypt.skip.no-dek", { entityId, tenantId, keyScope: map.keyScope ?? "tenant" });
      return payload;
    }
    debug("\u{1F512} encrypt_entity", { entityId, tenantId, organizationId, fields: map.fields.length });
    return this.encryptFields(payload, map.fields, dek);
  }
  async decryptEntityPayload(entityId, payload, tenantId, organizationId) {
    if (!isTenantDataEncryptionEnabled()) {
      debug("\u26AA\uFE0F decrypt.skip.disabled", { entityId, tenantId });
      return payload;
    }
    const map = await this.getMap({ entityId, tenantId: tenantId ?? null, organizationId: organizationId ?? null });
    if (!map || !map.fields?.length) {
      debug("\u26AA\uFE0F decrypt.skip.no-map", { entityId, tenantId });
      return payload;
    }
    const keyId = map.keyScope === "system" ? `system:${entityId}` : tenantId ?? null;
    const dek = await this.getDek(keyId);
    if (!dek) {
      debug("\u26A0\uFE0F decrypt.skip.no-dek", { entityId, tenantId, keyScope: map.keyScope ?? "tenant" });
      return payload;
    }
    debug("\u{1F513} decrypt_entity", { entityId, tenantId, organizationId, fields: map.fields.length });
    return this.decryptFields(payload, map.fields, dek);
  }
}
export {
  TenantDataEncryptionService,
  parseDecryptedFieldValue
};
//# sourceMappingURL=tenantDataEncryptionService.js.map
