import { startSpan, startAsyncSpan } from "../../../../otel/src/span.js";
import { assert } from "../../../../shared/src/asserts.js";
import "../../../../shared/src/bigint-json.js";
import { CustomKeyMap } from "../../../../shared/src/custom-key-map.js";
import { deepEqual } from "../../../../shared/src/json.js";
import { must } from "../../../../shared/src/must.js";
import { union, intersection, difference } from "../../../../shared/src/set-utils.js";
import { stringCompare } from "../../../../shared/src/string-compare.js";
import { ZeroCache } from "../../../../zero-protocol/src/error-origin-enum.js";
import { ProtocolError } from "../../../../zero-protocol/src/error.js";
import { DEFAULT_TTL_MS, compareTTL, clampTTL } from "../../../../zql/src/query/ttl.js";
import { recordQuery } from "../../server/anonymous-otel-start.js";
import { rowIDString } from "../../types/row-key.js";
import { upstreamSchema } from "../../types/shards.js";
import "./cvr-store.js";
import { cmpVersions, maxVersion, oneAfter } from "./schema/types.js";
import { tracer } from "./tracer.js";
import { ttlClockAsNumber } from "./ttl-clock.js";
const CLIENT_LMID_QUERY_ID = "lmids";
const CLIENT_MUTATION_RESULTS_QUERY_ID = "mutationResults";
function assertNotInternal(query) {
  if (query.type === "internal") {
    throw new Error(`Query ID ${query.id} is reserved for internal use`);
  }
}
function getMutationResultsQuery(upstreamSchema2, clientGroupID) {
  return {
    id: CLIENT_MUTATION_RESULTS_QUERY_ID,
    type: "internal",
    ast: {
      schema: "",
      table: `${upstreamSchema2}.mutations`,
      where: {
        type: "and",
        conditions: [
          {
            type: "simple",
            left: {
              type: "column",
              name: "clientGroupID"
            },
            op: "=",
            right: {
              type: "literal",
              value: clientGroupID
            }
          }
        ]
      },
      orderBy: [
        ["clientGroupID", "asc"],
        ["clientID", "asc"],
        ["mutationID", "asc"]
      ]
    }
  };
}
class CVRUpdater {
  _orig;
  _cvr;
  _cvrStore;
  /**
   * @param cvrStore The CVRStore to use for storage
   * @param cvr The current CVR
   */
  constructor(cvrStore, cvr, replicaVersion) {
    this._cvrStore = cvrStore;
    this._orig = cvr;
    this._cvr = structuredClone(cvr);
    this._cvr.replicaVersion = replicaVersion;
  }
  _setVersion(version) {
    assert(cmpVersions(this._cvr.version, version) < 0);
    this._cvr.version = version;
    return version;
  }
  /**
   * Ensures that the new CVR has a higher version than the original.
   * This method is idempotent in that it will always return the same
   * (possibly bumped) version.
   */
  _ensureNewVersion() {
    if (cmpVersions(this._orig.version, this._cvr.version) === 0) {
      this._setVersion(oneAfter(this._cvr.version));
    }
    return this._cvr.version;
  }
  async flush(lc, lastConnectTime, lastActive, ttlClock) {
    this._cvr.ttlClock = ttlClock;
    this._cvr.lastActive = lastActive;
    const flushed = await this._cvrStore.flush(
      lc,
      this._orig.version,
      this._cvr,
      lastConnectTime
    );
    if (!flushed) {
      return { cvr: this._orig, flushed: false };
    }
    return { cvr: this._cvr, flushed };
  }
}
class CVRConfigDrivenUpdater extends CVRUpdater {
  #shard;
  constructor(cvrStore, cvr, shard) {
    super(cvrStore, cvr, cvr.replicaVersion);
    this.#shard = shard;
  }
  ensureClient(id) {
    return startSpan(tracer, "CVRConfigDrivenUpdater.ensureClient", () => {
      let client = this._cvr.clients[id];
      if (client) {
        return client;
      }
      client = { id, desiredQueryIDs: [] };
      this._cvr.clients[id] = client;
      this._ensureNewVersion();
      this._cvrStore.insertClient(client);
      if (!this._cvr.queries[CLIENT_LMID_QUERY_ID]) {
        const lmidsQuery = {
          id: CLIENT_LMID_QUERY_ID,
          ast: {
            schema: "",
            table: `${upstreamSchema(this.#shard)}.clients`,
            where: {
              type: "simple",
              left: {
                type: "column",
                name: "clientGroupID"
              },
              op: "=",
              right: {
                type: "literal",
                value: this._cvr.id
              }
            },
            orderBy: [
              ["clientGroupID", "asc"],
              ["clientID", "asc"]
            ]
          },
          type: "internal"
        };
        this._cvr.queries[CLIENT_LMID_QUERY_ID] = lmidsQuery;
        this._cvrStore.putQuery(lmidsQuery);
      }
      if (!this._cvr.queries[CLIENT_MUTATION_RESULTS_QUERY_ID]) {
        const mutationResultsQuery = getMutationResultsQuery(upstreamSchema(this.#shard), this._cvr.id);
        this._cvr.queries[CLIENT_MUTATION_RESULTS_QUERY_ID] = mutationResultsQuery;
        this._cvrStore.putQuery(mutationResultsQuery);
      }
      return client;
    });
  }
  setClientSchema(lc, clientSchema) {
    startSpan(tracer, "CVRConfigDrivenUpdater.setClientSchema", () => {
      if (this._cvr.clientSchema === null) {
        this._cvr.clientSchema = clientSchema;
        this._cvrStore.putInstance(this._cvr);
      } else if (!deepEqual(this._cvr.clientSchema, clientSchema)) {
        lc.warn?.(
          `New schema ${JSON.stringify(
            clientSchema
          )} does not match existing schema ${JSON.stringify(
            this._cvr.clientSchema
          )}`
        );
        throw new ProtocolError({
          kind: "InvalidConnectionRequest",
          message: `Provided schema does not match previous schema`,
          origin: ZeroCache
        });
      }
    });
  }
  setProfileID(lc, profileID) {
    if (this._cvr.profileID !== profileID) {
      if (this._cvr.profileID !== null && !this._cvr.profileID.startsWith("cg")) {
        lc.warn?.(
          `changing profile ID from ${this._cvr.profileID} to ${profileID}`
        );
      }
      this._cvr.profileID = profileID;
      this._cvrStore.putInstance(this._cvr);
    }
  }
  putDesiredQueries(clientID, queries) {
    return startSpan(tracer, "CVRConfigDrivenUpdater.putDesiredQueries", () => {
      const patches = [];
      const client = this.ensureClient(clientID);
      const current = new Set(client.desiredQueryIDs);
      const needed = /* @__PURE__ */ new Set();
      const recordQueryForTelemetry = (q) => {
        const { ast, name, args } = q;
        if (ast) {
          recordQuery("crud");
        } else if (name && args) {
          recordQuery("custom");
        }
      };
      for (const q of queries) {
        const { hash, ttl = DEFAULT_TTL_MS } = q;
        const query = this._cvr.queries[hash];
        if (!query) {
          recordQueryForTelemetry(q);
          needed.add(hash);
          continue;
        }
        if (query.type === "internal") {
          continue;
        }
        const oldClientState = query.clientState[clientID];
        if (!oldClientState || oldClientState.inactivatedAt !== void 0) {
          recordQueryForTelemetry(q);
          needed.add(hash);
          continue;
        }
        if (compareTTL(ttl, oldClientState.ttl) > 0) {
          needed.add(hash);
        }
      }
      if (needed.size === 0) {
        return patches;
      }
      const newVersion = this._ensureNewVersion();
      client.desiredQueryIDs = [...union(current, needed)].sort(stringCompare);
      for (const id of needed) {
        const q = must(queries.find(({ hash }) => hash === id));
        const { ast, name, args } = q;
        const ttl = clampTTL(q.ttl ?? DEFAULT_TTL_MS);
        const query = this._cvr.queries[id] ?? newQueryRecord(id, ast, name, args);
        assertNotInternal(query);
        const inactivatedAt = void 0;
        query.clientState[clientID] = {
          inactivatedAt,
          ttl,
          version: newVersion
        };
        this._cvr.queries[id] = query;
        patches.push({
          toVersion: newVersion,
          patch: { type: "query", op: "put", id, clientID }
        });
        this._cvrStore.putQuery(query);
        this._cvrStore.putDesiredQuery(
          newVersion,
          query,
          client,
          false,
          inactivatedAt,
          ttl
        );
      }
      return patches;
    });
  }
  markDesiredQueriesAsInactive(clientID, queryHashes, ttlClock) {
    return this.#deleteQueries(clientID, queryHashes, ttlClock);
  }
  deleteDesiredQueries(clientID, queryHashes) {
    return this.#deleteQueries(clientID, queryHashes, void 0);
  }
  #deleteQueries(clientID, queryHashes, inactivatedAt) {
    return startSpan(tracer, "CVRConfigDrivenUpdater.#deleteQueries", () => {
      const patches = [];
      const client = this.ensureClient(clientID);
      const current = new Set(client.desiredQueryIDs);
      const unwanted = new Set(queryHashes);
      const remove = intersection(unwanted, current);
      if (remove.size === 0) {
        return patches;
      }
      const newVersion = this._ensureNewVersion();
      client.desiredQueryIDs = [...difference(current, remove)].sort(
        stringCompare
      );
      for (const id of remove) {
        const query = this._cvr.queries[id];
        if (!query) {
          continue;
        }
        assertNotInternal(query);
        let ttl = DEFAULT_TTL_MS;
        if (inactivatedAt === void 0) {
          delete query.clientState[clientID];
        } else {
          const clientState = query.clientState[clientID];
          if (clientState !== void 0) {
            assert(
              clientState.inactivatedAt === void 0,
              `Query ${id} is already inactivated`
            );
            ttl = clampTTL(clientState.ttl);
            query.clientState[clientID] = {
              inactivatedAt,
              ttl,
              version: newVersion
            };
          }
        }
        this._cvrStore.putQuery(query);
        this._cvrStore.putDesiredQuery(
          newVersion,
          query,
          client,
          true,
          inactivatedAt,
          ttl
        );
        patches.push({
          toVersion: newVersion,
          patch: { type: "query", op: "del", id, clientID }
        });
      }
      return patches;
    });
  }
  clearDesiredQueries(clientID) {
    const client = this.ensureClient(clientID);
    return this.#deleteQueries(clientID, client.desiredQueryIDs, void 0);
  }
  deleteClient(clientID, ttlClock) {
    return startSpan(tracer, "CVRConfigDrivenUpdater.deleteClient", () => {
      const client = this._cvr.clients[clientID];
      if (!client) {
        return [];
      }
      const patches = this.markDesiredQueriesAsInactive(
        clientID,
        client.desiredQueryIDs,
        ttlClock
      );
      delete this._cvr.clients[clientID];
      this._cvrStore.deleteClient(clientID);
      return patches;
    });
  }
}
class CVRQueryDrivenUpdater extends CVRUpdater {
  #removedOrExecutedQueryIDs = /* @__PURE__ */ new Set();
  #receivedRows = new CustomKeyMap(
    rowIDString
  );
  #lastPatches = new CustomKeyMap(rowIDString);
  #existingRows = void 0;
  /**
   * @param stateVersion The `stateVersion` at which the queries were executed.
   */
  constructor(cvrStore, cvr, stateVersion, replicaVersion) {
    super(cvrStore, cvr, replicaVersion);
    assert(
      // We should either be setting the cvr.replicaVersion for the first time, or it should
      // be something newer than the current cvr.replicaVersion. Otherwise, the CVR should
      // have been rejected by the ViewSyncer.
      (cvr.replicaVersion ?? replicaVersion) <= replicaVersion,
      `Cannot sync from an older replicaVersion: CVR=${cvr.replicaVersion}, DB=${replicaVersion}`
    );
    assert(
      stateVersion >= cvr.version.stateVersion,
      () => `stateVersion (${stateVersion}) must be >= cvr.version.stateVersion (${cvr.version.stateVersion})`
    );
    if (stateVersion > cvr.version.stateVersion) {
      this._setVersion({ stateVersion });
    }
  }
  /**
   * Initiates the tracking of the specified `executed` and `removed` queries.
   * This kicks of a lookup of existing {@link RowRecord}s currently associated
   * with those queries, which will be used to reconcile the rows to keep
   * after all rows have been {@link received()}.
   *
   * "transformed" queries are queries that are currently
   * gotten and running in the pipeline driver but
   * received a new transformation hash due to an auth token
   * update.
   *
   * @returns The new CVRVersion to be used when all changes are committed.
   */
  trackQueries(lc, executed, removed) {
    return startSpan(tracer, "CVRQueryDrivenUpdater.trackQueries", () => {
      assert(this.#existingRows === void 0, `trackQueries already called`);
      const queryPatches = [
        executed.map((q) => this.#trackExecuted(q.id, q.transformationHash)),
        removed.map((q) => this.#trackRemoved(q.id))
      ].flat(2);
      this.#existingRows = this.#lookupRowsForExecutedAndRemovedQueries(lc);
      void this.#existingRows.then(() => {
      });
      return {
        newVersion: this._cvr.version,
        queryPatches: queryPatches.map((patch) => ({
          patch,
          toVersion: this._cvr.version
        }))
      };
    });
  }
  #lookupRowsForExecutedAndRemovedQueries(lc) {
    return startAsyncSpan(
      tracer,
      "CVRQueryDrivenUpdater.#lookupRowsForExecutedAndRemovedQueries",
      async () => {
        const results = new CustomKeyMap(rowIDString);
        if (this.#removedOrExecutedQueryIDs.size === 0) {
          return [];
        }
        const allRowRecords = (await this._cvrStore.getRowRecords()).values();
        let total = 0;
        for (const existing of allRowRecords) {
          total++;
          assert(
            existing.refCounts !== null,
            "allRowRecords should not include null refCounts"
          );
          for (const id of Object.keys(existing.refCounts)) {
            if (this.#removedOrExecutedQueryIDs.has(id)) {
              results.set(existing.id, existing);
              break;
            }
          }
        }
        lc.debug?.(
          `found ${results.size} (of ${total}) rows for executed / removed queries ${[
            ...this.#removedOrExecutedQueryIDs
          ]}`
        );
        return results.values();
      }
    );
  }
  /**
   * Tracks an executed query, ensures that it is marked as "gotten",
   * updating the CVR and creating put patches if necessary.
   *
   * This must be called for all executed queries.
   */
  #trackExecuted(queryID, transformationHash) {
    return startSpan(tracer, "CVRQueryDrivenUpdater.#trackExecuted", () => {
      assert(
        !this.#removedOrExecutedQueryIDs.has(queryID),
        () => `Query ${queryID} already tracked as executed or removed`
      );
      this.#removedOrExecutedQueryIDs.add(queryID);
      let gotQueryPatch;
      const query = this._cvr.queries[queryID];
      if (query.transformationHash !== transformationHash) {
        const transformationVersion = this._ensureNewVersion();
        if (query.type !== "internal" && query.patchVersion === void 0) {
          query.patchVersion = transformationVersion;
          gotQueryPatch = {
            type: "query",
            op: "put",
            id: query.id
          };
        }
        query.transformationHash = transformationHash;
        query.transformationVersion = transformationVersion;
        this._cvrStore.updateQuery(query);
      }
      return gotQueryPatch ? [gotQueryPatch] : [];
    });
  }
  /**
   * Tracks a query removed from the "gotten" set. In addition to producing the
   * appropriate patches for deleting the query, the removed query is taken into
   * account when computing the final row records in
   * {@link deleteUnreferencedRows}.
   * Namely, any rows with columns that are no longer referenced by a
   * query are deleted.
   *
   * This must only be called on queries that are not "desired" by any client.
   */
  #trackRemoved(queryID) {
    return startSpan(tracer, "CVRQueryDrivenUpdater.#trackRemoved", () => {
      const query = this._cvr.queries[queryID];
      assertNotInternal(query);
      assert(
        !this.#removedOrExecutedQueryIDs.has(queryID),
        () => `Query ${queryID} already tracked as executed or removed`
      );
      this.#removedOrExecutedQueryIDs.add(queryID);
      delete this._cvr.queries[queryID];
      const newVersion = this._ensureNewVersion();
      const queryPatch = { type: "query", op: "del", id: queryID };
      this._cvrStore.markQueryAsDeleted(newVersion, queryPatch);
      return [queryPatch];
    });
  }
  /**
   * Asserts that a new version has already been set.
   *
   * After {@link #executed} and {@link #removed} are called, we must have properly
   * decided on the final CVR version because the poke-start message declares the
   * final cookie (i.e. version), and that must be sent before any poke parts
   * generated from {@link received} are sent.
   */
  #assertNewVersion() {
    assert(cmpVersions(this._orig.version, this._cvr.version) < 0);
    return this._cvr.version;
  }
  updatedVersion() {
    return this._cvr.version;
  }
  /**
   * Tracks rows received from executing queries. This will update row records
   * and row patches if the received rows have a new version. The method also
   * returns (put) patches to be returned to update their state, versioned by
   * patchVersion so that only the patches new to the clients are sent.
   */
  received(_lc, rows) {
    return startAsyncSpan(
      tracer,
      "CVRQueryDrivenUpdater.received",
      async () => {
        const patches = [];
        const existingRows = await this._cvrStore.getRowRecords();
        for (const [id, update] of rows.entries()) {
          const { contents, version, refCounts } = update;
          let existing = existingRows.get(id);
          const previouslyReceived = this.#receivedRows.get(id);
          const merged = previouslyReceived !== void 0 ? mergeRefCounts(previouslyReceived, refCounts) : mergeRefCounts(
            existing?.refCounts,
            refCounts,
            this.#removedOrExecutedQueryIDs
          );
          this.#receivedRows.set(id, merged);
          const newRowVersion = merged === null ? void 0 : version;
          const patchVersion = existing && existing.rowVersion === newRowVersion ? existing.patchVersion : this.#assertNewVersion();
          const rowVersion = version ?? existing?.rowVersion;
          if (rowVersion) {
            this._cvrStore.putRowRecord({
              id,
              rowVersion,
              patchVersion,
              refCounts: merged
            });
          } else {
            this._cvrStore.delRowRecord(id);
          }
          const lastPatch = this.#lastPatches.get(id);
          const toVersion = maxVersion(patchVersion, lastPatch?.toVersion);
          if (merged === null) {
            if (existing || previouslyReceived) {
              if (lastPatch?.rowVersion !== null) {
                patches.push({
                  patch: {
                    type: "row",
                    op: "del",
                    id
                  },
                  toVersion
                });
                this.#lastPatches.set(id, { rowVersion: null, toVersion });
              }
            }
          } else if (contents) {
            assert(
              rowVersion,
              "rowVersion is required when contents is present"
            );
            if (!lastPatch?.rowVersion || lastPatch.rowVersion < rowVersion) {
              patches.push({
                patch: {
                  type: "row",
                  op: "put",
                  id,
                  contents
                },
                toVersion
              });
              this.#lastPatches.set(id, { rowVersion, toVersion });
            }
          }
        }
        return patches;
      }
    );
  }
  /**
   * Computes and updates the row records based on:
   * * The {@link #executed} queries
   * * The {@link #removed} queries
   * * The {@link received} rows
   *
   * Returns the final delete and patch ops that must be sent to the client
   * to delete rows that are no longer referenced by any query.
   *
   * This is Step [5] of the
   * [CVR Sync Algorithm](https://www.notion.so/replicache/Sync-and-Client-View-Records-CVR-a18e02ec3ec543449ea22070855ff33d?pvs=4#7874f9b80a514be2b8cd5cf538b88d37).
   */
  deleteUnreferencedRows(lc) {
    return startAsyncSpan(
      tracer,
      "CVRQueryDrivenUpdater.deleteUnreferencedRows",
      async () => {
        if (this.#removedOrExecutedQueryIDs.size === 0) {
          assert(
            this.#receivedRows.size === 0,
            () => `Expected no received rows for query-less update, got ${this.#receivedRows.size}`
          );
          return [];
        }
        const patches = [];
        const start = Date.now();
        assert(this.#existingRows, `trackQueries() was not called`);
        for (const existing of await this.#existingRows) {
          const deletedID = this.#deleteUnreferencedRow(existing);
          if (deletedID === null) {
            continue;
          }
          patches.push({
            toVersion: this._cvr.version,
            patch: { type: "row", op: "del", id: deletedID }
          });
        }
        lc?.debug?.(
          `computed ${patches.length} delete patches (${Date.now() - start} ms)`
        );
        return patches;
      }
    );
  }
  #deleteUnreferencedRow(existing) {
    return startSpan(
      tracer,
      "CVRQueryDrivenUpdater.#deleteUnreferencedRow",
      () => {
        if (this.#receivedRows.get(existing.id)) {
          return null;
        }
        const newRefCounts = mergeRefCounts(
          existing.refCounts,
          void 0,
          this.#removedOrExecutedQueryIDs
        );
        const patchVersion = newRefCounts ? existing.patchVersion : this.#assertNewVersion();
        const rowRecord = {
          ...existing,
          patchVersion,
          refCounts: newRefCounts
        };
        this._cvrStore.putRowRecord(rowRecord);
        return newRefCounts ? null : existing.id;
      }
    );
  }
}
function mergeRefCounts(existing, received, removeHashes) {
  let merged = {};
  if (!existing) {
    merged = received ?? {};
  } else {
    [existing, received].forEach((refCounts, i) => {
      if (!refCounts) {
        return;
      }
      for (const [hash, count] of Object.entries(refCounts)) {
        if (i === 0 && removeHashes?.has(hash)) {
          continue;
        }
        merged[hash] = (merged[hash] ?? 0) + count;
        if (merged[hash] === 0) {
          delete merged[hash];
        }
      }
      return merged;
    });
  }
  return Object.values(merged).some((v) => v > 0) ? merged : null;
}
function getInactiveQueries(cvr) {
  const inactive = /* @__PURE__ */ new Map();
  for (const [queryID, query] of Object.entries(cvr.queries)) {
    if (query.type === "internal") {
      continue;
    }
    for (const clientState of Object.values(query.clientState)) {
      const { inactivatedAt, ttl } = clientState;
      const existing = inactive.get(queryID);
      if (inactivatedAt === void 0) {
        if (existing) {
          inactive.delete(queryID);
        }
        break;
      }
      const clampedTTL = clampTTL(ttl);
      if (existing) {
        const existingTTL = clampTTL(existing.ttl);
        if (existingTTL + ttlClockAsNumber(existing.inactivatedAt) < ttlClockAsNumber(inactivatedAt) + clampedTTL) {
          existing.ttl = clampedTTL;
          existing.inactivatedAt = inactivatedAt;
        }
      } else {
        inactive.set(queryID, {
          hash: queryID,
          inactivatedAt,
          ttl: clampedTTL
        });
      }
    }
  }
  return [...inactive.values()].sort((a, b) => {
    if (a.ttl === b.ttl) {
      return ttlClockAsNumber(a.inactivatedAt) - ttlClockAsNumber(b.inactivatedAt);
    }
    return ttlClockAsNumber(a.inactivatedAt) + a.ttl - ttlClockAsNumber(b.inactivatedAt) - b.ttl;
  });
}
function nextEvictionTime(cvr) {
  let next;
  for (const { inactivatedAt, ttl } of getInactiveQueries(cvr)) {
    const expire = ttlClockAsNumber(inactivatedAt) + ttl;
    if (next === void 0 || expire < next) {
      next = expire;
    }
  }
  return next;
}
function newQueryRecord(id, ast, name, args) {
  if (ast !== void 0) {
    assert(
      name === void 0 && args === void 0,
      "Cannot provide name or args with ast"
    );
    return {
      id,
      type: "client",
      ast,
      clientState: {}
    };
  }
  assert(
    name !== void 0 && args !== void 0,
    "Must provide name and args"
  );
  return {
    id,
    type: "custom",
    name,
    args,
    clientState: {}
  };
}
export {
  CVRConfigDrivenUpdater,
  CVRQueryDrivenUpdater,
  CVRUpdater,
  getInactiveQueries,
  getMutationResultsQuery,
  nextEvictionTime
};
//# sourceMappingURL=cvr.js.map
