/*
  Copyright 2018 Google LLC

  Use of this source code is governed by an MIT-style
  license that can be found in the LICENSE file or at
  https://opensource.org/licenses/MIT.
*/

import { SerwistError } from "../../utils/SerwistError.js";
import { assert } from "../../utils/assert.js";
import { logger } from "../../utils/logger.js";
import { CacheTimestampsModel } from "./models/CacheTimestampsModel.js";

interface CacheExpirationConfig {
  /**
   * The maximum number of entries to cache. Entries used least recently will
   * be removed as the maximum is reached.
   */
  maxEntries?: number;
  /**
   * The maximum age of an entry before it's treated as stale and removed.
   */
  maxAgeSeconds?: number;
  /**
   * The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
   * that will be used when calling `delete()` on the cache.
   */
  matchOptions?: CacheQueryOptions;
}

/**
 * Allows you to expires cached responses based on age or maximum number of entries.
 * @see https://serwist.pages.dev/docs/serwist/core/cache-expiration
 */
export class CacheExpiration {
  private _isRunning = false;
  private _rerunRequested = false;
  private readonly _maxEntries?: number;
  private readonly _maxAgeSeconds?: number;
  private readonly _matchOptions?: CacheQueryOptions;
  private readonly _cacheName: string;
  private readonly _timestampModel: CacheTimestampsModel;

  /**
   * To construct a new `CacheExpiration` instance you must provide at least
   * one of the `config` properties.
   *
   * @param cacheName Name of the cache to apply restrictions to.
   * @param config
   */
  constructor(cacheName: string, config: CacheExpirationConfig = {}) {
    if (process.env.NODE_ENV !== "production") {
      assert!.isType(cacheName, "string", {
        moduleName: "serwist",
        className: "CacheExpiration",
        funcName: "constructor",
        paramName: "cacheName",
      });

      if (!(config.maxEntries || config.maxAgeSeconds)) {
        throw new SerwistError("max-entries-or-age-required", {
          moduleName: "serwist",
          className: "CacheExpiration",
          funcName: "constructor",
        });
      }

      if (config.maxEntries) {
        assert!.isType(config.maxEntries, "number", {
          moduleName: "serwist",
          className: "CacheExpiration",
          funcName: "constructor",
          paramName: "config.maxEntries",
        });
      }

      if (config.maxAgeSeconds) {
        assert!.isType(config.maxAgeSeconds, "number", {
          moduleName: "serwist",
          className: "CacheExpiration",
          funcName: "constructor",
          paramName: "config.maxAgeSeconds",
        });
      }
    }

    this._maxEntries = config.maxEntries;
    this._maxAgeSeconds = config.maxAgeSeconds;
    this._matchOptions = config.matchOptions;
    this._cacheName = cacheName;
    this._timestampModel = new CacheTimestampsModel(cacheName);
  }

  /**
   * Expires entries for the given cache and given criteria.
   */
  async expireEntries(): Promise<void> {
    if (this._isRunning) {
      this._rerunRequested = true;
      return;
    }
    this._isRunning = true;

    const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0;

    const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries);

    // Delete URLs from the cache
    const cache = await self.caches.open(this._cacheName);
    for (const url of urlsExpired) {
      await cache.delete(url, this._matchOptions);
    }

    if (process.env.NODE_ENV !== "production") {
      if (urlsExpired.length > 0) {
        logger.groupCollapsed(
          `Expired ${urlsExpired.length} ` +
            `${urlsExpired.length === 1 ? "entry" : "entries"} and removed ` +
            `${urlsExpired.length === 1 ? "it" : "them"} from the ` +
            `'${this._cacheName}' cache.`,
        );
        logger.log(`Expired the following ${urlsExpired.length === 1 ? "URL" : "URLs"}:`);
        for (const url of urlsExpired) {
          logger.log(`    ${url}`);
        }
        logger.groupEnd();
      } else {
        logger.debug("Cache expiration ran and found no entries to remove.");
      }
    }

    this._isRunning = false;
    if (this._rerunRequested) {
      this._rerunRequested = false;
      void this.expireEntries();
    }
  }

  /**
   * Updates the timestamp for the given URL, allowing it to be correctly
   * tracked by the class.
   *
   * @param url
   */
  async updateTimestamp(url: string): Promise<void> {
    if (process.env.NODE_ENV !== "production") {
      assert!.isType(url, "string", {
        moduleName: "serwist",
        className: "CacheExpiration",
        funcName: "updateTimestamp",
        paramName: "url",
      });
    }

    await this._timestampModel.setTimestamp(url, Date.now());
  }

  /**
   * Checks if a URL has expired or not before it's used.
   *
   * This looks the timestamp up in IndexedDB and can be slow.
   *
   * Note: This method does not remove an expired entry, call
   * `expireEntries()` to remove such entries instead.
   *
   * @param url
   * @returns
   */
  async isURLExpired(url: string): Promise<boolean> {
    if (!this._maxAgeSeconds) {
      if (process.env.NODE_ENV !== "production") {
        throw new SerwistError("expired-test-without-max-age", {
          methodName: "isURLExpired",
          paramName: "maxAgeSeconds",
        });
      }
      return false;
    }
    const timestamp = await this._timestampModel.getTimestamp(url);
    const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000;
    return timestamp !== undefined ? timestamp < expireOlderThan : true;
  }

  /**
   * Removes the IndexedDB used to keep track of cache expiration metadata.
   */
  async delete(): Promise<void> {
    // Make sure we don't attempt another rerun if we're called in the middle of
    // a cache expiration.
    this._rerunRequested = false;
    await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); // Expires all.
  }
}
