/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PrivacyLevel: "resource://gre/modules/sessionstore/PrivacyLevel.sys.mjs",
});

const MAX_EXPIRY = Number.MAX_SAFE_INTEGER;

/**
 * The external API implemented by the SessionCookies module.
 */
export var SessionCookies = Object.freeze({
  collect() {
    return SessionCookiesInternal.collect();
  },

  restore(cookies) {
    SessionCookiesInternal.restore(cookies);
  },
});

/**
 * The internal API.
 */
var SessionCookiesInternal = {
  /**
   * Stores whether we're initialized, yet.
   */
  _initialized: false,

  /**
   * Retrieve an array of all stored session cookies.
   */
  collect() {
    this._ensureInitialized();
    return CookieStore.toArray();
  },

  /**
   * Restores a given list of session cookies.
   */
  restore(cookies) {
    for (let cookie of cookies) {
      let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY;
      let exists = false;
      try {
        exists = Services.cookies.cookieExists(
          cookie.host,
          cookie.path || "",
          cookie.name || "",
          cookie.originAttributes || {}
        );
      } catch (ex) {
        console.error(
          `CookieService::CookieExists failed with error '${ex}' for '${JSON.stringify(
            cookie
          )}'.`
        );
      }
      if (!exists) {
        // Enforces isPartitioned if the partitionKey is set. We need to do this
        // because the session store didn't store the isPartitioned flag.
        // Otherwise, we'd end up setting partitioned cookies without
        // isPartitioned flag.
        let isPartitioned =
          cookie.isPartitioned ||
          cookie.originAttributes?.partitionKey?.length > 0;

        try {
          const cv = Services.cookies.add(
            cookie.host,
            cookie.path || "",
            cookie.name || "",
            cookie.value,
            !!cookie.secure,
            !!cookie.httponly,
            /* isSession = */ true,
            expiry,
            cookie.originAttributes || {},
            // If sameSite is undefined, we are migrating from a pre bug 1955685 session).
            cookie.sameSite === undefined
              ? Ci.nsICookie.SAMESITE_NONE
              : cookie.sameSite,
            cookie.schemeMap || Ci.nsICookie.SCHEME_HTTPS,
            isPartitioned
          );
          if (cv.result !== Ci.nsICookieValidation.eOK) {
            console.error(
              `CookieService::Add failed with error '${cv.result}' for cookie ${JSON.stringify(
                cookie
              )}.`
            );
          }
        } catch (ex) {
          console.error(
            `CookieService::Add failed with error '${ex}' for cookie ${JSON.stringify(
              cookie
            )}.`
          );
        }
      }
    }
  },

  /**
   * Handles observers notifications that are sent whenever cookies are added,
   * changed, or removed. Ensures that the storage is updated accordingly.
   */
  observe(subject) {
    let notification = subject.QueryInterface(Ci.nsICookieNotification);

    let {
      COOKIE_DELETED,
      COOKIE_ADDED,
      COOKIE_CHANGED,
      ALL_COOKIES_CLEARED,
      COOKIES_BATCH_DELETED,
    } = Ci.nsICookieNotification;

    switch (notification.action) {
      case COOKIE_ADDED:
        this._addCookie(notification.cookie);
        break;
      case COOKIE_CHANGED:
        this._updateCookie(notification.cookie);
        break;
      case COOKIE_DELETED:
        this._removeCookie(notification.cookie);
        break;
      case ALL_COOKIES_CLEARED:
        CookieStore.clear();
        break;
      case COOKIES_BATCH_DELETED:
        this._removeCookies(notification.batchDeletedCookies);
        break;
      default:
        throw new Error("Unhandled session-cookie-changed notification.");
    }
  },

  /**
   * If called for the first time in a session, iterates all cookies in the
   * cookies service and puts them into the store if they're session cookies.
   */
  _ensureInitialized() {
    if (this._initialized) {
      return;
    }
    this._reloadCookies();
    this._initialized = true;
    Services.obs.addObserver(this, "session-cookie-changed");

    // Listen for privacy level changes to reload cookies when needed.
    Services.prefs.addObserver("browser.sessionstore.privacy_level", () => {
      this._reloadCookies();
    });
  },

  /**
   * Adds a given cookie to the store.
   */
  _addCookie(cookie) {
    cookie.QueryInterface(Ci.nsICookie);

    // Store only session cookies, obey the privacy level.
    if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) {
      CookieStore.add(cookie);
    }
  },

  /**
   * Updates a given cookie.
   */
  _updateCookie(cookie) {
    cookie.QueryInterface(Ci.nsICookie);

    // Store only session cookies, obey the privacy level.
    if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) {
      CookieStore.add(cookie);
    } else {
      CookieStore.delete(cookie);
    }
  },

  /**
   * Removes a given cookie from the store.
   */
  _removeCookie(cookie) {
    cookie.QueryInterface(Ci.nsICookie);

    if (cookie.isSession) {
      CookieStore.delete(cookie);
    }
  },

  /**
   * Removes a given list of cookies from the store.
   */
  _removeCookies(cookies) {
    for (let i = 0; i < cookies.length; i++) {
      this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie));
    }
  },

  /**
   * Iterates all cookies in the cookies service and puts them into the store
   * if they're session cookies. Obeys the user's chosen privacy level.
   */
  _reloadCookies() {
    CookieStore.clear();

    // Bail out if we're not supposed to store cookies at all.
    if (!lazy.PrivacyLevel.canSave(false)) {
      return;
    }

    for (let cookie of Services.cookies.sessionCookies) {
      this._addCookie(cookie);
    }
  },
};

