import { sql } from "kysely";
import {
  applyJoinFilters,
  normalizeFilters,
  partitionFilters,
  resolveJoins
} from "./join-utils.js";
import { resolveSearchConfig } from "../search/config.js";
import {
  createSearchTokenAvailability,
  isSearchFilterOp
} from "../search/availability.js";
import { tokenizeText } from "../search/tokenize.js";
import { fieldNameCandidates } from "./encrypted-sort.js";
import { isTenantDataEncryptionEnabled } from "../encryption/toggles.js";
import { runBeforeQueryPipeline, runAfterQueryPipeline } from "./query-extension-runner.js";
import {
  buildCustomFieldDefinitionIndexFromRows,
  resolveCfDefIndexOrgCandidates
} from "../crud/custom-field-definition-index.js";
import { warnOnCiphertextLikeFallback } from "./ciphertext-search-warning.js";
import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from "./encrypted-sort.js";
import { resolveListCountCap } from "./count-cap.js";
import { mapWithConcurrency } from "./bounded-decrypt.js";
import { createLogger } from "../logger/index.js";
const logger = createLogger("shared").child({ component: "query" });
const DECRYPT_CONCURRENCY = 8;
const entityTableCache = /* @__PURE__ */ new Map();
function isEncryptedLikeField(encrypted, field) {
  return fieldNameCandidates(field).some((candidate) => encrypted.has(candidate));
}
const ENCRYPTED_LIKE_FIELDS_TTL_MS = 6e4;
const ENCRYPTED_LIKE_FIELDS_CACHE_CAP = 500;
const encryptedLikeFieldsCache = /* @__PURE__ */ new Map();
async function resolveEncryptedLikeFieldSet(read, entity, tenantId) {
  const key = `${entity}|${tenantId ?? ""}`;
  const hit = encryptedLikeFieldsCache.get(key);
  if (hit && Date.now() - hit.at < ENCRYPTED_LIKE_FIELDS_TTL_MS) return hit.fields;
  const names = await read();
  const fields = /* @__PURE__ */ new Set();
  for (const name of names ?? []) {
    for (const candidate of fieldNameCandidates(String(name))) fields.add(candidate);
  }
  if (encryptedLikeFieldsCache.size >= ENCRYPTED_LIKE_FIELDS_CACHE_CAP) encryptedLikeFieldsCache.clear();
  encryptedLikeFieldsCache.set(key, { at: Date.now(), fields });
  return fields;
}
function clearEncryptedLikeFieldsCache() {
  encryptedLikeFieldsCache.clear();
}
const ENTITY_ID_PATTERN = /^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$/;
const isValidEntityIdShape = (value) => ENTITY_ID_PATTERN.test(value);
const pluralizeBaseName = (name) => {
  if (!name) return name;
  if (name.endsWith("s")) return name;
  if (name.endsWith("y")) return `${name.slice(0, -1)}ies`;
  return `${name}s`;
};
const PLAIN_TABLE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const isPlainTableIdentifier = (value) => typeof value === "string" && PLAIN_TABLE_IDENTIFIER_PATTERN.test(value);
const toPascalCase = (value) => {
  return value.split(/[_\s]+/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join("");
};
const candidateClassNames = (rawName) => {
  const base = toPascalCase(rawName);
  const candidates = /* @__PURE__ */ new Set();
  if (base) candidates.add(base);
  if (base && !base.endsWith("Entity")) candidates.add(`${base}Entity`);
  return Array.from(candidates);
};
function resolveRegisteredEntityTableName(em, entity) {
  const parts = String(entity || "").split(":");
  const rawName = parts[1] && parts[1].trim().length > 0 ? parts[1] : (parts[0] || "").trim();
  const metadata = em?.getMetadata?.();
  if (!metadata || !rawName) return null;
  const candidates = candidateClassNames(rawName);
  for (const candidate of candidates) {
    try {
      const meta = metadata.find?.(candidate);
      if (meta?.tableName) {
        return String(meta.tableName);
      }
    } catch {
    }
  }
  const modulePrefix = parts[0] ?? "";
  const candidateTables = [
    `${modulePrefix}_${rawName}`,
    pluralizeBaseName(rawName),
    `${modulePrefix}_${pluralizeBaseName(rawName)}`
  ];
  try {
    const allMeta = metadata.getAll?.() ?? [];
    for (const meta of allMeta) {
      if (meta?.tableName && candidateTables.includes(String(meta.tableName))) {
        return String(meta.tableName);
      }
    }
  } catch {
  }
  return null;
}
function resolveEntityTableName(em, entity) {
  if (entityTableCache.has(entity)) {
    return entityTableCache.get(entity);
  }
  const parts = String(entity || "").split(":");
  const rawName = parts[1] && parts[1].trim().length > 0 ? parts[1] : (parts[0] || "").trim();
  const registered = resolveRegisteredEntityTableName(em, entity);
  if (registered) {
    entityTableCache.set(entity, registered);
    return registered;
  }
  const fallback = pluralizeBaseName(rawName || "");
  logger.warn("Could not resolve entity via ORM metadata; falling back to table name \u2014 ensure the entity ID segment matches the class name convention", { entity, fallback });
  entityTableCache.set(entity, fallback);
  return fallback;
}
function buildFilterableCustomFieldJoins(sources) {
  if (!sources || sources.length === 0) return [];
  return sources.flatMap((source, index) => {
    if (!source.join) return [];
    const alias = typeof source.alias === "string" && source.alias.trim().length > 0 ? source.alias.trim() : `cfs_${index}`;
    return [{
      alias,
      table: source.table,
      entityId: source.entityId,
      from: { field: source.join.fromField },
      to: { field: source.join.toField },
      type: source.join.type === "inner" ? "inner" : "left"
    }];
  });
}
function computeCustomFieldScore(cfg, kind, entityIndex) {
  const listVisibleScore = cfg.listVisible === false ? 0 : 1;
  const formEditableScore = cfg.formEditable === false ? 0 : 1;
  const filterableScore = cfg.filterable ? 1 : 0;
  const kindScore = (() => {
    switch (kind) {
      case "dictionary":
        return 8;
      case "relation":
        return 6;
      case "select":
        return 4;
      case "multiline":
        return 3;
      case "boolean":
      case "integer":
      case "float":
        return 2;
      default:
        return 1;
    }
  })();
  const optionsBonus = Array.isArray(cfg.options) && cfg.options.length ? 2 : 0;
  const dictionaryBonus = typeof cfg.dictionaryId === "string" && cfg.dictionaryId.trim().length ? 5 : 0;
  const base = listVisibleScore * 16 + formEditableScore * 8 + filterableScore * 4 + kindScore + optionsBonus + dictionaryBonus;
  const penalty = typeof cfg.priority === "number" ? cfg.priority : 0;
  return { base, penalty, entityIndex };
}
class BasicQueryEngine {
  constructor(em, getDbFn, resolveEncryptionService) {
    this.em = em;
    this.getDbFn = getDbFn;
    this.resolveEncryptionService = resolveEncryptionService;
    this.columnCache = /* @__PURE__ */ new Map();
    this.searchAliasSeq = 0;
    this.searchAvailabilityInstance = null;
  }
  getEncryptionService() {
    try {
      return this.resolveEncryptionService?.() ?? null;
    } catch {
      return null;
    }
  }
  getDb() {
    if (this.getDbFn) return this.getDbFn();
    const emAny = this.em;
    if (typeof emAny?.getKysely === "function") return emAny.getKysely();
    throw new Error("BasicQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)");
  }
  searchAvailability() {
    if (!this.searchAvailabilityInstance) {
      this.searchAvailabilityInstance = createSearchTokenAvailability({
        getDb: () => this.getDb(),
        getConfig: resolveSearchConfig,
        applyOrganizationScope: (query, column, scope) => this.applyOrganizationScope(
          query,
          column,
          scope
        ),
        logDebug: (event, payload) => this.logSearchDebug(event, payload)
      });
    }
    return this.searchAvailabilityInstance;
  }
  async query(entity, opts = {}) {
    const ext = opts.extensions;
    let effectiveOpts = opts;
    let extensionCtx = null;
    const noop = { resolve: (_name) => {
      throw new Error("No DI context");
    } };
    if (ext) {
      extensionCtx = {
        entity: String(entity),
        engine: "basic",
        tenantId: opts.tenantId ?? "",
        organizationId: opts.organizationId,
        userId: ext.userId,
        em: this.em,
        container: ext.container,
        userFeatures: ext.userFeatures
      };
      const diCtx = ext.resolve ? { resolve: ext.resolve } : noop;
      const beforeResult = await runBeforeQueryPipeline(opts, extensionCtx, diCtx);
      if (beforeResult.blocked) {
        throw new Error(beforeResult.errorMessage ?? "Query blocked by extension subscriber");
      }
      effectiveOpts = beforeResult.query;
    }
    const { extensions: _ext, ...coreOpts } = effectiveOpts;
    opts = coreOpts;
    const table = resolveEntityTableName(this.em, entity);
    const db = this.getDb();
    const qualify = (col) => `${table}.${col}`;
    const orgScope = this.resolveOrganizationScope(opts);
    this.searchAliasSeq = 0;
    if (!opts.tenantId) {
      throw new Error(
        "QueryEngine: tenantId is now required for all queries (breaking change). Please provide a tenantId in QueryOptions, e.g., query(entity, { tenantId: ... }). See migration guide or documentation for details."
      );
    }
    const skipAutoScope = opts.omitAutomaticTenantOrgScope === true;
    const normalizedFilters = normalizeFilters(opts.filters);
    const resolvedJoins = resolveJoins(
      table,
      [...opts.joins ?? [], ...buildFilterableCustomFieldJoins(opts.customFieldSources)],
      (entityId) => resolveEntityTableName(this.em, entityId)
    );
    const joinMap = /* @__PURE__ */ new Map();
    const aliasTables = /* @__PURE__ */ new Map();
    aliasTables.set(table, table);
    aliasTables.set("base", table);
    for (const join of resolvedJoins) {
      joinMap.set(join.alias, join);
      aliasTables.set(join.alias, join.table);
    }
    const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap);
    const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith("cf:"));
    const regularCfFilters = cfFilters.filter((filter) => !filter.orGroup);
    const orGroupCfFilters = cfFilters.filter((filter) => filter.orGroup);
    const searchConfig = resolveSearchConfig();
    const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => isSearchFilterOp(filter.op));
    const searchEnabled = !skipAutoScope && await this.searchAvailability().staticEnabled();
    const hasSearchTokens = searchEnabled && searchFilters.length ? await this.searchAvailability().hasTokens(String(entity), opts.tenantId ?? null, orgScope) : false;
    const searchActive = searchEnabled && hasSearchTokens;
    let encryptedLikeFields = null;
    if (searchActive && searchConfig.useIlikeForNonEncryptedFields === true && searchFilters.some((filter) => !String(filter.field).startsWith("cf:"))) {
      try {
        const service = this.getEncryptionService();
        const readEncryptedFieldNames = service?.getEncryptedFieldNames?.bind(service);
        if (readEncryptedFieldNames) {
          encryptedLikeFields = await resolveEncryptedLikeFieldSet(
            () => readEncryptedFieldNames(
              String(entity),
              opts.tenantId ?? null,
              null,
              { ignoreRuntimeHealth: true }
            ),
            String(entity),
            opts.tenantId ?? null
          );
        } else if (isTenantDataEncryptionEnabled()) {
          encryptedLikeFields = null;
        } else {
          encryptedLikeFields = /* @__PURE__ */ new Set();
        }
      } catch (err) {
        logger.warn("search: encrypted-field map unavailable; keeping the token rewrite for all columns", {
          entity: String(entity),
          error: err instanceof Error ? err.message : String(err)
        });
        encryptedLikeFields = null;
      }
    }
    if (searchFilters.length) {
      const fields = searchFilters.map((filter) => String(filter.field));
      this.logSearchDebug("search:init", {
        entity: String(entity),
        table,
        tenantId: opts.tenantId ?? null,
        organizationScope: orgScope,
        fields,
        searchEnabled,
        hasSearchTokens,
        searchActive,
        searchConfig: {
          enabled: searchConfig.enabled,
          minTokenLength: searchConfig.minTokenLength,
          enablePartials: searchConfig.enablePartials,
          hashAlgorithm: searchConfig.hashAlgorithm,
          blocklistedFields: searchConfig.blocklistedFields
        }
      });
      if (!searchEnabled) {
        this.logSearchDebug("search:disabled", { entity: String(entity), table });
      } else if (!hasSearchTokens) {
        this.logSearchDebug("search:no-search-tokens", {
          entity: String(entity),
          table,
          tenantId: opts.tenantId ?? null,
          organizationScope: orgScope
        });
      }
      const fallbackFields = searchFilters.filter((filter) => !searchActive || typeof filter.value !== "string" || tokenizeText(filter.value, searchConfig).hashes.length === 0).map((filter) => String(filter.field));
      if (fallbackFields.length) {
        await warnOnCiphertextLikeFallback({
          entity: String(entity),
          fields: fallbackFields,
          tenantId: opts.tenantId ?? null,
          // `searchEnabled` also folds in the missing-table and
          // omitAutomaticTenantOrgScope cases, which are "no usable tokens"
          // rather than "the operator switched search off".
          reason: searchActive ? "no-indexable-tokens" : searchConfig.enabled ? "no-search-tokens" : "search-disabled",
          service: this.getEncryptionService()
        });
      }
    }
    for (const [alias, joinedFilters] of joinFilters) {
      const filters = joinedFilters.filter((entry) => entry.op === "like" || entry.op === "ilike");
      if (!filters.length) continue;
      const join = joinMap.get(alias);
      if (!join?.entityId) continue;
      const hasJoinedTokens = searchEnabled ? await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope) : false;
      const fallbackFields = filters.filter((filter) => !hasJoinedTokens || typeof filter.value !== "string" || tokenizeText(filter.value, searchConfig).hashes.length === 0).map((filter) => filter.column);
      if (!fallbackFields.length) continue;
      await warnOnCiphertextLikeFallback({
        entity: join.entityId,
        fields: fallbackFields,
        tenantId: opts.tenantId ?? null,
        reason: hasJoinedTokens ? "no-indexable-tokens" : searchConfig.enabled ? "no-search-tokens" : "search-disabled",
        service: this.getEncryptionService()
      });
    }
    const recordIdColumn = qualify("id");
    const applyFilterOp = (builder, column, op, value, fieldName) => {
      if ((op === "like" || op === "ilike") && searchActive && typeof value === "string" && fieldName && typeof column === "string" && // Plaintext columns keep exact SQL ILIKE -- see the encryptedLikeFields note above. cf:*
      // filters never reach this path (they are applied by the custom-field branches), so this
      // gate only decides base columns. Membership is tested across name-shape candidates --
      // encryption maps may declare `displayName` while the filter carries the column name
      // `display_name`, and a raw comparison would misread that ciphertext column as plaintext.
      (encryptedLikeFields === null || isEncryptedLikeField(encryptedLikeFields, fieldName))) {
        const tokens = tokenizeText(String(value), searchConfig);
        const hashes = tokens.hashes;
        if (hashes.length) {
          const result = this.applySearchTokens(builder, {
            entity: String(entity),
            field: fieldName,
            hashes,
            recordIdColumn,
            tenantId: opts.tenantId ?? null,
            organizationScope: orgScope,
            tokens: tokens.tokens
          });
          this.logSearchDebug("search:filter", {
            entity: String(entity),
            field: fieldName,
            tokens: tokens.tokens,
            hashes,
            applied: result.applied,
            tenantId: opts.tenantId ?? null,
            organizationScope: orgScope
          });
          if (result.applied) return result.builder;
        } else {
          this.logSearchDebug("search:skip-empty-hashes", {
            entity: String(entity),
            field: fieldName,
            value
          });
        }
      }
      return this.applyColumnOp(builder, column, op, value);
    };
    const applyJoinFilterOp = async (builder, filter, _qualified, join) => {
      if (!searchEnabled || !join.entityId) return { applied: false, builder };
      if (!["like", "ilike"].includes(filter.op)) return { applied: false, builder };
      if (typeof filter.value !== "string" || filter.value.trim().length === 0) return { applied: false, builder };
      const searchAvailable = await this.searchAvailability().hasTokens(join.entityId, opts.tenantId ?? null, orgScope);
      if (!searchAvailable) return { applied: false, builder };
      const tokens = tokenizeText(String(filter.value), searchConfig);
      if (!tokens.hashes.length) return { applied: false, builder };
      const result = this.applySearchTokens(builder, {
        entity: join.entityId,
        field: filter.column,
        hashes: tokens.hashes,
        recordIdColumn: `${join.alias}.id`,
        tenantId: opts.tenantId ?? null,
        organizationScope: orgScope,
        tokens: tokens.tokens
      });
      return { applied: result.applied, builder: result.builder };
    };
    const regularBaseFilters = baseFilters.filter((f) => !f.orGroup);
    const orGroupFilters = baseFilters.filter((f) => f.orGroup);
    const applyAliasScopes = async (builder, aliasName) => {
      const targetTable = aliasTables.get(aliasName);
      if (!targetTable) return builder;
      let next = builder;
      if (!skipAutoScope && orgScope && await this.columnExists(targetTable, "organization_id")) {
        next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope);
      }
      if (!skipAutoScope && opts.tenantId && await this.columnExists(targetTable, "tenant_id")) {
        next = next.where(`${aliasName}.tenant_id`, "=", opts.tenantId);
      }
      return next;
    };
    const fallbackOrgId = opts.organizationId ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null);
    const encryptionService = this.getEncryptionService();
    const resolvedSorts = [];
    for (const s of opts.sort || []) {
      if (s.field.startsWith("cf:")) {
        resolvedSorts.push(s);
      } else {
        const column = await this.resolveBaseColumn(table, s.field);
        if (column) resolvedSorts.push({ ...s, field: column });
      }
    }
    const encryptedSortFields = await resolveEncryptedSortFields(
      encryptionService,
      entity,
      resolvedSorts.filter((sort) => !sort.field.startsWith("cf:")).map((sort) => sort.field),
      opts.tenantId ?? null,
      fallbackOrgId
    );
    const requiresPlaintextSort = encryptedSortFields.size > 0;
    const tenantId = opts.tenantId;
    const sanitize = (s) => s.replace(/[^a-zA-Z0-9_]/g, "_");
    const buildQuery = async (projection) => {
      const isSortKeysProjection = projection === "sortKeys";
      const isCountProjection = projection === "count";
      let q = db.selectFrom(table);
      if (!skipAutoScope && orgScope && await this.columnExists(table, "organization_id")) {
        q = this.applyOrganizationScope(q, qualify("organization_id"), orgScope);
      }
      if (!skipAutoScope && await this.columnExists(table, "tenant_id")) {
        q = q.where(qualify("tenant_id"), "=", opts.tenantId);
      }
      if (!opts.withDeleted && await this.columnExists(table, "deleted_at")) {
        q = q.where(qualify("deleted_at"), "is", null);
      }
      for (const filter of regularBaseFilters) {
        const fieldName = String(filter.field);
        let qualified = filter.qualified ?? null;
        if (!qualified) {
          const column = await this.resolveBaseColumn(table, fieldName);
          if (!column) {
            q = this.applyIndexDocFilter(q, {
              entity: String(entity),
              field: fieldName,
              op: filter.op,
              value: filter.value,
              recordIdColumn,
              tenantId: opts.tenantId ?? null,
              organizationScope: orgScope,
              withDeleted: opts.withDeleted === true,
              searchActive,
              searchConfig
            });
            continue;
          }
          qualified = qualify(column);
        }
        q = applyFilterOp(q, qualified, filter.op, filter.value, fieldName);
      }
      const resolvedGroupFilters = [];
      if (orGroupFilters.length > 0 || orGroupCfFilters.length > 0) {
        const groups = /* @__PURE__ */ new Map();
        for (const f of [...orGroupFilters, ...orGroupCfFilters]) {
          const group = groups.get(f.orGroup) ?? [];
          group.push(f);
          groups.set(f.orGroup, group);
        }
        for (const [, groupFilters] of groups) {
          const resolved = [];
          for (const filter of groupFilters) {
            const field = String(filter.field);
            if (field.startsWith("cf:")) {
              resolved.push({ kind: "cf", key: field.slice(3), op: filter.op, value: filter.value });
              continue;
            }
            const column = await this.resolveBaseColumn(table, field);
            if (column) {
              resolved.push({ kind: "column", qualified: qualify(column), op: filter.op, value: filter.value });
            } else {
              resolved.push({ kind: "doc", field, op: filter.op, value: filter.value });
            }
          }
          if (resolved.length > 0) resolvedGroupFilters.push(resolved);
        }
      }
      q = await applyJoinFilters({
        db,
        baseTable: table,
        builder: q,
        joinMap,
        joinFilters,
        aliasTables,
        qualifyBase: (column) => qualify(column),
        applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
        applyFilterOp: (builder, column, op, value) => applyFilterOp(builder, column, op, value),
        applyJoinFilterOp,
        columnExists: (tbl, column) => this.columnExists(tbl, column)
      });
      if (isCountProjection) {
      } else if (isSortKeysProjection) {
        q = q.select(sql.ref(qualify("id")).as("id"));
        if (await this.columnExists(table, "tenant_id")) {
          q = q.select(sql.ref(qualify("tenant_id")).as("tenant_id"));
        }
        if (await this.columnExists(table, "organization_id")) {
          q = q.select(sql.ref(qualify("organization_id")).as("organization_id"));
        }
        for (const s of resolvedSorts) {
          if (!s.field.startsWith("cf:")) q = q.select(sql.ref(qualify(s.field)).as(s.field));
        }
      } else if (opts.fields && opts.fields.length) {
        const cols = new Set(opts.fields.filter((f) => !f.startsWith("cf:")));
        if (requiresPlaintextSort) {
          for (const field of encryptedSortFields) cols.add(field);
        }
        for (const c of cols) {
          q = q.select(sql.ref(qualify(c)).as(c));
        }
      } else {
        q = q.select(sql`${sql.ref(table)}.*`.as("__all"));
      }
      const cfSourcesResult = this.configureCustomFieldSources(q, table, entity, db, opts, qualify, !isCountProjection);
      q = cfSourcesResult.builder;
      const cfSources = cfSourcesResult.sources;
      const entityIdToSource = /* @__PURE__ */ new Map();
      for (const source of cfSources) {
        entityIdToSource.set(String(source.entityId), source);
      }
      const requestedCustomFieldKeys = projection === "full" && Array.isArray(opts.includeCustomFields) ? opts.includeCustomFields.map((key) => String(key)) : [];
      const cfKeys = /* @__PURE__ */ new Set();
      const keySource = /* @__PURE__ */ new Map();
      let resolvedCustomFieldDefinitions2;
      if (projection === "full") {
        for (const f of opts.fields || []) {
          if (typeof f === "string" && f.startsWith("cf:")) cfKeys.add(f.slice(3));
        }
      }
      for (const f of cfFilters) {
        if (typeof f.field === "string" && f.field.startsWith("cf:")) cfKeys.add(f.field.slice(3));
      }
      if (projection === "full" && opts.includeCustomFields === true) {
        if (entityIdToSource.size > 0) {
          const entityIdList = Array.from(entityIdToSource.keys());
          const entityOrder = /* @__PURE__ */ new Map();
          entityIdList.forEach((id, idx) => entityOrder.set(id, idx));
          const rows = await db.selectFrom("custom_field_defs").select([
            "key",
            "entity_id",
            "config_json",
            "kind",
            "organization_id",
            "tenant_id",
            "updated_at",
            "deleted_at"
          ]).where("entity_id", "in", entityIdList).where("is_active", "=", true).where((eb) => eb.or([
            eb("tenant_id", "=", tenantId),
            eb("tenant_id", "is", null)
          ])).execute();
          const orgCandidates = resolveCfDefIndexOrgCandidates(opts.organizationIds, opts.organizationId ?? null);
          const definitionRows = rows.map((row) => ({
            key: String(row.key),
            entityId: String(row.entity_id),
            kind: row.kind == null ? null : String(row.kind),
            configJson: row.config_json,
            organizationId: row.organization_id == null ? null : String(row.organization_id),
            tenantId: row.tenant_id == null ? null : String(row.tenant_id),
            deletedAt: row.deleted_at ?? null,
            updatedAt: row.updated_at ?? null
          }));
          resolvedCustomFieldDefinitions2 = {
            index: buildCustomFieldDefinitionIndexFromRows(definitionRows, { organizationIds: orgCandidates }),
            entityIds: entityIdList,
            tenantId: tenantId ?? null,
            organizationIds: orgCandidates
          };
          const sorted = rows.map((row) => {
            const raw = row.config_json;
            let cfg = {};
            if (raw && typeof raw === "string") {
              try {
                cfg = JSON.parse(raw);
              } catch {
                cfg = {};
              }
            } else if (raw && typeof raw === "object") {
              cfg = raw;
            }
            return {
              key: String(row.key),
              entityId: String(row.entity_id),
              kind: String(row.kind || ""),
              config: cfg
            };
          });
          sorted.sort((a, b) => {
            const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER;
            const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER;
            if (ai !== bi) return ai - bi;
            return a.key.localeCompare(b.key);
          });
          const selectedSources = /* @__PURE__ */ new Map();
          for (const row of sorted) {
            const source = entityIdToSource.get(row.entityId);
            if (!source) continue;
            const cfg = row.config || {};
            const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER;
            const scores = computeCustomFieldScore(cfg, row.kind, entityIndex);
            const existing = selectedSources.get(row.key);
            if (!existing || scores.base > existing.score || scores.base === existing.score && (scores.penalty < existing.penalty || scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)) {
              selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex });
            }
            cfKeys.add(row.key);
          }
          for (const [key, entry] of selectedSources.entries()) {
            keySource.set(key, entry.source);
          }
        }
      } else if (!isSortKeysProjection && requestedCustomFieldKeys.length > 0) {
        for (const key of requestedCustomFieldKeys) cfKeys.add(key);
      }
      const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key));
      if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
        const rows = await db.selectFrom("custom_field_defs").select(["key", "entity_id"]).where("entity_id", "in", Array.from(entityIdToSource.keys())).where("key", "in", unresolvedKeys).where("is_active", "=", true).where((eb) => eb.or([
          eb("tenant_id", "=", tenantId),
          eb("tenant_id", "is", null)
        ])).execute();
        for (const row of rows) {
          const source = entityIdToSource.get(String(row.entity_id));
          if (!source) continue;
          if (!keySource.has(row.key)) keySource.set(row.key, source);
        }
      }
      const cfValueExprByKey = {};
      const cfSelectedAliases = [];
      const cfJsonAliases2 = /* @__PURE__ */ new Set();
      const cfMultiAliasByAlias2 = /* @__PURE__ */ new Map();
      for (const key of cfKeys) {
        const source = keySource.get(key);
        if (!source) continue;
        if (isCountProjection) continue;
        const entityIdForKey = source.entityId;
        const recordIdExpr = source.recordIdExpr;
        const sourceAliasSafe = sanitize(source.alias || "src");
        const keyAliasSafe = sanitize(key);
        const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`;
        const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`;
        q = q.leftJoin(
          `custom_field_defs as ${defAlias}`,
          (jb) => jb.on(`${defAlias}.entity_id`, "=", String(entityIdForKey)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((eb) => eb.or([
            eb(`${defAlias}.tenant_id`, "=", tenantId),
            eb(`${defAlias}.tenant_id`, "is", null)
          ]))
        );
        q = q.leftJoin(
          `custom_field_values as ${valAlias}`,
          (jb) => jb.on(`${valAlias}.entity_id`, "=", String(entityIdForKey)).on(`${valAlias}.field_key`, "=", key).onRef(`${valAlias}.record_id`, "=", recordIdExpr).on((eb) => eb.or([
            eb(`${valAlias}.tenant_id`, "=", tenantId),
            eb(`${valAlias}.tenant_id`, "is", null)
          ]))
        );
        const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
             WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
             WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
             WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
             WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
             ELSE (${sql.ref(`${valAlias}.value_text`)})::text
           END`;
        cfValueExprByKey[key] = caseExpr;
        const alias = sanitize(`cf:${key}`);
        if (!isSortKeysProjection && ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
          const multiAlias = `${alias}__is_multi`;
          const isMultiExpr = sql`bool_or(coalesce((${sql.ref(`${defAlias}.config_json`)}->>'multi')::boolean, false))`;
          const aggregatedArray = sql`array_remove(array_agg(DISTINCT ${caseExpr}), NULL)`;
          const projExpr = sql`CASE WHEN ${isMultiExpr}
                  THEN to_jsonb(${aggregatedArray})
                  ELSE to_jsonb(max(${caseExpr}))
             END`;
          q = q.select(projExpr.as(alias));
          q = q.select(isMultiExpr.as(multiAlias));
          cfSelectedAliases.push(alias);
          cfJsonAliases2.add(alias);
          cfMultiAliasByAlias2.set(alias, multiAlias);
        }
      }
      for (const f of regularCfFilters) {
        if (!f.field.startsWith("cf:")) continue;
        const key = f.field.slice(3);
        const filterSource = keySource.get(key);
        if (!filterSource) continue;
        const expr = cfValueExprByKey[key];
        if (!isCountProjection && !expr) continue;
        if ((f.op === "like" || f.op === "ilike") && searchActive && typeof f.value === "string") {
          const tokens = tokenizeText(String(f.value), searchConfig);
          const hashes = tokens.hashes;
          if (hashes.length) {
            const result = this.applySearchTokens(q, {
              entity: String(entity),
              field: f.field,
              hashes,
              recordIdColumn,
              tenantId: opts.tenantId ?? null,
              organizationScope: orgScope,
              tokens: tokens.tokens
            });
            this.logSearchDebug("search:cf-filter", {
              entity: String(entity),
              field: f.field,
              tokens: tokens.tokens,
              hashes,
              applied: result.applied,
              tenantId: opts.tenantId ?? null,
              organizationScope: orgScope
            });
            if (result.applied) {
              q = result.builder;
              continue;
            }
          } else {
            this.logSearchDebug("search:cf-skip-empty-hashes", {
              entity: String(entity),
              field: f.field,
              value: f.value
            });
          }
        }
        if (isCountProjection) {
          q = this.applyCfValueExistsFilter(q, {
            source: filterSource,
            qualify,
            tenantId: tenantId ?? null,
            key,
            op: f.op,
            value: f.value
          });
          continue;
        }
        q = this.applyColumnOp(q, expr, f.op, f.value);
      }
      const cfLeafApplicable = (key) => isCountProjection ? keySource.has(key) : Boolean(cfValueExprByKey[key]);
      const applicableGroupFilters = resolvedGroupFilters.map((group) => group.filter((rf) => rf.kind !== "cf" || cfLeafApplicable(rf.key))).filter((group) => group.length > 0);
      if (applicableGroupFilters.length > 0) {
        q = q.where((eb) => {
          const disjuncts = applicableGroupFilters.map((group) => {
            const parts = group.map((rf) => {
              if (rf.kind === "column") return this.buildColumnOpExpression(eb, rf.qualified, rf.op, rf.value);
              if (rf.kind === "cf") {
                if (isCountProjection) {
                  return this.buildCfValueExistsExpression(eb, {
                    source: keySource.get(rf.key),
                    qualify,
                    tenantId: tenantId ?? null,
                    key: rf.key,
                    op: rf.op,
                    value: rf.value
                  });
                }
                return this.buildColumnOpExpression(eb, cfValueExprByKey[rf.key], rf.op, rf.value);
              }
              return this.buildIndexDocOpExpression(eb, {
                entity: String(entity),
                field: rf.field,
                op: rf.op,
                value: rf.value,
                recordIdColumn,
                tenantId: opts.tenantId ?? null,
                organizationScope: orgScope,
                withDeleted: opts.withDeleted === true
              });
            });
            return parts.length === 1 ? parts[0] : eb.and(parts);
          });
          return disjuncts.length === 1 ? disjuncts[0] : eb.or(disjuncts);
        });
      }
      if (opts.includeExtensions && !isCountProjection) {
        const { getModules } = await import("@open-mercato/shared/lib/i18n/server");
        const allMods = getModules();
        const allExts = allMods.flatMap((m) => m.entityExtensions || []);
        const exts = allExts.filter((e) => e.base === entity);
        const chosen = Array.isArray(opts.includeExtensions) ? exts.filter((e) => opts.includeExtensions.includes(e.extension)) : exts;
        for (const e of chosen) {
          const [, extName] = e.extension.split(":");
          const derivedTable = pluralizeBaseName(extName);
          const extTable = isPlainTableIdentifier(e.table) ? e.table : derivedTable;
          const alias = `ext_${sanitize(extName)}`;
          q = q.leftJoin(
            `${extTable} as ${alias}`,
            (jb) => jb.onRef(`${alias}.${e.join.extensionKey}`, "=", `${table}.${e.join.baseKey}`)
          );
        }
      }
      for (const s of isCountProjection ? [] : resolvedSorts) {
        if (s.field.startsWith("cf:")) {
          const key = s.field.slice(3);
          const alias = sanitize(`cf:${key}`);
          if (!cfSelectedAliases.includes(alias)) {
            const expr = cfValueExprByKey[key];
            if (expr) {
              q = q.select(sql`max(${expr})`.as(alias));
              cfSelectedAliases.push(alias);
            }
          }
          if (!requiresPlaintextSort) q = q.orderBy(alias, s.dir ?? "asc");
        } else {
          if (!requiresPlaintextSort) q = q.orderBy(qualify(s.field), s.dir ?? "asc");
        }
      }
      const hasJoinedAggregates2 = !isCountProjection && (opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? opts.includeExtensions.length > 0 : true) || Object.keys(cfValueExprByKey).length > 0);
      if (hasJoinedAggregates2) {
        q = q.groupBy(`${table}.id`);
      }
      return { builder: q, hasJoinedAggregates: hasJoinedAggregates2, cfJsonAliases: cfJsonAliases2, cfMultiAliasByAlias: cfMultiAliasByAlias2, resolvedCustomFieldDefinitions: resolvedCustomFieldDefinitions2 };
    };
    const page = opts.page?.page ?? 1;
    const pageSize = opts.page?.pageSize ?? 20;
    const {
      builder: qFull,
      hasJoinedAggregates,
      cfJsonAliases,
      cfMultiAliasByAlias,
      resolvedCustomFieldDefinitions
    } = await buildQuery("full");
    const countCap = resolveListCountCap();
    const { builder: countShape } = await buildQuery("count");
    let total;
    let listCountCapWarning;
    if (countCap !== null) {
      const probe = countShape.select(sql`1`.as("one")).limit(countCap + 1);
      const countRow = await db.selectFrom(probe.as("om_count_probe")).select(sql`count(*)`.as("count")).executeTakeFirst();
      const probed = Number(countRow?.count ?? 0);
      if (probed > countCap) {
        total = countCap;
        listCountCapWarning = { entity, cap: countCap };
      } else {
        total = probed;
      }
    } else {
      const countRow = await countShape.select(sql`count(*)`.as("count")).executeTakeFirst();
      total = Number(countRow?.count ?? 0);
    }
    const svc = encryptionService;
    const decryptPayload = svc?.decryptEntityPayload?.bind(svc);
    const decryptRow = async (item) => {
      if (!decryptPayload) return item;
      try {
        const decrypted = await decryptPayload(
          entity,
          item,
          item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
          item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null
        );
        return { ...item, ...decrypted };
      } catch (err) {
        logger.error("Error decrypting entity payload", { err });
        return item;
      }
    };
    const normalizeCfJsonAliases = (rows) => {
      if (cfJsonAliases.size === 0) return;
      for (const row of rows) {
        for (const alias of cfJsonAliases) {
          const multiAlias = cfMultiAliasByAlias.get(alias);
          const isMulti = multiAlias ? Boolean(row[multiAlias]) : false;
          let raw = row[alias];
          if (typeof raw === "string") {
            try {
              raw = JSON.parse(raw);
            } catch {
            }
          }
          if (isMulti) {
            if (raw == null) row[alias] = [];
            else if (Array.isArray(raw)) row[alias] = raw;
            else row[alias] = [raw];
          } else {
            if (Array.isArray(raw)) row[alias] = raw.length > 0 ? raw[0] : null;
            else row[alias] = raw;
          }
          if (multiAlias) delete row[multiAlias];
        }
      }
    };
    let pagedItems;
    let encryptedSortRowCapWarning;
    if (requiresPlaintextSort) {
      const cap = resolveEncryptedSortMaxRows();
      let qSort = (await buildQuery("sortKeys")).builder;
      if (cap !== null) {
        qSort = qSort.limit(cap + 1).orderBy(qualify("id"), "asc");
      }
      const candidateRowsRaw = await qSort.execute();
      const sortTruncated = cap !== null && candidateRowsRaw.length > cap;
      const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw;
      const decryptedCandidates = decryptPayload ? await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow) : candidateRows;
      const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts);
      const pageIds = orderedCandidates.slice((page - 1) * pageSize, page * pageSize).map((row) => row.id);
      if (sortTruncated && cap !== null) {
        encryptedSortRowCapWarning = {
          entity,
          sortFields: resolvedSorts.map((s) => s.field),
          maxRows: cap,
          totalMatched: total
        };
      }
      if (pageIds.length === 0) {
        pagedItems = [];
      } else {
        const pageRows = await qFull.where(qualify("id"), "in", pageIds).execute();
        normalizeCfJsonAliases(pageRows);
        const decryptedPageRows = decryptPayload ? await mapWithConcurrency(pageRows, DECRYPT_CONCURRENCY, decryptRow) : pageRows;
        const byId = new Map(decryptedPageRows.map((row) => [String(row.id), row]));
        pagedItems = pageIds.map((id) => byId.get(String(id))).filter((row) => row != null);
      }
    } else {
      const dataQuery = qFull.limit(pageSize).offset((page - 1) * pageSize);
      const items = await dataQuery.execute();
      normalizeCfJsonAliases(items);
      pagedItems = decryptPayload ? await mapWithConcurrency(items, DECRYPT_CONCURRENCY, decryptRow) : items;
    }
    let queryResult = { items: pagedItems, page, pageSize, total };
    if (encryptedSortRowCapWarning || listCountCapWarning) {
      const meta = {};
      if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning;
      if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning;
      queryResult.meta = meta;
    }
    if (ext && extensionCtx) {
      const diCtx = ext.resolve ? { resolve: ext.resolve } : noop;
      queryResult = await runAfterQueryPipeline(
        queryResult,
        opts,
        extensionCtx,
        diCtx
      );
    }
    if (resolvedCustomFieldDefinitions) {
      queryResult.customFieldDefinitions = resolvedCustomFieldDefinitions;
    }
    return queryResult;
  }
  applyColumnOp(builder, column, op, value) {
    switch (op) {
      case "eq":
        return value === null ? builder.where(column, "is", null) : builder.where(column, "=", value);
      case "ne":
        return value === null ? builder.where(column, "is not", null) : builder.where(column, "!=", value);
      case "gt":
        return builder.where(column, ">", value);
      case "gte":
        return builder.where(column, ">=", value);
      case "lt":
        return builder.where(column, "<", value);
      case "lte":
        return builder.where(column, "<=", value);
      case "in":
        return builder.where(column, "in", Array.isArray(value) ? value : [value]);
      case "nin":
        return builder.where(column, "not in", Array.isArray(value) ? value : [value]);
      case "like":
        return builder.where(column, "like", value);
      case "ilike":
        return builder.where(column, "ilike", value);
      case "exists":
        return value ? builder.where(column, "is not", null) : builder.where(column, "is", null);
      default:
        return builder;
    }
  }
  /**
   * Apply a `cf:*` filter as a correlated EXISTS semi-join over
   * `custom_field_values` (+ `custom_field_defs` for kind-based coercion) —
   * the count shape's equivalent of the projection path's leftJoin + WHERE.
   * A semi-join returns each base row at most once, so the count query needs
   * no DISTINCT or GROUP BY and stays boundable by an outer LIMIT.
   *
   * Predicates satisfied by the *absence* of a value row (`eq null`,
   * `exists: false`) become `NOT EXISTS(value) OR EXISTS(null value)`,
   * matching the leftJoin form where a missing row yields a NULL expression.
   */
  applyCfValueExistsFilter(q, opts) {
    return q.where((eb) => this.buildCfValueExistsExpression(eb, opts));
  }
  /**
   * Expression-returning core of `applyCfValueExistsFilter`, so a cf leaf
   * inside an OR group can compile to an EXISTS predicate on the count shape
   * instead of being dropped for lacking a `cfValueExprByKey` entry.
   */
  buildCfValueExistsExpression(eb, opts) {
    const { source, qualify, tenantId, key, op, value } = opts;
    const seq = this.searchAliasSeq++;
    const valAlias = `cfev_${seq}`;
    const defAlias = `cfed_${seq}`;
    const srcAlias = `cfes_${seq}`;
    const caseExpr = sql`CASE ${sql.ref(`${defAlias}.kind`)}
         WHEN 'integer' THEN (${sql.ref(`${valAlias}.value_int`)})::text
         WHEN 'float' THEN (${sql.ref(`${valAlias}.value_float`)})::text
         WHEN 'boolean' THEN (${sql.ref(`${valAlias}.value_bool`)})::text
         WHEN 'multiline' THEN (${sql.ref(`${valAlias}.value_multiline`)})::text
         ELSE (${sql.ref(`${valAlias}.value_text`)})::text
       END`;
    const buildSub = (eb2) => {
      let sub = eb2.selectFrom(`custom_field_values as ${valAlias}`).select(sql`1`.as("one")).leftJoin(`custom_field_defs as ${defAlias}`, (jb) => jb.on(`${defAlias}.entity_id`, "=", String(source.entityId)).on(`${defAlias}.key`, "=", key).on(`${defAlias}.is_active`, "=", true).on((jeb) => jeb.or([
        jeb(`${defAlias}.tenant_id`, "=", tenantId),
        jeb(`${defAlias}.tenant_id`, "is", null)
      ]))).where(`${valAlias}.entity_id`, "=", String(source.entityId)).where(`${valAlias}.field_key`, "=", key).where((web) => web.or([
        web(`${valAlias}.tenant_id`, "=", tenantId),
        web(`${valAlias}.tenant_id`, "is", null)
      ]));
      if (source.hop) {
        sub = sub.innerJoin(`${source.table} as ${srcAlias}`, (jb) => jb.on(sql`${sql.ref(`${valAlias}.record_id`)} = (${sql.ref(`${srcAlias}.${source.hop.recordIdColumn}`)})::text`)).whereRef(`${srcAlias}.${source.hop.toField}`, "=", qualify(source.hop.fromField));
      } else {
        sub = sub.where(sql`${sql.ref(`${valAlias}.record_id`)} = ${source.recordIdExpr}`);
      }
      return sub;
    };
    const absenceSatisfiable = op === "eq" && value === null || op === "exists" && !value;
    if (absenceSatisfiable) {
      return eb.or([
        eb.not(eb.exists(buildSub(eb))),
        eb.exists(buildSub(eb).where(sql`${caseExpr} is null`))
      ]);
    }
    let predicate = null;
    switch (op) {
      case "eq":
        predicate = sql`${caseExpr} = ${value}`;
        break;
      case "ne":
        predicate = value === null ? sql`${caseExpr} is not null` : sql`${caseExpr} != ${value}`;
        break;
      case "gt":
      case "gte":
      case "lt":
      case "lte": {
        const operator = sql.raw(op === "gt" ? ">" : op === "gte" ? ">=" : op === "lt" ? "<" : "<=");
        predicate = sql`${caseExpr} ${operator} ${value}`;
        break;
      }
      case "in": {
        const vals = Array.isArray(value) ? value : [value];
        predicate = sql`${caseExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
        break;
      }
      case "nin": {
        const vals = Array.isArray(value) ? value : [value];
        predicate = sql`${caseExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`;
        break;
      }
      case "like":
        predicate = sql`${caseExpr} like ${value}`;
        break;
      case "ilike":
        predicate = sql`${caseExpr} ilike ${value}`;
        break;
      case "exists":
        predicate = sql`${caseExpr} is not null`;
        break;
      default:
        return eb.val(true);
    }
    const captured = predicate;
    return eb.exists(buildSub(eb).where(captured));
  }
  buildColumnOpExpression(eb, column, op, value) {
    switch (op) {
      case "eq":
        return value === null ? eb(column, "is", null) : eb(column, "=", value);
      case "ne":
        return value === null ? eb(column, "is not", null) : eb(column, "!=", value);
      case "gt":
        return eb(column, ">", value);
      case "gte":
        return eb(column, ">=", value);
      case "lt":
        return eb(column, "<", value);
      case "lte":
        return eb(column, "<=", value);
      case "in":
        return eb(column, "in", Array.isArray(value) ? value : [value]);
      case "nin":
        return eb(column, "not in", Array.isArray(value) ? value : [value]);
      case "like":
        return eb(column, "like", value);
      case "ilike":
        return eb(column, "ilike", value);
      case "exists":
        return value ? eb(column, "is not", null) : eb(column, "is", null);
      default:
        return eb.val(true);
    }
  }
  async resolveBaseColumn(table, field) {
    if (await this.columnExists(table, field)) return field;
    if (field === "organization_id" && await this.columnExists(table, "id")) return "id";
    return null;
  }
  async columnExists(table, column) {
    const key = `${table}.${column}`;
    if (this.columnCache.has(key)) {
      const cached = this.columnCache.get(key);
      if (cached === true) return true;
      this.columnCache.delete(key);
    }
    const db = this.getDb();
    const exists = await db.selectFrom("information_schema.columns").select(sql`1`.as("one")).where("table_name", "=", table).where("column_name", "=", column).limit(1).executeTakeFirst();
    const present = !!exists;
    if (present) this.columnCache.set(key, true);
    else this.columnCache.delete(key);
    return present;
  }
  applySearchTokens(q, opts) {
    if (!opts.hashes.length) {
      this.logSearchDebug("search:skip-no-hashes", {
        entity: opts.entity,
        field: opts.field,
        tenantId: opts.tenantId ?? null,
        organizationScope: opts.organizationScope
      });
      return { applied: false, builder: q };
    }
    const alias = `st_${this.searchAliasSeq++}`;
    const engine = this;
    this.logSearchDebug("search:apply-search-tokens", {
      entity: opts.entity,
      field: opts.field,
      alias,
      tokenCount: opts.hashes.length,
      tokens: opts.tokens,
      tenantId: opts.tenantId ?? null,
      organizationScope: opts.organizationScope,
      combineWith: opts.combineWith ?? "and"
    });
    const buildSub = (eb) => {
      let sub = eb.selectFrom(`search_tokens as ${alias}`).select(sql`1`.as("one")).where(`${alias}.entity_type`, "=", opts.entity).where(`${alias}.field`, "=", opts.field).where(sql`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`).where(`${alias}.token_hash`, "in", opts.hashes).groupBy([`${alias}.entity_id`, `${alias}.field`]).having(sql`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`);
      if (opts.tenantId !== void 0) {
        sub = sub.where(sql`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`);
      }
      if (opts.organizationScope) {
        sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope);
      }
      return sub;
    };
    const combiner = opts.combineWith === "or" ? "or" : "and";
    if (combiner === "or") {
      const next2 = q.where((eb) => eb.or([eb.exists(buildSub(eb))]));
      return { applied: true, builder: next2 };
    }
    const next = q.where((eb) => eb.exists(buildSub(eb)));
    return { applied: true, builder: next };
  }
  applyIndexDocFilter(q, opts) {
    if ((opts.op === "like" || opts.op === "ilike") && opts.searchActive && typeof opts.value === "string") {
      const tokens = tokenizeText(String(opts.value), opts.searchConfig);
      const hashes = tokens.hashes;
      if (hashes.length) {
        const result = this.applySearchTokens(q, {
          entity: opts.entity,
          field: opts.field,
          hashes,
          recordIdColumn: opts.recordIdColumn,
          tenantId: opts.tenantId ?? null,
          organizationScope: opts.organizationScope,
          tokens: tokens.tokens
        });
        this.logSearchDebug("search:index-doc-filter", {
          entity: opts.entity,
          field: opts.field,
          tokens: tokens.tokens,
          hashes,
          applied: result.applied,
          tenantId: opts.tenantId ?? null,
          organizationScope: opts.organizationScope
        });
        if (result.applied) return result.builder;
      } else {
        this.logSearchDebug("search:index-doc-skip-empty-hashes", {
          entity: opts.entity,
          field: opts.field,
          value: opts.value
        });
      }
      return q;
    }
    return q.where((eb) => this.buildIndexDocOpExpression(eb, opts));
  }
  // Builds the entity_indexes EXISTS expression for a single doc-field operator,
  // shared by the regular doc-filter path (applyIndexDocFilter) and the OR-group
  // path so `$or` queries over custom-entity doc fields resolve instead of being
  // silently dropped (#3229).
  buildIndexDocOpExpression(eb, opts) {
    const alias = `ei_${this.searchAliasSeq++}`;
    const engine = this;
    return eb.exists((() => {
      let sub = eb.selectFrom(`entity_indexes as ${alias}`).select(sql`1`.as("one")).where(`${alias}.entity_type`, "=", opts.entity).where(sql`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`);
      if (opts.tenantId !== void 0) {
        sub = sub.where(sql`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`);
      }
      if (opts.organizationScope) {
        sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope);
      }
      if (!opts.withDeleted) {
        sub = sub.where(`${alias}.deleted_at`, "is", null);
      }
      const textExpr = sql`(${sql.ref(`${alias}.doc`)} ->> ${opts.field})`;
      switch (opts.op) {
        case "eq":
          sub = opts.value === null ? sub.where(sql`${textExpr} is null`) : sub.where(sql`${textExpr} = ${opts.value}`);
          break;
        case "ne":
          sub = opts.value === null ? sub.where(sql`${textExpr} is not null`) : sub.where(sql`${textExpr} <> ${opts.value}`);
          break;
        case "gt":
        case "gte":
        case "lt":
        case "lte": {
          const operator = sql.raw(opts.op === "gt" ? ">" : opts.op === "gte" ? ">=" : opts.op === "lt" ? "<" : "<=");
          sub = sub.where(sql`${textExpr} ${operator} ${opts.value}`);
          break;
        }
        case "in": {
          const vals = Array.isArray(opts.value) ? opts.value : [opts.value];
          sub = sub.where(sql`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`);
          break;
        }
        case "nin": {
          const vals = Array.isArray(opts.value) ? opts.value : [opts.value];
          sub = sub.where(sql`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`);
          break;
        }
        case "like":
          sub = sub.where(sql`${textExpr} like ${opts.value}`);
          break;
        case "ilike":
          sub = sub.where(sql`${textExpr} ilike ${opts.value}`);
          break;
        case "exists":
          sub = opts.value ? sub.where(sql`${textExpr} is not null`) : sub.where(sql`${textExpr} is null`);
          break;
        default:
          break;
      }
      return sub;
    })());
  }
  configureCustomFieldSources(q, baseTable, baseEntity, db, opts, qualify, attachJoins = true) {
    const sources = [
      {
        entityId: baseEntity,
        alias: "base",
        table: baseTable,
        recordIdExpr: sql`${sql.ref(`${baseTable}.id`)}::text`
      }
    ];
    const extras = opts.customFieldSources ?? [];
    let next = q;
    extras.forEach((srcOpt, index) => {
      const joinTable = srcOpt.table ?? resolveEntityTableName(this.em, srcOpt.entityId);
      const alias = srcOpt.alias ?? `cfs_${index}`;
      const join = srcOpt.join;
      if (!join) {
        throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`);
      }
      const joinType = (join.type ?? "left") === "inner" ? "inner" : "left";
      if (attachJoins) {
        const joinFn = joinType === "inner" ? "innerJoin" : "leftJoin";
        next = next[joinFn](`${joinTable} as ${alias}`, (jb) => jb.onRef(`${alias}.${join.toField}`, "=", qualify(join.fromField)));
      } else if (joinType === "inner") {
        next = next.where((eb) => eb.exists(
          eb.selectFrom(`${joinTable} as ${alias}`).select(sql`1`.as("one")).whereRef(`${alias}.${join.toField}`, "=", qualify(join.fromField))
        ));
      }
      const recordColumn = srcOpt.recordIdColumn ?? "id";
      sources.push({
        entityId: srcOpt.entityId,
        alias,
        table: joinTable,
        recordIdExpr: sql`${sql.ref(`${alias}.${recordColumn}`)}::text`,
        hop: { fromField: join.fromField, toField: join.toField, recordIdColumn: recordColumn, type: joinType }
      });
    });
    return { builder: next, sources };
  }
  logSearchDebug(event, payload) {
    try {
      logger.debug(event, payload);
    } catch {
    }
  }
  resolveOrganizationScope(opts) {
    if (opts.organizationIds !== void 0) {
      const raw = (opts.organizationIds ?? []).map((id) => typeof id === "string" ? id.trim() : id);
      const includeNull = raw.some((id) => id == null || id === "");
      const ids = raw.filter((id) => typeof id === "string" && id.length > 0);
      return { ids: Array.from(new Set(ids)), includeNull };
    }
    if (typeof opts.organizationId === "string" && opts.organizationId.trim().length > 0) {
      return { ids: [opts.organizationId], includeNull: false };
    }
    return null;
  }
  applyOrganizationScope(q, column, scope) {
    if (!scope) return q;
    if (scope.ids.length === 0 && !scope.includeNull) {
      return q.where(sql`1 = 0`);
    }
    return q.where((eb) => {
      const parts = [];
      if (scope.ids.length > 0) parts.push(eb(column, "in", scope.ids));
      if (scope.includeNull) parts.push(eb(column, "is", null));
      if (parts.length === 1) return parts[0];
      return eb.or(parts);
    });
  }
}
export {
  BasicQueryEngine,
  ENTITY_ID_PATTERN,
  clearEncryptedLikeFieldsCache,
  isEncryptedLikeField,
  isValidEntityIdShape,
  resolveEncryptedLikeFieldSet,
  resolveEntityTableName,
  resolveRegisteredEntityTableName
};
//# sourceMappingURL=engine.js.map
