import { sql } from "kysely";
import { resolveRegisteredEntityTableName } from "@open-mercato/shared/lib/query/engine";
import { resolveTenantEncryptionService } from "@open-mercato/shared/lib/encryption/customFieldValues";
import { decryptIndexDocForSearch, encryptIndexDocForStorage } from "@open-mercato/shared/lib/encryption/indexDoc";
import { upsertIndexBatch } from "./batch.js";
import { refreshCoverageSnapshot, writeCoverageCounts, applyCoverageAdjustments } from "./coverage.js";
import { prepareJob, updateJobProgress, finalizeJob } from "./jobs.js";
import { purgeOrphans } from "./stale.js";
import { isSearchDebugEnabled } from "./search-tokens.js";
import { createLogger } from "@open-mercato/shared/lib/logger";
const logger = createLogger("query_index").child({ component: "reindexer" });
const DEFAULT_REINDEX_PARTITIONS = 5;
const DEFAULT_BATCH_SIZE = 500;
const deriveOrgFromId = /* @__PURE__ */ new Set(["directory:organization"]);
const COVERAGE_REFRESH_THROTTLE_MS = 5 * 60 * 1e3;
const lastCoverageReset = /* @__PURE__ */ new Map();
const REINDEX_DECRYPT_DEBUG_KEYS = ["display_name", "first_name", "last_name", "brand_name", "legal_name", "primary_email", "primary_phone"];
function buildReindexDecryptDebugPayload(entityType, doc, scope) {
  const presentKeys = [];
  for (const key of REINDEX_DECRYPT_DEBUG_KEYS) {
    const value = doc[key];
    if (key in doc && value != null && value !== "") presentKeys.push(key);
  }
  return {
    entityType,
    tenantId: scope.tenantId ?? null,
    organizationId: scope.organizationId ?? null,
    keys: presentKeys
  };
}
async function cleanupLegacyJobScopes(db, options) {
  await db.deleteFrom("entity_index_jobs").where("entity_type", "=", options.entityType).where(sql`organization_id is not distinct from ${options.organizationId}`).where(sql`tenant_id is not distinct from ${options.tenantId}`).where(sql`partition_count is distinct from ${options.activePartitionCount}`).execute();
}
function toNumber(value) {
  if (typeof value === "number") return Number.isFinite(value) ? value : 0;
  if (typeof value === "string") {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : 0;
  }
  return 0;
}
async function getColumnSet(db, tableName) {
  try {
    const rows = await db.selectFrom("information_schema.columns").select(["column_name"]).where(sql`table_schema = current_schema()`).where("table_name", "=", tableName).execute();
    return new Set(rows.map((row) => String(row.column_name).toLowerCase()));
  } catch {
    return /* @__PURE__ */ new Set();
  }
}
async function reindexEntity(em, options) {
  const entityType = String(options?.entityType || "");
  if (!entityType) {
    return {
      processed: 0,
      total: 0,
      tenantScopes: [],
      scopes: []
    };
  }
  const tenantIdInput = options?.tenantId;
  const tenantId = tenantIdInput === "undefined" ? void 0 : tenantIdInput;
  const organizationIdInput = options?.organizationId;
  const organizationId = organizationIdInput === "undefined" ? void 0 : organizationIdInput;
  const force = options?.force === true;
  const batchSize = Number.isFinite(options?.batchSize) && options.batchSize > 0 ? Math.max(1, Math.trunc(options.batchSize)) : DEFAULT_BATCH_SIZE;
  const emitVectorize = options?.emitVectorizeEvents === true;
  const eventBus = options?.eventBus;
  const vectorService = options?.vectorService ?? null;
  const partitionCountRaw = Number.isFinite(options?.partitionCount) ? Math.max(1, Math.trunc(options.partitionCount)) : 1;
  const usingPartitions = partitionCountRaw > 1;
  const partitionIndexRaw = Number.isFinite(options?.partitionIndex) ? Math.max(0, Math.trunc(options.partitionIndex)) : 0;
  const partitionIndex = usingPartitions ? Math.min(partitionIndexRaw, partitionCountRaw - 1) : null;
  const resetCoverage = options?.resetCoverage ?? (!usingPartitions || partitionIndex === 0);
  const db = em.getKysely();
  const table = resolveRegisteredEntityTableName(em, entityType);
  if (!table || entityType === "query_index:search_token" || table === "search_tokens") {
    if (!table) {
      logger.warn("Refusing to reindex unregistered entity type", {
        entityType
      });
    }
    return {
      processed: 0,
      total: 0,
      tenantScopes: [],
      scopes: []
    };
  }
  const columns = await getColumnSet(db, table);
  const hasOrgCol = columns.has("organization_id");
  const hasTenantCol = columns.has("tenant_id");
  const hasDeletedCol = columns.has("deleted_at");
  const jobScope = {
    entityType,
    organizationId: organizationId ?? null,
    tenantId: tenantId ?? null,
    partitionIndex,
    partitionCount: usingPartitions ? partitionCountRaw : null
  };
  if (!force) {
    const activeJob = await db.selectFrom("entity_index_jobs").select(["id"]).where("entity_type", "=", entityType).where("finished_at", "is", null).where(sql`organization_id is not distinct from ${null}`).where(sql`tenant_id is not distinct from ${tenantId ?? null}`).where(sql`partition_index is not distinct from ${partitionIndex}`).where(sql`partition_count is not distinct from ${usingPartitions ? partitionCountRaw : null}`).executeTakeFirst();
    if (activeJob) {
      return {
        processed: 0,
        total: 0,
        tenantScopes: [],
        scopes: []
      };
    }
  }
  if (resetCoverage) {
    await cleanupLegacyJobScopes(db, {
      entityType,
      organizationId: jobScope.organizationId ?? null,
      tenantId: jobScope.tenantId ?? null,
      activePartitionCount: jobScope.partitionCount ?? null
    });
  }
  const scopeKey = (tenantValue, orgValue) => `${tenantValue ?? "__null__"}|${orgValue ?? "__null__"}`;
  const applyBaseWhere = (q) => {
    let chain = q;
    if (hasDeletedCol) chain = chain.where("b.deleted_at", "is", null);
    if (tenantId !== void 0 && hasTenantCol) {
      chain = tenantId === null ? chain.where("b.tenant_id", "is", null) : chain.where("b.tenant_id", "=", tenantId);
    }
    if (organizationId !== void 0 && hasOrgCol) {
      chain = organizationId === null ? chain.where("b.organization_id", "is", null) : chain.where("b.organization_id", "=", organizationId);
    }
    if (usingPartitions && partitionIndex !== null) {
      chain = chain.where(sql`mod(abs(hashtext(b.id::text)), ${partitionCountRaw}) = ${partitionIndex}`);
    }
    return chain;
  };
  const baseCounts = /* @__PURE__ */ new Map();
  const registerBaseCount = (tenantValue, orgValue, count) => {
    const key = scopeKey(tenantValue, orgValue);
    baseCounts.set(key, { tenantId: tenantValue, organizationId: orgValue, count });
  };
  const groupByTenant = hasTenantCol && tenantId === void 0;
  const groupByOrg = hasOrgCol && organizationId === void 0;
  if (groupByTenant || groupByOrg) {
    let groupQuery = applyBaseWhere(
      db.selectFrom(`${table} as b`).select(sql`count(*)`.as("count"))
    );
    if (groupByTenant) {
      groupQuery = groupQuery.select("b.tenant_id as tenant_id").groupBy("b.tenant_id");
    }
    if (groupByOrg) {
      groupQuery = groupQuery.select("b.organization_id as organization_id").groupBy("b.organization_id");
    }
    const rows = await groupQuery.execute();
    for (const row of rows) {
      const bucketTenant = groupByTenant ? row?.tenant_id ?? null : tenantId === void 0 ? null : tenantId ?? null;
      const bucketOrg = groupByOrg ? row?.organization_id ?? null : organizationId === void 0 ? null : organizationId ?? null;
      registerBaseCount(bucketTenant, bucketOrg, toNumber(row?.count));
    }
  } else {
    const row = await applyBaseWhere(
      db.selectFrom(`${table} as b`).select(sql`count(*)`.as("count"))
    ).executeTakeFirst();
    const bucketTenant = tenantId === void 0 ? null : tenantId ?? null;
    const bucketOrg = organizationId === void 0 ? null : organizationId ?? null;
    registerBaseCount(bucketTenant, bucketOrg, toNumber(row?.count));
  }
  const total = Array.from(baseCounts.values()).reduce((acc, value) => acc + (Number.isFinite(value.count) ? value.count : 0), 0);
  await prepareJob(db, jobScope, "reindexing", { totalCount: total });
  const jobRow = await db.selectFrom("entity_index_jobs").select(["started_at"]).where("entity_type", "=", entityType).where("organization_id", "is", null).where(sql`tenant_id is not distinct from ${tenantId ?? null}`).where(sql`partition_index is not distinct from ${partitionIndex}`).where(sql`partition_count is not distinct from ${usingPartitions ? partitionCountRaw : null}`).orderBy("started_at", "desc").executeTakeFirst();
  const jobStartedAt = jobRow?.started_at ? new Date(jobRow.started_at) : /* @__PURE__ */ new Date();
  const deriveOrg = deriveOrgFromId.has(entityType) ? (row) => String(row.id) : void 0;
  const scopeOverrides = {};
  if (tenantId !== void 0 && tenantId !== null) {
    scopeOverrides.tenantId = String(tenantId);
  }
  if (organizationId !== void 0 && organizationId !== null) {
    scopeOverrides.orgId = String(organizationId);
  }
  const scopeEntries = Array.from(baseCounts.values()).map((entry) => ({
    tenantId: entry.tenantId,
    organizationId: entry.organizationId
  }));
  const tenantScopes = Array.from(
    new Set(scopeEntries.map((entry) => entry.tenantId ?? null))
  );
  let processed = 0;
  let lastId = null;
  options?.onProgress?.({ processed, total, chunkSize: 0 });
  if (resetCoverage) {
    if (force) {
      try {
        let purgeQuery = db.deleteFrom("entity_indexes").where("entity_type", "=", entityType);
        if (tenantId !== void 0) {
          purgeQuery = purgeQuery.where(sql`tenant_id is not distinct from ${tenantId ?? null}`);
        }
        if (organizationId !== void 0) {
          purgeQuery = purgeQuery.where(sql`organization_id is not distinct from ${organizationId ?? null}`);
        }
        await purgeQuery.execute();
      } catch (error) {
        logger.warn("Failed to purge index rows before force reindex", {
          entityType,
          tenantId: tenantId ?? null,
          organizationId: organizationId ?? null,
          error: error instanceof Error ? error.message : error
        });
      }
      if (emitVectorize && eventBus) {
        if (tenantId !== void 0) {
          const payload = {
            entityType,
            tenantId: tenantId ?? null
          };
          if (organizationId !== void 0) payload.organizationId = organizationId ?? null;
          try {
            await eventBus.emitEvent("query_index.vectorize_purge", payload);
          } catch (err) {
            logger.warn("Failed to queue vector purge before force reindex", {
              entityType,
              tenantId: tenantId ?? null,
              organizationId: organizationId ?? null,
              error: err instanceof Error ? err.message : err
            });
          }
        } else {
          logger.warn("Skipping vector purge for force reindex without tenant scope", {
            entityType
          });
        }
      }
    }
    const nowTs = Date.now();
    for (const scope of baseCounts.values()) {
      const key = `${entityType}|${scopeKey(scope.tenantId, scope.organizationId)}`;
      const last = lastCoverageReset.get(key) ?? 0;
      if (force || nowTs - last >= COVERAGE_REFRESH_THROTTLE_MS) {
        await writeCoverageCounts(em, {
          entityType,
          tenantId: scope.tenantId,
          organizationId: scope.organizationId,
          withDeleted: false
        }, {
          baseCount: scope.count,
          indexedCount: 0,
          vectorCount: emitVectorize ? 0 : void 0
        });
        lastCoverageReset.set(key, nowTs);
      }
    }
  }
  try {
    while (true) {
      let query = applyBaseWhere(
        db.selectFrom(`${table} as b`).selectAll("b").orderBy("b.id", "asc").limit(batchSize)
      );
      if (lastId !== null) {
        query = query.where("b.id", ">", lastId);
      }
      const rows = await query.execute();
      if (!rows.length) break;
      const encryption = resolveTenantEncryptionService(em);
      const dekKeyCache = /* @__PURE__ */ new Map();
      const encryptDoc = async (targetEntity, doc, scope) => {
        return await encryptIndexDocForStorage(
          targetEntity,
          doc,
          { tenantId: scope.tenantId ?? null, organizationId: scope.organizationId ?? null },
          encryption
        );
      };
      const decryptDoc = async (targetEntity, doc, scope) => {
        const result = await decryptIndexDocForSearch(
          targetEntity,
          doc,
          { tenantId: scope.tenantId ?? null, organizationId: scope.organizationId ?? null },
          encryption,
          dekKeyCache
        );
        if (isSearchDebugEnabled()) {
          logger.debug("Reindex decrypt", buildReindexDecryptDebugPayload(targetEntity, result, scope));
        }
        return result;
      };
      await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc });
      const coverageDeltas = /* @__PURE__ */ new Map();
      for (const row of rows) {
        const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
        const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
        const key = scopeKey(scopeTenant ?? null, scopeOrg ?? null);
        const existingDelta = coverageDeltas.get(key);
        if (existingDelta) existingDelta.delta += 1;
        else coverageDeltas.set(key, {
          tenantId: scopeTenant ?? null,
          organizationId: scopeOrg ?? null,
          delta: 1
        });
      }
      if (coverageDeltas.size > 0) {
        await applyCoverageAdjustments(
          em,
          Array.from(coverageDeltas.values()).map((entry) => ({
            entityType,
            tenantId: entry.tenantId,
            organizationId: entry.organizationId,
            withDeleted: false,
            deltaBase: 0,
            deltaIndex: entry.delta
          }))
        );
      }
      if (emitVectorize && eventBus) {
        await Promise.all(
          rows.map((row) => {
            const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
            const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
            return eventBus.emitEvent("query_index.vectorize_one", {
              entityType,
              recordId: String(row.id),
              organizationId: scopeOrg,
              tenantId: scopeTenant
            }).catch(() => void 0);
          })
        );
      }
      processed += rows.length;
      lastId = String(rows[rows.length - 1].id);
      options?.onProgress?.({ processed, total, chunkSize: rows.length });
      await updateJobProgress(db, jobScope, rows.length);
    }
    await purgeOrphans(db, {
      entityType,
      tenantId,
      organizationId,
      partitionIndex: usingPartitions ? partitionIndex : null,
      partitionCount: usingPartitions ? partitionCountRaw : null,
      startedAt: jobStartedAt
    });
    if (force && vectorService && (!usingPartitions || partitionIndex === null)) {
      try {
        await vectorService.removeOrphans({
          entityId: entityType,
          tenantId,
          organizationId,
          olderThan: jobStartedAt
        });
      } catch (error) {
        logger.warn("Failed to prune vector orphans after reindex", {
          entityType,
          tenantId: tenantId ?? null,
          organizationId: organizationId ?? null,
          error: error instanceof Error ? error.message : error
        });
      }
    }
    for (const scope of scopeEntries) {
      await refreshCoverageSnapshot(
        em,
        {
          entityType,
          tenantId: scope.tenantId,
          organizationId: scope.organizationId,
          withDeleted: false
        }
      );
    }
  } finally {
    await finalizeJob(db, jobScope);
  }
  return {
    processed,
    total,
    scopes: scopeEntries,
    tenantScopes
  };
}
export {
  DEFAULT_REINDEX_PARTITIONS,
  buildReindexDecryptDebugPayload,
  reindexEntity
};
//# sourceMappingURL=reindexer.js.map