/**
 * The internal storage that keeps track of session cookies.
 */
var CookieStore = {
  /**
   * The internal map holding all known session cookies.
   */
  _entries: new Map(),

  /**
   * Stores a given cookie.
   *
   * @param cookie
   *        The nsICookie object to add to the storage.
   */
  add(cookie) {
    let jscookie = { host: cookie.host, value: cookie.value };

    // Only add properties with non-default values to save a few bytes.
    if (cookie.path) {
      jscookie.path = cookie.path;
    }

    if (cookie.name) {
      jscookie.name = cookie.name;
    }

    if (cookie.isSecure) {
      jscookie.secure = true;
    }

    if (cookie.isHttpOnly) {
      jscookie.httponly = true;
    }

    if (cookie.expiry < MAX_EXPIRY) {
      jscookie.expiry = cookie.expiry;
    }

    if (cookie.originAttributes) {
      jscookie.originAttributes = cookie.originAttributes;
    }

    jscookie.sameSite = cookie.sameSite;

    if (cookie.schemeMap) {
      jscookie.schemeMap = cookie.schemeMap;
    }

    if (cookie.isPartitioned) {
      jscookie.isPartitioned = true;
    }

    this._entries.set(this._getKeyForCookie(cookie), jscookie);
  },

  /**
   * Removes a given cookie.
   *
   * @param cookie
   *        The nsICookie object to be removed from storage.
   */
  delete(cookie) {
    this._entries.delete(this._getKeyForCookie(cookie));
  },

  /**
   * Removes all cookies.
   */
  clear() {
    this._entries.clear();
  },

  /**
   * Return all cookies as an array.
   */
  toArray() {
    return [...this._entries.values()];
  },

  /**
   * Returns the key needed to properly store and identify a given cookie.
   * A cookie is uniquely identified by the combination of its host, name,
   * path, and originAttributes properties.
   *
   * @param cookie
   *        The nsICookie object to compute a key for.
   * @return string
   */
  _getKeyForCookie(cookie) {
    return JSON.stringify({
      host: cookie.host,
      name: cookie.name,
      path: cookie.path,
      attr: ChromeUtils.originAttributesToSuffix(cookie.originAttributes),
    });
  },
};
