import { resolver } from "@rocicorp/resolver";
import { assert, unreachable } from "../../../shared/src/asserts.js";
import { getErrorDetails } from "../../../shared/src/error.js";
import { must } from "../../../shared/src/must.js";
import { emptyObject } from "../../../shared/src/sentinels.js";
import { parse } from "../../../shared/src/valita.js";
import { wrapWithApplicationError, ApplicationError, isApplicationError } from "../../../zero-protocol/src/application-error.js";
import { PushFailed, InvalidPush } from "../../../zero-protocol/src/error-kind-enum.js";
import { ZeroCache, Server } from "../../../zero-protocol/src/error-origin-enum.js";
import { Internal, HTTP } from "../../../zero-protocol/src/error-reason-enum.js";
import { ProtocolError } from "../../../zero-protocol/src/error.js";
import { mutationResultSchema } from "../../../zero-protocol/src/push.js";
import { isZeroError } from "./error.js";
import { MUTATIONS_KEY_PREFIX } from "./keys.js";
let currentEphemeralID = 0;
function nextEphemeralID() {
  return ++currentEphemeralID;
}
const successResultDetails = { type: "success" };
class MutationTracker {
  #outstandingMutations;
  #ephemeralIDsByMutationID;
  #allMutationsAppliedListeners;
  #lc;
  #ackMutations;
  #onFatalError;
  #clientID;
  #largestOutstandingMutationID;
  #currentMutationID;
  constructor(lc, ackMutations, onFatalError) {
    this.#lc = lc.withContext("MutationTracker");
    this.#outstandingMutations = /* @__PURE__ */ new Map();
    this.#ephemeralIDsByMutationID = /* @__PURE__ */ new Map();
    this.#allMutationsAppliedListeners = /* @__PURE__ */ new Set();
    this.#largestOutstandingMutationID = 0;
    this.#currentMutationID = 0;
    this.#ackMutations = ackMutations;
    this.#onFatalError = onFatalError;
  }
  setClientIDAndWatch(clientID, experimentalWatch) {
    assert(this.#clientID === void 0, "clientID already set");
    this.#clientID = clientID;
    experimentalWatch(
      (diffs) => {
        this.#processMutationResponses(diffs);
      },
      {
        prefix: MUTATIONS_KEY_PREFIX + clientID + "/",
        initialValuesInFirstDiff: true
      }
    );
  }
  trackMutation() {
    const id = nextEphemeralID();
    const mutationResolver = resolver();
    this.#outstandingMutations.set(id, {
      resolver: mutationResolver
    });
    return { ephemeralID: id, serverPromise: mutationResolver.promise };
  }
  mutationIDAssigned(id, mutationID) {
    const entry = this.#outstandingMutations.get(id);
    if (entry) {
      entry.mutationID = mutationID;
      this.#ephemeralIDsByMutationID.set(mutationID, id);
      this.#largestOutstandingMutationID = Math.max(
        this.#largestOutstandingMutationID,
        mutationID
      );
    }
  }
  /**
   * Reject the mutation due to an unhandled exception on the client.
   * The mutation must not have been persisted to the client store.
   */
  rejectMutation(id, e) {
    const entry = this.#outstandingMutations.get(id);
    if (entry) {
      this.#settleMutation(id, entry, wrapWithApplicationError(e));
    }
  }
  /**
   * Reject all outstanding mutations. Called when the client is in a state
   * that prevents mutations from being applied, such as offline or closed.
   */
  rejectAllOutstandingMutations(error) {
    if (this.#outstandingMutations.size === 0) {
      return;
    }
    for (const [id, entry] of this.#outstandingMutations) {
      this.#settleMutation(id, entry, error);
    }
    this.#largestOutstandingMutationID = this.#currentMutationID;
    this.#notifyAllMutationsAppliedListeners();
  }
  /**
   * Used when zero-cache pokes down mutation results.
   */
  #processMutationResponses(diffs) {
    const clientID = must(this.#clientID);
    let largestLmid = 0;
    for (const diff of diffs) {
      const mutationID = Number(
        diff.key.slice(MUTATIONS_KEY_PREFIX.length + clientID.length + 1)
      );
      assert(
        !isNaN(mutationID),
        `MutationTracker received a diff with an invalid mutation ID: ${diff.key}`
      );
      largestLmid = Math.max(largestLmid, mutationID);
      switch (diff.op) {
        case "add": {
          const result = parse(diff.newValue, mutationResultSchema);
          if ("error" in result) {
            this.#processMutationError(clientID, mutationID, result);
          } else {
            this.#processMutationOk(clientID, mutationID, result);
          }
          break;
        }
        case "del":
          break;
        case "change":
          throw new Error("MutationTracker does not expect change operations");
      }
    }
    if (largestLmid > 0) {
      this.#ackMutations({
        clientID: must(this.#clientID),
        id: largestLmid
      });
    }
  }
  processPushResponse(response) {
    if ("error" in response) {
      this.#lc.error?.(
        "Received an error response when pushing mutations",
        response
      );
      const fatalError = this.#fatalErrorFromPushError(response);
      if (fatalError) {
        this.#onFatalError(fatalError);
      }
    } else {
      this.#processPushOk(response);
    }
  }
  #fatalErrorFromPushError(error) {
    switch (error.error) {
      case "unsupportedPushVersion":
        return new ProtocolError({
          kind: PushFailed,
          origin: ZeroCache,
          reason: Internal,
          message: `Unsupported push version`,
          mutationIDs: []
        });
      case "unsupportedSchemaVersion":
        return new ProtocolError({
          kind: PushFailed,
          origin: ZeroCache,
          reason: Internal,
          message: `Unsupported schema version`,
          mutationIDs: []
        });
      case "http":
        return new ProtocolError({
          kind: PushFailed,
          origin: ZeroCache,
          reason: HTTP,
          status: error.status,
          message: `Fetch from API server returned non-OK status ${error.status}: ${error.details ?? "unknown"}`,
          mutationIDs: []
        });
      case "zeroPusher":
        return new ProtocolError({
          kind: PushFailed,
          origin: ZeroCache,
          reason: Internal,
          message: `ZeroPusher error: ${error.details ?? "unknown"}`,
          mutationIDs: []
        });
      default:
        unreachable();
    }
  }
  /**
   * DEPRECATED: to be removed when we switch to fully driving
   * mutation resolution via poke.
   *
   * When we reconnect to zero-cache, we resolve all outstanding mutations
   * whose ID is less than or equal to the lastMutationID.
   *
   * The reason is that any responses the API server sent
   * to those mutations have been lost.
   *
   * An example case: the API server responds while the connection
   * is down. Those responses are lost.
   *
   * Mutations whose LMID is > the lastMutationID are not resolved
   * since they will be retried by the client, giving us another chance
   * at getting a response.
   *
   * The only way to ensure that all API server responses are
   * received would be to have the API server write them
   * to the DB while writing the LMID.
   */
  onConnected(lastMutationID) {
    this.lmidAdvanced(lastMutationID);
  }
  /**
   * lmid advance will:
   * 1. notify "allMutationsApplied" listeners if the lastMutationID
   *    is greater than or equal to the largest outstanding mutation ID.
   * 2. resolve all mutations whose mutation ID is less than or equal to
   *    the lastMutationID.
   */
  lmidAdvanced(lastMutationID) {
    assert(
      lastMutationID >= this.#currentMutationID,
      "lmid must be greater than or equal to current lmid"
    );
    if (lastMutationID === this.#currentMutationID) {
      return;
    }
    try {
      this.#currentMutationID = lastMutationID;
      this.#resolveMutations(lastMutationID);
    } finally {
      if (lastMutationID >= this.#largestOutstandingMutationID) {
        this.#notifyAllMutationsAppliedListeners();
      }
    }
  }
  get size() {
    return this.#outstandingMutations.size;
  }
  #resolveMutations(upTo) {
    for (const [id, entry] of this.#outstandingMutations) {
      if (entry.mutationID && entry.mutationID <= upTo) {
        this.#settleMutation(id, entry, emptyObject);
      } else {
        break;
      }
    }
  }
  #processPushOk(ok) {
    for (const mutation of ok.mutations) {
      if ("error" in mutation.result) {
        this.#processMutationError(
          mutation.id.clientID,
          mutation.id.id,
          mutation.result
        );
      } else {
        this.#processMutationOk(
          mutation.id.clientID,
          mutation.id.id,
          mutation.result
        );
      }
    }
  }
  #processMutationError(clientID, mid, error) {
    assert(
      clientID === this.#clientID,
      "received mutation for the wrong client"
    );
    const ephemeralID = this.#ephemeralIDsByMutationID.get(mid);
    if (!ephemeralID) {
      this.#lc.debug?.(
        "Mutation already resolved or rejected (e.g. due to disconnect); ignore late reject."
      );
      return;
    }
    const entry = this.#outstandingMutations.get(ephemeralID);
    assert(
      entry && entry.mutationID === mid,
      `outstanding mutation not found for mutation ID ${mid} and ephemeral ID ${ephemeralID}`
    );
    if (error.error === "alreadyProcessed") {
      this.#settleMutation(ephemeralID, entry, emptyObject);
      return;
    }
    this.#settleMutation(
      ephemeralID,
      entry,
      error.error === "app" ? new ApplicationError(
        error.message ?? `Unknown application error: ${error.error}`,
        error.details ? { details: error.details } : void 0
      ) : new ProtocolError({
        kind: InvalidPush,
        origin: Server,
        reason: Internal,
        message: error.error === "oooMutation" ? "Server reported an out-of-order mutation" : `Unknown fallback error with mutation ID ${mid}: ${error.error}`,
        details: getErrorDetails(error)
      })
    );
    if (error.error === "oooMutation") {
      this.#onFatalError(
        new ProtocolError({
          kind: InvalidPush,
          origin: Server,
          reason: Internal,
          message: "Server reported an out-of-order mutation",
          details: error.details
        })
      );
    }
  }
  #processMutationOk(clientID, mid, result) {
    assert(
      clientID === this.#clientID,
      "received mutation for the wrong client"
    );
    const ephemeralID = this.#ephemeralIDsByMutationID.get(mid);
    if (!ephemeralID) {
      this.#lc.debug?.(
        "Mutation already resolved or rejected (e.g. due to disconnect); ignore late resolve."
      );
      return;
    }
    const entry = this.#outstandingMutations.get(ephemeralID);
    assert(
      entry && entry.mutationID === mid,
      `outstanding mutation not found for mutation ID ${mid} and ephemeral ID ${ephemeralID}`
    );
    this.#settleMutation(ephemeralID, entry, result);
  }
  #settleMutation(ephemeralID, entry, result) {
    if (isApplicationError(result) || isZeroError(result)) {
      entry.resolver.reject(result);
    } else {
      entry.resolver.resolve(successResultDetails);
    }
    this.#outstandingMutations.delete(ephemeralID);
    if (entry.mutationID) {
      this.#ephemeralIDsByMutationID.delete(entry.mutationID);
    }
  }
  /**
   * Be notified when all mutations have been included in the server snapshot.
   *
   * The query manager will not de-register queries from the server until there
   * are no pending mutations.
   *
   * The reason is that a mutation may need to be rebased. We do not want
   * data that was available the first time it was run to not be available
   * on a rebase.
   */
  onAllMutationsApplied(listener) {
    this.#allMutationsAppliedListeners.add(listener);
  }
  #notifyAllMutationsAppliedListeners() {
    for (const listener of this.#allMutationsAppliedListeners) {
      listener();
    }
  }
}
export {
  MutationTracker
};
//# sourceMappingURL=mutation-tracker.js.map
