import { unreachable, assert } from "../../../../shared/src/asserts.js";
import { assertJSONValue } from "../../../../shared/src/json.js";
import { promiseVoid } from "../../../../shared/src/resolved-promises.js";
import { parse } from "../../../../shared/src/valita.js";
import { rowSchema } from "../../../../zero-protocol/src/data.js";
import { ProtocolError } from "../../../../zero-protocol/src/error.js";
import { primaryKeyValueRecordSchema } from "../../../../zero-protocol/src/primary-key.js";
import { mutationResultSchema } from "../../../../zero-protocol/src/push.js";
import { getOrCreateHistogram, getOrCreateCounter } from "../../observability/metrics.js";
import { getLogLevel, wrapWithProtocolError } from "../../types/error-with-level.js";
import { upstreamSchema } from "../../types/shards.js";
import { cookieToVersion, versionToCookie, cmpVersions, versionToNullableCookie } from "./schema/types.js";
import { object, number, string } from "@badrap/valita";
const NOOP = {
  addPatch: () => promiseVoid,
  cancel: () => promiseVoid,
  end: () => promiseVoid
};
function startPoke(clients, tentativeVersion) {
  const pokers = clients.map((c) => c.startPoke(tentativeVersion));
  return {
    addPatch: async (patch) => {
      await Promise.allSettled(pokers.map((poker) => poker.addPatch(patch)));
    },
    cancel: async () => {
      await Promise.allSettled(pokers.map((poker) => poker.cancel()));
    },
    end: async (finalVersion) => {
      await Promise.allSettled(pokers.map((poker) => poker.end(finalVersion)));
    }
  };
}
const PART_COUNT_FLUSH_THRESHOLD = 100;
class ClientHandler {
  #clientGroupID;
  clientID;
  wsID;
  #zeroClientsTable;
  #zeroMutationsTable;
  #lc;
  #downstream;
  #baseVersion;
  #pokeTime = getOrCreateHistogram("sync", "poke.time", {
    description: "Time elapsed for each poke transaction. Canceled / noop pokes are excluded.",
    unit: "s"
  });
  #pokeTransactions = getOrCreateCounter(
    "sync",
    "poke.transactions",
    "Count of poke transactions."
  );
  #pokedRows = getOrCreateCounter(
    "sync",
    "poke.rows",
    "Count of poked rows."
  );
  constructor(lc, clientGroupID, clientID, wsID, shard, baseCookie, downstream) {
    lc.debug?.("new client handler");
    this.#clientGroupID = clientGroupID;
    this.clientID = clientID;
    this.wsID = wsID;
    this.#zeroClientsTable = `${upstreamSchema(shard)}.clients`;
    this.#zeroMutationsTable = `${upstreamSchema(shard)}.mutations`;
    this.#lc = lc;
    this.#downstream = downstream;
    this.#baseVersion = cookieToVersion(baseCookie);
  }
  version() {
    return this.#baseVersion;
  }
  async #push(msg) {
    const { result } = this.#downstream.push(msg);
    await result;
  }
  fail(e) {
    this.#lc[getLogLevel(e)]?.(
      `view-syncer closing connection with error: ${String(e)}`,
      e
    );
    this.#downstream.fail(wrapWithProtocolError(e));
  }
  close(reason) {
    this.#lc.debug?.(`view-syncer closing connection: ${reason}`);
    this.#downstream.cancel();
  }
  startPoke(tentativeVersion) {
    const pokeID = versionToCookie(tentativeVersion);
    const lc = this.#lc.withContext("pokeID", pokeID);
    if (cmpVersions(this.#baseVersion, tentativeVersion) >= 0) {
      lc.info?.(`already caught up, not sending poke.`);
      return NOOP;
    }
    const baseCookie = versionToNullableCookie(this.#baseVersion);
    const cookie = versionToCookie(tentativeVersion);
    lc.info?.(`starting poke from ${baseCookie} to ${cookie}`);
    const start = performance.now();
    const pokeStart = { pokeID, baseCookie };
    let pokeStarted = false;
    let body;
    let partCount = 0;
    const ensureBody = async () => {
      if (!pokeStarted) {
        await this.#push(["pokeStart", pokeStart]);
        pokeStarted = true;
      }
      return body ??= { pokeID };
    };
    const flushBody = async () => {
      if (body) {
        await this.#push(["pokePart", body]);
        body = void 0;
        partCount = 0;
      }
    };
    const addPatch = async (patchToVersion) => {
      const { patch, toVersion } = patchToVersion;
      if (cmpVersions(toVersion, this.#baseVersion) <= 0) {
        return;
      }
      const body2 = await ensureBody();
      const { type, op } = patch;
      switch (type) {
        case "query": {
          const patches = patch.clientID ? (body2.desiredQueriesPatches ??= {})[patch.clientID] ??= [] : body2.gotQueriesPatch ??= [];
          if (op === "put") {
            patches.push({ op, hash: patch.id });
          } else {
            patches.push({ op, hash: patch.id });
          }
          break;
        }
        case "row":
          if (patch.id.table === this.#zeroClientsTable) {
            this.#updateLMIDs(body2.lastMutationIDChanges ??= {}, patch);
          } else if (patch.id.table === this.#zeroMutationsTable) {
            const patches = body2.mutationsPatch ??= [];
            if (op === "put") {
              const row = parse(
                ensureSafeJSON(patch.contents),
                mutationRowSchema,
                "passthrough"
              );
              patches.push({
                op: "put",
                mutation: {
                  id: {
                    clientID: row.clientID,
                    id: row.mutationID
                  },
                  result: row.result
                }
              });
            } else {
              const { clientID, mutationID } = patch.id.rowKey;
              assert(
                typeof clientID === "string",
                "client id must be a string"
              );
              const id = Number(mutationID);
              assert(
                !Number.isNaN(id) && Number.isFinite(id) && id >= 0,
                "mutation id must be a finite number"
              );
              patches.push({
                op: "del",
                id: {
                  clientID,
                  id
                }
              });
            }
          } else {
            (body2.rowsPatch ??= []).push(makeRowPatch(patch));
          }
          break;
        default:
          unreachable();
      }
      if (++partCount >= PART_COUNT_FLUSH_THRESHOLD) {
        await flushBody();
      }
    };
    return {
      addPatch: async (patchToVersion) => {
        try {
          await addPatch(patchToVersion);
          if (patchToVersion.patch.type === "row") {
            this.#pokedRows.add(1);
          }
        } catch (e) {
          this.#downstream.fail(wrapWithProtocolError(e));
        }
      },
      cancel: async () => {
        if (pokeStarted) {
          await this.#push(["pokeEnd", { pokeID, cookie: "", cancel: true }]);
        }
      },
      end: async (finalVersion) => {
        const cookie2 = versionToCookie(finalVersion);
        if (!pokeStarted) {
          if (cmpVersions(this.#baseVersion, finalVersion) === 0) {
            return;
          }
          await this.#push(["pokeStart", pokeStart]);
        } else if (cmpVersions(this.#baseVersion, finalVersion) >= 0) {
          throw new Error(
            `Patches were sent but finalVersion ${finalVersion} is not greater than baseVersion ${this.#baseVersion}`
          );
        }
        await flushBody();
        await this.#push(["pokeEnd", { pokeID, cookie: cookie2 }]);
        this.#baseVersion = finalVersion;
        const elapsed = performance.now() - start;
        this.#pokeTransactions.add(1);
        this.#pokeTime.record(elapsed / 1e3);
      }
    };
  }
  async sendDeleteClients(lc, deletedClientIDs, deletedClientGroupIDs) {
    const deleteClientsBody = {};
    if (deletedClientIDs.length > 0) {
      deleteClientsBody.clientIDs = deletedClientIDs;
    }
    if (deletedClientGroupIDs.length > 0) {
      deleteClientsBody.clientGroupIDs = deletedClientGroupIDs;
    }
    lc.debug?.("sending deleteClients", deleteClientsBody);
    await this.#push(["deleteClients", deleteClientsBody]);
  }
  sendQueryTransformApplicationErrors(errors) {
    void this.#push(["transformError", errors]);
  }
  sendQueryTransformFailedError(error) {
    this.fail(new ProtocolError(error));
  }
  sendInspectResponse(lc, response) {
    lc.debug?.("sending inspect response", response);
    this.#downstream.push(["inspect", response]);
  }
  #updateLMIDs(lmids, patch) {
    if (patch.op === "put") {
      const row = ensureSafeJSON(patch.contents);
      const { clientGroupID, clientID, lastMutationID } = parse(
        row,
        lmidRowSchema,
        "passthrough"
      );
      if (clientGroupID !== this.#clientGroupID) {
        this.#lc.error?.(
          `Received clients row for wrong clientGroupID. Ignoring.`,
          clientGroupID
        );
      } else {
        lmids[clientID] = lastMutationID;
      }
    } else {
      patch.op;
    }
  }
}
const lmidRowSchema = object({
  clientGroupID: string(),
  clientID: string(),
  lastMutationID: number()
  // Actually returned as a bigint, but converted by ensureSafeJSON().
});
const mutationRowSchema = object({
  clientGroupID: string(),
  clientID: string(),
  mutationID: number(),
  result: mutationResultSchema
});
function makeRowPatch(patch) {
  const {
    op,
    id: { table: tableName, rowKey: id }
  } = patch;
  switch (op) {
    case "put":
      return {
        op: "put",
        tableName,
        value: parse(ensureSafeJSON(patch.contents), rowSchema)
      };
    case "del":
      return {
        op,
        tableName,
        id: parse(id, primaryKeyValueRecordSchema)
      };
    default:
      unreachable();
  }
}
function ensureSafeJSON(row) {
  const modified = Object.entries(row).filter(([k, v2]) => {
    if (typeof v2 === "bigint") {
      if (v2 >= Number.MIN_SAFE_INTEGER && v2 <= Number.MAX_SAFE_INTEGER) {
        return true;
      }
      throw new Error(`Value of "${k}" exceeds safe Number range (${v2})`);
    } else if (typeof v2 === "object") {
      assertJSONValue(v2);
    }
    return false;
  }).map(([k, v2]) => [k, Number(v2)]);
  return modified.length ? { ...row, ...Object.fromEntries(modified) } : row;
}
export {
  ClientHandler,
  ensureSafeJSON,
  startPoke
};
//# sourceMappingURL=client-handler.js.map
