Source: src/js/models/sysmeta/VersionTracker.js

define(["backbone", "models/sysmeta/SysMeta", "localforage", "md5"], (
  Backbone,
  SysMeta,
  localforage,
  md5,
) => {
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
  const { DEFAULT_META_SERVICE_URL } = SysMeta;
  const DEFAULT_MAX_CHAIN_HOPS = 200;
  const DEFAULT_MAX_CACHE_RECORDS = 5000;

  // Fields to persist from SysMeta in the cache
  const PERSISTED_SYS_META_FIELDS = [
    "formatId",
    "size",
    "checksum",
    "checksumAlgorithm",
    "rightsHolder",
    "dateUploaded",
    "dateSysMetadataModified",
    "fileName",
  ];

  const IS_LOCAL_FORAGE = (store) =>
    store &&
    typeof store.setItem === "function" &&
    typeof store.getItem === "function";

  const NORMALIZE_METASERVICE_URL = (url) => {
    let normalUrl = typeof url !== "string" ? DEFAULT_META_SERVICE_URL : url;
    normalUrl = normalUrl.trim();
    return normalUrl.endsWith("/") ? normalUrl : `${normalUrl}/`;
  };

  /**
   * @typedef {object} VersionResult
   * @property {string} pid - The PID of the version at the requested offset.
   * @property {SysMeta|null} sysMeta - SysMeta object for the PID, or null if
   * not fetched yet. If `withMeta` is true, this will always be populated.
   */

  /**
   * @typedef {object} VersionRecord
   * @property {string[]|VersionResult[]} next - List of PIDs that are newer.
   * May be either strings (PIDs) or objects with { pid, sysMeta } if `withMeta`
   * is true.
   * @property {string[]|VersionResult[]} prev - List of PIDs that are older.
   * May be either strings (PIDs) or objects with { pid, sysMeta } if `withMeta`
   * is true.
   * @property {boolean} endNext - true if there are no newer versions detected
   * from the SysMeta service. This is not a guarantee that no newer versions
   * exist, just that the chain is complete up to the last known version.
   * @property {boolean} endPrev - true if there are no older versions detected
   * from the SysMeta service. This is not a guarantee that no older versions
   * exist, just that the chain is complete up to the first known version.
   * @property {SysMeta|null} sysMeta - SysMeta object for the PID, or null if
   * not fetched yet. If `withMeta` is true, this will always be populated.
   */

  /**
   * @class VersionTracker
   * @classcategory Models/SysMeta
   * @since 2.34.0
   * @classdesc VersionTracker walks sysmeta version chains and caches them in
   * memory and in localForage for fast access. It allows getting PIDs at
   * arbitrary offsets from a given PID, fetching full version chains, and
   * listening for updates. It also supports manually adding a new version to
   * the chain (e.g. when a document has been updated in the editor and the new
   * version is known). A store is created for each unique SysMeta service URL,
   * so multiple VersionTracker instances can coexist without conflicts.
   * @example
   * const vt = new VersionTracker({
   *  metaServiceUrl: "https://example.com/sysmeta",
   * })
   * vt.getNth("pid123", 1).then((result) => {
   *  console.log("Next version PID:", result.pid);
   * });
   * const fullChain = await vt.getFullChain("pid123");
   * console.log("All versions in chain:", fullChain.prev, fullChain.next);
   * @example
   * // Get a singleton instance for a specific SysMeta service URL
   * // This will create a new instance if it doesn't exist yet.
   * const vt = VersionTracker.get("https://example.com/sysmeta");
   */
  class VersionTracker {
    /**
     * Create a new VersionTracker instance.
     * @param {object} options - configuration options
     * @param {string} options.metaServiceUrl - URL of the SysMeta service
     * @param {localforage} [options.store] - optional localForage instance for
     * persistent caching. If not provided, a new instance will be created
     * @param {number} [options.ttlMs] - Time-To-Live for cached records in
     * milliseconds. Defaults to 24 hours (1 day).
     * @param {number} [options.maxChainHops] - Maximum number of hops in the
     * version chain to cache. Defaults to 200 hops.
     * @param {number} [options.maxCacheRecords] - Maximum number of in-memory
     * cache records. Defaults to 5000.
     */
    constructor({
      metaServiceUrl,
      store = null,
      ttlMs = DEFAULT_TTL_MS,
      maxChainHops = DEFAULT_MAX_CHAIN_HOPS,
      maxCacheRecords = DEFAULT_MAX_CACHE_RECORDS,
    } = {}) {
      // metaServiceUrl may be undefined or invalid
      this.metaServiceUrl = NORMALIZE_METASERVICE_URL(metaServiceUrl);

      // TTL for cached records in milliseconds
      if (typeof ttlMs !== "number" || ttlMs <= 0) {
        throw new Error("Invalid TTL provided to VersionTracker");
      }
      this.TTL_MS = ttlMs;

      // avoid unbounded chain growth
      if (typeof maxChainHops !== "number" || maxChainHops <= 0) {
        throw new Error("Invalid maxChainHops provided to VersionTracker");
      }
      this.MAX_CHAIN_HOPS = maxChainHops;
      // limit in‑memory cache size (simple LRU)
      if (typeof maxCacheRecords !== "number" || maxCacheRecords <= 0) {
        throw new Error("Invalid maxCacheRecords provided to VersionTracker");
      }
      this.MAX_CACHE_RECORDS = maxCacheRecords;

      // dedup in‑flight SysMeta fetches
      this.inFlight = new Map();

      // locks for concurrent fillVersionChain calls (e.g. when multiple getNth
      // calls are made for the same PID)
      this.locks = new Map();

      // in-memory cache
      this.cache = new Map();

      // store - persistent cache (IndexedDB | localStorage)
      if (store && !IS_LOCAL_FORAGE(store)) {
        throw new Error(
          "Invalid store provided to VersionTracker. Must be a localforage instance.",
        );
      }
      // use a name based on the SysMeta service URL to avoid conflicts with
      // other instances that might use different URLs
      const storeName = `vt_${md5(this.metaServiceUrl)}`;
      this.store =
        store || localforage.createInstance({ name: storeName, storeName });
    }

    /**
     * Get the PID that is `offset` earlier or later in the version chain
     * relative to the given `pid`.
     * @param {string}  pid - the PID to start from
     * @param {number}  offset - the number of steps to move in the chain. 0 =
     * same PID, +n newer, -n older
     * @param {boolean} [ignoreEnd] Set to true to allow walking beyond cached
     * chain end (e.g. to re-check whether there's a newer version)
     * @param {boolean} [withMeta] - If true, return arrays of { pid, sysMeta }
     * instead of bare PIDs.
     * @returns {Promise<VersionResult|string|null>} - resolves to the requested
     * PID at the given offset, or null if no such version exists. If `withMeta`
     * is true, resolves to an object with { pid, sysMeta }.
     */
    async getNth(pid, offset, ignoreEnd = false, withMeta = false) {
      // Validate inputs
      if (typeof pid !== "string" || !pid) {
        throw new Error("Invalid PID provided to getNth");
      }
      if (typeof offset !== "number") {
        throw new Error("Invalid offset provided to getNth");
      }
      if (Math.abs(offset) > this.MAX_CHAIN_HOPS) {
        throw new Error(
          `Offset ${offset} exceeds maximum chain hops limit of ${this.MAX_CHAIN_HOPS}`,
        );
      }

      // If offset is 0, just return the PID itself but make sure it's cached
      if (offset === 0) {
        const rec = await this.record(pid);
        if (withMeta) {
          if (!rec.sysMeta) await this.getSysMeta(pid);
          return { pid, sysMeta: rec.sysMeta };
        }
        return pid;
      }

      // Ensure the chain is long enough in the requested direction & cache it
      const steps = Math.abs(offset);
      const forward = offset > 0;
      await this.fillVersionChain(pid, steps, forward, ignoreEnd);

      // Get the record from the cached chain and return the requested PID and
      // sysMeta
      const rec = await this.record(pid);

      const list = forward ? rec.next : rec.prev;
      const targetPid = list[steps - 1] ?? null;
      if (!targetPid) return null;

      const targetRec = await this.record(targetPid);
      if (withMeta) {
        if (!targetRec.sysMeta) await this.getSysMeta(targetPid);
        return { pid: targetPid, sysMeta: targetRec.sysMeta };
      }
      return targetPid;
    }

    /**
     * Get the complete version chain for the given PID.
     * @param {string}  pid - PID to get the chain for
     * @param {boolean} [ignoreEnd] - Re‑probe past cached end flags
     * @param {boolean} [withMeta] - If true, return arrays of { pid, sysMeta }
     * instead of bare PIDs.
     * @returns {Promise<VersionRecord>} - resolves to an object with `prev`,
     * `next`, `sysMeta`, `endPrev`, and `endNext` properties.
     */
    async getFullChain(pid, ignoreEnd = false, withMeta = false) {
      await this.fillVersionChain(pid, Infinity, true, ignoreEnd); // walk → newest
      await this.fillVersionChain(pid, Infinity, false, ignoreEnd); // walk → oldest
      const cached = this.cache.get(pid);
      const chain = {
        prev: cached.prev,
        next: cached.next,
        sysMeta: cached.sysMeta,
        endPrev: cached.endPrev,
        endNext: cached.endNext,
      };
      if (withMeta) {
        const withMetaMap = async (list) =>
          Promise.all(
            list.map(async (p) => {
              const r = await this.record(p);
              return { pid: p, sysMeta: r.sysMeta };
            }),
          );

        chain.prev = await withMetaMap(chain.prev);
        chain.next = await withMetaMap(chain.next);
      }
      return chain;
    }

    /**
     * Get the latest version PID in the version chain for the given PID.
     * @param {string} pid - the PID to get the latest version for
     * @param {boolean} [ignoreEnd] - If true, ignore end flags and continue
     * walking the chain even if it appears complete. This is useful for
     * re-checking if a newer version exists.
     * @param {boolean} [withMeta] - If true, the SysMeta will be fetched if not
     * already available in the cache.
     * @returns {Promise<string|VersionResult>} - resolves to the latest PID in
     * the chain, or an object with { pid, sysMeta } if `withMeta` is true. If
     * no versions are found, resolves to the original PID.
     */
    async getLatestVersion(pid, ignoreEnd = false, withMeta = false) {
      await this.fillVersionChain(pid, Infinity, true, ignoreEnd); // walk → newest
      const rec = await this.record(pid);
      const nextPids = rec.next;
      if (nextPids.length === 0) return pid;
      const latestPid = nextPids[nextPids.length - 1];
      if (withMeta) {
        const latestRec = await this.record(latestPid);
        if (!latestRec.sysMeta) await this.getSysMeta(latestPid);
        return { pid: latestPid, sysMeta: latestRec.sysMeta };
      }
      return latestPid;
    }

    /**
     * Refresh the version chain for the given PID by removing it from the cache
     * and re-fetching the full chain from the SysMeta service.
     * @param {string} pid - the PID to refresh
     * @returns {Promise<object>} - resolves to the refreshed chain object
     */
    async refresh(pid) {
      await this.store.removeItem(pid);
      this.cache.delete(pid);
      await this.getFullChain(pid);
      return this.cache.get(pid);
    }

    /**
     * Clear the entire cache, including in-memory and persistent store.
     * @returns {Promise<boolean>} - resolves to true if the cache was cleared
     */
    async clear() {
      await this.store.clear();
      this.cache.clear();
      this.inFlight.clear();
      this.locks.clear();
      // remove all listeners
      this.off();
      return true;
    }

    /**
     * Set the Time-To-Live (TTL) for cached records.
     * @param {number} ms - the TTL in milliseconds
     */
    setTTL(ms) {
      if (typeof ms !== "number" || ms <= 0) {
        throw new Error("Invalid TTL provided to VersionTracker");
      }
      this.TTL_MS = Number(ms) || this.TTL_MS;
    }

    /**
     * Manually register that `newPid` obsoletes (comes after) `prevPid`. Useful
     * when an external editor just created a brand‑new revision so the chain
     * can be updated immediately without refetching SysMeta. If `sysMeta` for
     * the new PID is already available, pass it to avoid a network round‑trip;
     * otherwise the tracker will fetch it lazily when first requested.
     * @param {string} prevPid - the PID of the previous version
     * @param {string} newPid - the PID of the new version
     * @param {SysMeta} [sysMeta] - optional SysMeta object for the new version.
     */
    async addVersion(prevPid, newPid, sysMeta = null) {
      await this.fillVersionChain(prevPid, 1, true);
      await this.fillVersionChain(newPid, 1, false);

      const prevRec = await this.record(prevPid);
      const newRec = await this.record(newPid);

      // newPid must not be in the chain of prevPid
      if (prevRec.next.includes(newPid) || prevRec.prev.includes(newPid)) {
        throw new Error(
          `Cannot add version: ${newPid} is already in the chain of ${prevPid}`,
        );
      }

      // prevRec must be tip, newRec must be isolated
      const prevIsTip = prevRec.endNext && prevRec.next.length === 0;
      const newIsIsolated =
        newRec.endPrev && newRec.prev.length === 0 && newRec.next.length === 0;
      if (!prevIsTip || !newIsIsolated) {
        let msg = `Cannot add version: ${newPid} as a new version of ${prevPid}`;
        if (!prevIsTip)
          msg += ` prevPid (${prevPid}) already has a newer version.`;
        if (!newIsIsolated)
          msg += ` newPid (${newPid}) already version history.`;
        throw new Error(msg);
      }

      // link the two
      prevRec.next[0] = newPid;
      prevRec.endNext = true;

      newRec.prev[0] = prevPid;
      newRec.endNext = true;
      if (sysMeta) newRec.sysMeta = sysMeta;

      await this.persist(prevPid, prevRec);
      await this.persist(newPid, newRec);

      this.notify(prevPid);
      this.notify(newPid);
    }

    /**
     * Get the next most recent version after the given PID.
     * @param {string} pid - the PID to get the next version for
     * @param {boolean} [ignoreEnd] - If true, ignore end flags and continue
     * walking the chain even if it appears complete.
     * @param {boolean} [withMeta] - If true, the SysMeta will be fetched if not
     * already available in the cache.
     * @returns {Promise<VersionResult>} - resolves to the next version PID or
     * an object with { pid, sysMeta } if `withMeta` is true. If no next version
     * is found, resolves to the original PID.
     * @since 2.34.1
     */
    getNext(pid, ignoreEnd = false, withMeta = false) {
      return this.getNth(pid, 1, ignoreEnd, withMeta);
    }

    /**
     * Get the previous version before the given PID.
     * @param {string} pid - the PID to get the previous version for
     * @param {boolean} [ignoreEnd] - If true, ignore end flags and continue
     * walking the chain even if it appears complete.
     * @param {boolean} [withMeta] - If true, the SysMeta will be fetched if not
     * already available in the cache.
     * @returns {Promise<VersionResult>} - resolves to the previous version PID
     * or an object with { pid, sysMeta } if `withMeta` is true. If no previous
     * version is found, resolves to the original PID.
     * @since 2.34.1
     */
    getPrev(pid, ignoreEnd = false, withMeta = false) {
      return this.getNth(pid, -1, ignoreEnd, withMeta);
    }

    /**
     * Get the version before and after the given PID, if available.
     * @param {string} pid - the PID to get adjacent versions for
     * @param {boolean} [ignoreEnd] - If true, ignore end flags and continue
     * walking the chain even if it appears complete.
     * @param {boolean} [withMeta] - If true, the SysMeta will be fetched if not
     * already available in the cache.
     * @returns {Promise<{pid: string, prev: VersionResult, next:
     * VersionResult}>}
     * - resolves to an object with `pid`, `prev`, and `next` properties. `prev`
     *   and `next` are either PIDs or objects with { pid, sysMeta } if
     *   `withMeta` is true. If no previous or next version is found, the
     *   respective property will be null.
     * @since 2.34.1
     */
    async getAdjacent(pid, ignoreEnd = false, withMeta = false) {
      return Promise.all([
        this.getPrev(pid, ignoreEnd, withMeta),
        this.getNext(pid, ignoreEnd, withMeta),
      ]).then(([prev, next]) => ({
        pid,
        prev,
        next,
      }));
    }

    /**
     * Notify that a specific the chain for a specific PID has been updated.
     * This is called internally after a new version is added or the chain is
     * updated.
     * @param {string} pid - the PID to notify about
     * @private
     */
    notify(pid) {
      const rec = this.cache.get(pid);
      this.trigger("update", rec);
      this.trigger(`update:${pid}`, rec);
    }

    /**
     * Ensure we have at least `stepsNeeded` hops cached in `direction` starting
     * from `startPid` (not counting the start itself).
     * @param {string} startPid - the PID to start from
     * @param {number} stepsNeeded - how many hops to fill in the chain
     * @param {boolean} forward - true to fill next versions, false for prev
     * @param {boolean} [ignoreEnd] - if true, ignore end flags and continue
     * filling the chain even if it appears complete.
     * @returns {Promise<void>} - resolves when the chain is filled
     * @private
     */
    async fillVersionChain(startPid, stepsNeeded, forward, ignoreEnd = false) {
      const steps =
        stepsNeeded === Infinity ? this.MAX_CHAIN_HOPS : stepsNeeded;

      const rec = await this.record(startPid);
      const list = forward ? rec.next : rec.prev;
      const endFlag = forward ? "endNext" : "endPrev";

      if (ignoreEnd) {
        rec[endFlag] = false;
        rec.errors = []; // reset errors if we're ignoring end
        rec.sysMeta = null; // reset SysMeta to force re-fetch
        // re-save
        await this.persist(startPid, rec);
      }
      // --- simple per‑PID/dir lock to avoid concurrent mutation ---
      const lockKey = `${startPid}`;
      /* eslint-disable no-await-in-loop */
      while (this.locks.get(lockKey)) await this.locks.get(lockKey);
      let resolveLock;
      this.locks.set(
        lockKey,
        new Promise((r) => {
          resolveLock = r;
        }),
      );
      try {
        // already long enough
        if (list.length >= steps) {
          this.notify(startPid);
          return;
        }

        let currentPid = list.length > 0 ? list[list.length - 1] : startPid;

        // Walk until we fill `stepsNeeded` slots or hit chain end
        while (
          list.length < steps &&
          currentPid &&
          (!rec[endFlag] || ignoreEnd)
        ) {
          let sysMeta;
          try {
            sysMeta = await this.getSysMeta(currentPid);
          } catch (e) {
            if (!rec.errors) rec.errors = [];
            rec.errors.push({
              pid: currentPid,
              error: e.message || "Unknown error fetching SysMeta",
              status: e.status ?? "UNKNOWN",
            });
            if (e.status === 401 || e.status === 403) {
              rec.unauthorized = true;
            }
            break; // stop if we can't fetch SysMeta
          }
          const adjacentPid = forward
            ? sysMeta?.data?.obsoletedBy
            : sysMeta?.data?.obsoletes;
          if (!adjacentPid) {
            rec[endFlag] = true; // mark end reached
            this.notify(startPid);
            break; // no more versions in this direction
          } else {
            list.push(adjacentPid);
            this.notify(startPid);

            // also populate the reverse link on the neighbour record
            const adjRec = await this.record(adjacentPid);
            const reverseList = forward ? adjRec.prev : adjRec.next;
            const idx = list.length - 1;
            if (reverseList[idx] !== startPid) {
              reverseList[idx] = startPid;
              await this.persist(adjacentPid, adjRec);
              this.notify(adjacentPid);
            }

            currentPid = adjacentPid;
          }
          /* eslint-enable no-await-in-loop */
        }
      } finally {
        await this.persist(startPid, rec);
        // always resolve the lock to allow other calls to proceed
        if (resolveLock) resolveLock();
        this.locks.delete(lockKey);
      }
    }

    /**
     * Ensure the user is authenticated and has a valid token.
     * @returns {Promise<string>} - resolves to the user's token
     */
    static async getToken() {
      return MetacatUI.appUserModel.getTokenPromise();
    }

    /**
     * Get the SysMeta for a given PID. Prevents duplicate fetches for the same
     * PID and token by caching in-flight requests.
     * @param {string} pid - the PID to get SysMeta for
     * @returns {Promise<SysMeta>} - resolves to the SysMeta object for the PID
     * @private
     */
    async getSysMeta(pid) {
      const token = await VersionTracker.getToken();
      const cacheKey = `${pid}:${token || ""}`;
      if (this.inFlight.has(cacheKey)) return this.inFlight.get(cacheKey);

      const fetchPromise = (async () => {
        const rec = await this.record(pid);
        if (rec.sysMeta) return rec.sysMeta;

        const sysMeta = new SysMeta({
          identifier: pid,
          metaServiceUrl: this.metaServiceUrl,
        });
        await sysMeta.fetch(token);
        rec.sysMeta = sysMeta;
        return rec.sysMeta;
      })();

      this.inFlight.set(cacheKey, fetchPromise);

      try {
        return await fetchPromise;
      } finally {
        this.inFlight.delete(cacheKey);
      }
    }

    /**
     * Get or create a record for the given PID, ensuring it is cached. If the
     * record is not found in the cache, it will be fetched from localForage. If
     * it doesn't exist in localForage, a new record will be created.
     * @param {string} pid - the PID to get the record for
     * @returns {Promise<VersionRecord>} - resolves to the record object with
     * `next`, `prev`, `endNext`, `endPrev`, and `sysMeta` properties.
     * @private
     */
    async record(pid) {
      // First check the in-memory cache
      let rec = this.cache.get(pid);
      if (rec) return rec;

      // the rec obj will be updated with next/prev links if saved in
      // localForage
      rec = {
        next: [],
        prev: [],
        endNext: false,
        endPrev: false,
        sysMeta: null,
      };
      this.cache.set(pid, rec);
      // move to the end on (re)access
      const bump = () => {
        this.cache.delete(pid);
        this.cache.set(pid, rec);
      };
      bump();
      // Trim oldest if over limit
      while (this.cache.size > this.MAX_CACHE_RECORDS) {
        const oldestKey = this.cache.keys().next().value;
        this.cache.delete(oldestKey);
      }

      // async resurrect
      const saved = await this.load(pid);
      if (saved) {
        rec.next = (saved.next || []).filter(Boolean);
        rec.prev = (saved.prev || []).filter(Boolean);
        rec.endNext = !!saved.endNext;
        rec.endPrev = !!saved.endPrev;
        if (saved.sysMeta) {
          const sm = new SysMeta({
            identifier: pid,
            metaServiceUrl: this.metaServiceUrl,
          });
          sm.data = { identifier: pid, ...saved.sysMeta };
          sm.fetched = true; // mark as fetched to skip network
          rec.sysMeta = sm;
        }
      }
      return rec;
    }

    /**
     * Persist the record for the given PID to localForage. This will store the
     * next/prev links, end flags, and lean SysMeta data.
     * @param {string} pid - the PID to persist
     * @param {VersionRecord} rec - the record to persist
     * @returns {Promise<void>} - resolves when the record is persisted
     * @private
     */
    async persist(pid, rec) {
      // Extract lean SysMeta data if available
      let leanMeta = null;

      if (rec.sysMeta && rec.sysMeta.data) {
        leanMeta = {};
        PERSISTED_SYS_META_FIELDS.forEach((k) => {
          if (rec.sysMeta.data[k] !== undefined)
            leanMeta[k] = rec.sysMeta.data[k];
        });
      }
      await this.store.setItem(pid, {
        next: rec.next,
        prev: rec.prev,
        endNext: rec.endNext,
        endPrev: rec.endPrev,
        sysMeta: leanMeta,
        ts: Date.now(),
      });
    }

    /**
     * Load a record for the given PID from localForage. If the record is older
     * than the TTL, it will be removed from the store and null will be
     * returned.
     * @param {string} pid - the PID to load
     * @returns {Promise<VersionRecord|null>} resolves to the record object or
     * null if not found or expired
     * @private
     */
    async load(pid) {
      const saved = await this.store.getItem(pid);
      if (!saved) return null;
      if (Date.now() - saved.ts > this.TTL_MS) {
        await this.store.removeItem(pid);
        return null;
      }
      return saved;
    }
  }

  // Allow the class to trigger Backbone events
  Object.assign(VersionTracker.prototype, Backbone.Events);

  // static map & accessor for singleton instances
  VersionTracker.instances = new Map();

  VersionTracker.get = function get(metaServiceUrl) {
    const msUrl = NORMALIZE_METASERVICE_URL(metaServiceUrl);

    if (!VersionTracker.instances.has(msUrl)) {
      VersionTracker.instances.set(msUrl, new VersionTracker({ msUrl }));
    }
    return VersionTracker.instances.get(msUrl);
  };

  return VersionTracker;
});