/* Copyright 2017 Mozilla Foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./pdf_link_service.js").PDFLinkService} PDFLinkService */

import { isValidRotation, parseQueryString } from "./ui_utils.js";
import { updateUrlHash } from "pdfjs-lib";
import { waitOnEventOrTimeout } from "./event_utils.js";

// Heuristic value used when force-resetting `this._blockHashChange`.
const HASH_CHANGE_TIMEOUT = 1000; // milliseconds
// Heuristic value used when adding the current position to the browser history.
const POSITION_UPDATED_THRESHOLD = 50;
// Heuristic value used when adding a temporary position to the browser history.
const UPDATE_VIEWAREA_TIMEOUT = 1000; // milliseconds

/**
 * @typedef {Object} PDFHistoryOptions
 * @property {PDFLinkService} linkService - The navigation/linking service.
 * @property {EventBus} eventBus - The application event bus.
 */

/**
 * @typedef {Object} InitializeParameters
 * @property {string} fingerprint - The PDF document's unique fingerprint.
 * @property {boolean} [resetHistory] - Reset the browsing history.
 * @property {boolean} [updateUrl] - Attempt to update the document URL, with
 *   the current hash, when pushing/replacing browser history entries.
 */

/**
 * @typedef {Object} PushParameters
 * @property {string} [namedDest] - The named destination. If absent, a
 *   stringified version of `explicitDest` is used.
 * @property {Array} explicitDest - The explicit destination array.
 * @property {number} pageNumber - The page to which the destination points.
 */

function getCurrentHash() {
  return document.location.hash;
}

class PDFHistory {
  #eventAbortController = null;

  /**
   * @param {PDFHistoryOptions} options
   */
  constructor({ linkService, eventBus }) {
    this.linkService = linkService;
    this.eventBus = eventBus;

    this._initialized = false;
    this._fingerprint = "";
    this.reset();

    // Ensure that we don't miss a "pagesinit" event,
    // by registering the listener immediately.
    this.eventBus._on("pagesinit", () => {
      this._isPagesLoaded = false;

      this.eventBus._on(
        "pagesloaded",
        evt => {
          this._isPagesLoaded = !!evt.pagesCount;
        },
        { once: true }
      );
    });
  }

  /**
   * Initialize the history for the PDF document, using either the current
   * browser history entry or the document hash, whichever is present.
   * @param {InitializeParameters} params
   */
  initialize({ fingerprint, resetHistory = false, updateUrl = false }) {
    if (!fingerprint || typeof fingerprint !== "string") {
      console.error(
        'PDFHistory.initialize: The "fingerprint" must be a non-empty string.'
      );
      return;
    }
    // Ensure that any old state is always reset upon initialization.
    if (this._initialized) {
      this.reset();
    }
    const reInitialized =
      this._fingerprint !== "" && this._fingerprint !== fingerprint;
    this._fingerprint = fingerprint;
    this._updateUrl = updateUrl === true;

    this._initialized = true;
    this.#bindEvents();
    const state = window.history.state;

    this._popStateInProgress = false;
    this._blockHashChange = 0;
    this._currentHash = getCurrentHash();
    this._numPositionUpdates = 0;

    this._uid = this._maxUid = 0;
    this._destination = null;
    this._position = null;

    if (!this.#isValidState(state, /* checkReload = */ true) || resetHistory) {
      const { hash, page, rotation } = this.#parseCurrentHash(
        /* checkNameddest = */ true
      );

      if (!hash || reInitialized || resetHistory) {
        // Ensure that the browser history is reset on PDF document load.
        this.#pushOrReplaceState(null, /* forceReplace = */ true);
        return;
      }
      // Ensure that the browser history is initialized correctly when
      // the document hash is present on PDF document load.
      this.#pushOrReplaceState(
        { hash, page, rotation },
        /* forceReplace = */ true
      );
      return;
    }

    // The browser history contains a valid entry, ensure that the history is
    // initialized correctly on PDF document load.
    const destination = state.destination;
    this.#updateInternalState(
      destination,
      state.uid,
      /* removeTemporary = */ true
    );

    if (destination.rotation !== undefined) {
      this._initialRotation = destination.rotation;
    }
    if (destination.dest) {
      this._initialBookmark = JSON.stringify(destination.dest);

      // If the history is updated, e.g. through the user changing the hash,
      // before the initial destination has become visible, then we do *not*
      // want to potentially add `this._position` to the browser history.
      this._destination.page = null;
    } else if (destination.hash) {
      this._initialBookmark = destination.hash;
    } else if (destination.page) {
      // Fallback case; shouldn't be necessary, but better safe than sorry.
      this._initialBookmark = `page=${destination.page}`;
    }
  }

  /**
   * Reset the current `PDFHistory` instance, and consequently prevent any
   * further updates and/or navigation of the browser history.
   */
  reset() {
    if (this._initialized) {
      this.#pageHide(); // Simulate a 'pagehide' event when resetting.

      this._initialized = false;
      this.#unbindEvents();
    }
    if (this._updateViewareaTimeout) {
      clearTimeout(this._updateViewareaTimeout);
      this._updateViewareaTimeout = null;
    }
    this._initialBookmark = null;
    this._initialRotation = null;
  }

  /**
   * Push an internal destination to the browser history.
   * @param {PushParameters}
   */
  push({ namedDest = null, explicitDest, pageNumber }) {
    if (!this._initialized) {
      return;
    }
    if (namedDest && typeof namedDest !== "string") {
      console.error(
        "PDFHistory.push: " +
          `"${namedDest}" is not a valid namedDest parameter.`
      );
      return;
    } else if (!Array.isArray(explicitDest)) {
      console.error(
        "PDFHistory.push: " +
          `"${explicitDest}" is not a valid explicitDest parameter.`
      );
      return;
    } else if (!this.#isValidPage(pageNumber)) {
      // Allow an unset `pageNumber` if and only if the history is still empty;
      // please refer to the `this._destination.page = null;` comment above.
      if (pageNumber !== null || this._destination) {
        console.error(
          "PDFHistory.push: " +
            `"${pageNumber}" is not a valid pageNumber parameter.`
        );
        return;
      }
    }

    const hash = namedDest || JSON.stringify(explicitDest);
    if (!hash) {
      // The hash *should* never be undefined, but if that were to occur,
      // avoid any possible issues by not updating the browser history.
      return;
    }

    let forceReplace = false;
    if (
      this._destination &&
      (isDestHashesEqual(this._destination.hash, hash) ||
        isDestArraysEqual(this._destination.dest, explicitDest))
    ) {
      // When the new destination is identical to `this._destination`, and
      // its `page` is undefined, replace the current browser history entry.
      // NOTE: This can only occur if `this._destination` was set either:
      //  - through the document hash being specified on load.
      //  - through the user changing the hash of the document.
      if (this._destination.page) {
        return;
      }
      forceReplace = true;
    }
    if (this._popStateInProgress && !forceReplace) {
      return;
    }

    this.#pushOrReplaceState(
      {
        dest: explicitDest,
        hash,
        page: pageNumber,
        rotation: this.linkService.rotation,
      },
      forceReplace
    );

    if (!this._popStateInProgress) {
      // Prevent the browser history from updating while the new destination is
      // being scrolled into view, to avoid potentially inconsistent state.
      this._popStateInProgress = true;
      // We defer the resetting of `this._popStateInProgress`, to account for
      // e.g. zooming occurring when the new destination is being navigated to.
      Promise.resolve().then(() => {
        this._popStateInProgress = false;
      });
    }
  }

  /**
   * Push a page to the browser history; generally the `push` method should be
   * used instead.
   * @param {number} pageNumber
   */
  pushPage(pageNumber) {
    if (!this._initialized) {
      return;
    }
    if (!this.#isValidPage(pageNumber)) {
      console.error(
        `PDFHistory.pushPage: "${pageNumber}" is not a valid page number.`
      );
      return;
    }

    if (this._destination?.page === pageNumber) {
      // When the new page is identical to the one in `this._destination`, we
      // don't want to add a potential duplicate entry in the browser history.
      return;
    }
    if (this._popStateInProgress) {
      return;
    }

    this.#pushOrReplaceState({
      // Simulate an internal destination, for `this.#tryPushCurrentPosition`:
      dest: null,
      hash: `page=${pageNumber}`,
      page: pageNumber,
      rotation: this.linkService.rotation,
    });

    if (!this._popStateInProgress) {
      // Prevent the browser history from updating while the new page is
      // being scrolled into view, to avoid potentially inconsistent state.
      this._popStateInProgress = true;
      // We defer the resetting of `this._popStateInProgress`, to account for
      // e.g. zooming occurring when the new page is being navigated to.
      Promise.resolve().then(() => {
        this._popStateInProgress = false;
      });
    }
  }

  /**
   * Push the current position to the browser history.
   */
  pushCurrentPosition() {
    if (!this._initialized || this._popStateInProgress) {
      return;
    }
    this.#tryPushCurrentPosition();
  }

  /**
   * Go back one step in the browser history.
   * NOTE: Avoids navigating away from the document, useful for "named actions".
   */
  back() {
    if (!this._initialized || this._popStateInProgress) {
      return;
    }
    const state = window.history.state;
    if (this.#isValidState(state) && state.uid > 0) {
      window.history.back();
    }
  }

  /**
   * Go forward one step in the browser history.
   * NOTE: Avoids navigating away from the document, useful for "named actions".
   */
  forward() {
    if (!this._initialized || this._popStateInProgress) {
      return;
    }
    const state = window.history.state;
    if (this.#isValidState(state) && state.uid < this._maxUid) {
      window.history.forward();
    }
  }

  /**
   * @type {boolean} Indicating if the user is currently moving through the
   *   browser history, useful e.g. for skipping the next 'hashchange' event.
   */
  get popStateInProgress() {
    return (
      this._initialized &&
      (this._popStateInProgress || this._blockHashChange > 0)
    );
  }

  get initialBookmark() {
    return this._initialized ? this._initialBookmark : null;
  }

  get initialRotation() {
    return this._initialized ? this._initialRotation : null;
  }

  #pushOrReplaceState(destination, forceReplace = false) {
    const shouldReplace = forceReplace || !this._destination;
    const newState = {
      fingerprint: this._fingerprint,
      uid: shouldReplace ? this._uid : this._uid + 1,
      destination,
    };

    if (
      typeof PDFJSDev !== "undefined" &&
      PDFJSDev.test("CHROME") &&
      window.history.state?.chromecomState
    ) {
      // history.state.chromecomState is managed by chromecom.js.
      newState.chromecomState = window.history.state.chromecomState;
    }
    this.#updateInternalState(destination, newState.uid);

    let newUrl;
    if (this._updateUrl && destination?.hash) {
      const { href, protocol } = document.location;
      if (protocol !== "file:") {
        newUrl = updateUrlHash(href, destination.hash);
      }
    }
    if (shouldReplace) {
      window.history.replaceState(newState, "", newUrl);
    } else {
      window.history.pushState(newState, "", newUrl);
    }
  }

  #tryPushCurrentPosition(temporary = false) {
    if (!this._position) {
      return;
    }
    let position = this._position;
    if (temporary) {
      position = Object.assign(Object.create(null), this._position);
      position.temporary = true;
    }

    if (!this._destination) {
      this.#pushOrReplaceState(position);
      return;
    }
    if (this._destination.temporary) {
      // Always replace a previous *temporary* position.
      this.#pushOrReplaceState(position, /* forceReplace = */ true);
      return;
    }
    if (this._destination.hash === position.hash) {
      return; // The current document position has not changed.
    }
    if (
      !this._destination.page &&
      (POSITION_UPDATED_THRESHOLD <= 0 ||
        this._numPositionUpdates <= POSITION_UPDATED_THRESHOLD)
    ) {
      // `this._destination` was set through the user changing the hash of
      // the document. Do not add `this._position` to the browser history,
      // to avoid "flooding" it with lots of (nearly) identical entries,
      // since we cannot ensure that the document position has changed.
      return;
    }

    let forceReplace = false;
    if (
      this._destination.page >= position.first &&
      this._destination.page <= position.page
    ) {
      // When the `page` of `this._destination` is still visible, do not
      // update the browsing history when `this._destination` either:
      //  - contains an internal destination, since in this case we
      //    cannot ensure that the document position has actually changed.
      //  - was set through the user changing the hash of the document.
      if (this._destination.dest !== undefined || !this._destination.first) {
        return;
      }
      // To avoid "flooding" the browser history, replace the current entry.
      forceReplace = true;
    }
    this.#pushOrReplaceState(position, forceReplace);
  }

  #isValidPage(val) {
    return (
      Number.isInteger(val) && val > 0 && val <= this.linkService.pagesCount
    );
  }

  #isValidState(state, checkReload = false) {
    if (!state) {
      return false;
    }
    if (state.fingerprint !== this._fingerprint) {
      if (checkReload) {
        // Potentially accept the history entry, even if the fingerprints don't
        // match, when the viewer was reloaded (see issue 6847).
        if (
          typeof state.fingerprint !== "string" ||
          state.fingerprint.length !== this._fingerprint.length
        ) {
          return false;
        }
        const [perfEntry] = performance.getEntriesByType("navigation");
        if (perfEntry?.type !== "reload") {
          return false;
        }
      } else {
        // This should only occur in viewers with support for opening more than
        // one PDF document, e.g. the GENERIC viewer.
        return false;
      }
    }
    if (!Number.isInteger(state.uid) || state.uid < 0) {
      return false;
    }
    if (state.destination === null || typeof state.destination !== "object") {
      return false;
    }
    return true;
  }

  #updateInternalState(destination, uid, removeTemporary = false) {
    if (this._updateViewareaTimeout) {
      // When updating `this._destination`, make sure that we always wait for
      // the next 'updateviewarea' event before (potentially) attempting to
      // push the current position to the browser history.
      clearTimeout(this._updateViewareaTimeout);
      this._updateViewareaTimeout = null;
    }
    if (removeTemporary && destination?.temporary) {
      // When the `destination` comes from the browser history,
      // we no longer treat it as a *temporary* position.
      delete destination.temporary;
    }
    this._destination = destination;
    this._uid = uid;
    this._maxUid = Math.max(this._maxUid, uid);
    // This should always be reset when `this._destination` is updated.
    this._numPositionUpdates = 0;
  }

  #parseCurrentHash(checkNameddest = false) {
    const hash = unescape(getCurrentHash()).substring(1);
    const params = parseQueryString(hash);

    const nameddest = params.get("nameddest") || "";
    let page = params.get("page") | 0;

    if (!this.#isValidPage(page) || (checkNameddest && nameddest.length > 0)) {
      page = null;
    }
    return { hash, page, rotation: this.linkService.rotation };
  }

  #updateViewarea({ location }) {
    if (this._updateViewareaTimeout) {
      clearTimeout(this._updateViewareaTimeout);
      this._updateViewareaTimeout = null;
    }

    this._position = {
      hash: location.pdfOpenParams.substring(1),
      page: this.linkService.page,
      first: location.pageNumber,
      rotation: location.rotation,
    };

    if (this._popStateInProgress) {
      return;
    }

    if (
      POSITION_UPDATED_THRESHOLD > 0 &&
      this._isPagesLoaded &&
      this._destination &&
      !this._destination.page
    ) {
      // If the current destination was set through the user changing the hash
      // of the document, we will usually not try to push the current position
      // to the browser history; see `this.#tryPushCurrentPosition()`.
      //
      // To prevent `this.#tryPushCurrentPosition()` from effectively being
      // reduced to a no-op in this case, we will assume that the position
      // *did* in fact change if the 'updateviewarea' event was dispatched
      // more than `POSITION_UPDATED_THRESHOLD` times.
      this._numPositionUpdates++;
    }

    if (UPDATE_VIEWAREA_TIMEOUT > 0) {
      // When closing the browser, a 'pagehide' event will be dispatched which
      // *should* allow us to push the current position to the browser history.
      // In practice, it seems that the event is arriving too late in order for
      // the session history to be successfully updated.
      // (For additional details, please refer to the discussion in
      //  https://bugzilla.mozilla.org/show_bug.cgi?id=1153393.)
      //
      // To workaround this we attempt to *temporarily* add the current position
      // to the browser history only when the viewer is *idle*,
      // i.e. when scrolling and/or zooming does not occur.
      //
      // PLEASE NOTE: It's absolutely imperative that the browser history is
      // *not* updated too often, since that would render the viewer more or
      // less unusable. Hence the use of a timeout to delay the update until
      // the viewer has been idle for `UPDATE_VIEWAREA_TIMEOUT` milliseconds.
      this._updateViewareaTimeout = setTimeout(() => {
        if (!this._popStateInProgress) {
          this.#tryPushCurrentPosition(/* temporary = */ true);
        }
        this._updateViewareaTimeout = null;
      }, UPDATE_VIEWAREA_TIMEOUT);
    }
  }

  #popState({ state }) {
    const newHash = getCurrentHash(),
      hashChanged = this._currentHash !== newHash;
    this._currentHash = newHash;

    if (
      (typeof PDFJSDev !== "undefined" &&
        PDFJSDev.test("CHROME") &&
        state?.chromecomState &&
        !this.#isValidState(state)) ||
      !state
    ) {
      // This case corresponds to the user changing the hash of the document.
      this._uid++;

      const { hash, page, rotation } = this.#parseCurrentHash();
      this.#pushOrReplaceState(
        { hash, page, rotation },
        /* forceReplace = */ true
      );
      return;
    }
    if (!this.#isValidState(state)) {
      // This should only occur in viewers with support for opening more than
      // one PDF document, e.g. the GENERIC viewer.
      return;
    }

    // Prevent the browser history from updating until the new destination,
    // as stored in the browser history, has been scrolled into view.
    this._popStateInProgress = true;

    if (hashChanged) {
      // When the hash changed, implying that the 'popstate' event will be
      // followed by a 'hashchange' event, then we do *not* want to update the
      // browser history when handling the 'hashchange' event (in web/app.js)
      // since that would *overwrite* the new destination navigated to below.
      //
      // To avoid accidentally disabling all future user-initiated hash changes,
      // if there's e.g. another 'hashchange' listener that stops the event
      // propagation, we make sure to always force-reset `this._blockHashChange`
      // after `HASH_CHANGE_TIMEOUT` milliseconds have passed.
      this._blockHashChange++;
      waitOnEventOrTimeout({
        target: window,
        name: "hashchange",
        delay: HASH_CHANGE_TIMEOUT,
      }).then(() => {
        this._blockHashChange--;
      });
    }

    // Navigate to the new destination.
    const destination = state.destination;
    this.#updateInternalState(
      destination,
      state.uid,
      /* removeTemporary = */ true
    );

    if (isValidRotation(destination.rotation)) {
      this.linkService.rotation = destination.rotation;
    }
    if (destination.dest) {
      this.linkService.goToDestination(destination.dest);
    } else if (destination.hash) {
      this.linkService.setHash(destination.hash);
    } else if (destination.page) {
      // Fallback case; shouldn't be necessary, but better safe than sorry.
      this.linkService.page = destination.page;
    }

    // Since `PDFLinkService.goToDestination` is asynchronous, we thus defer the
    // resetting of `this._popStateInProgress` slightly.
    Promise.resolve().then(() => {
      this._popStateInProgress = false;
    });
  }

  #pageHide() {
    // Attempt to push the `this._position` into the browser history when
    // navigating away from the document. This is *only* done if the history
    // is empty/temporary, since otherwise an existing browser history entry
    // will end up being overwritten (given that new entries cannot be pushed
    // into the browser history when the 'unload' event has already fired).
    if (!this._destination || this._destination.temporary) {
      this.#tryPushCurrentPosition();
    }
  }

  #bindEvents() {
    if (this.#eventAbortController) {
      return; // The event listeners were already added.
    }
    this.#eventAbortController = new AbortController();
    const { signal } = this.#eventAbortController;

    this.eventBus._on("updateviewarea", this.#updateViewarea.bind(this), {
      signal,
    });
    window.addEventListener("popstate", this.#popState.bind(this), { signal });
    window.addEventListener("pagehide", this.#pageHide.bind(this), { signal });
  }

  #unbindEvents() {
    this.#eventAbortController?.abort();
    this.#eventAbortController = null;
  }
}

function isDestHashesEqual(destHash, pushHash) {
  if (typeof destHash !== "string" || typeof pushHash !== "string") {
    return false;
  }
  if (destHash === pushHash) {
    return true;
  }
  const nameddest = parseQueryString(destHash).get("nameddest");
  if (nameddest === pushHash) {
    return true;
  }
  return false;
}

function isDestArraysEqual(firstDest, secondDest) {
  function isEntryEqual(first, second) {
    if (typeof first !== typeof second) {
      return false;
    }
    if (Array.isArray(first) || Array.isArray(second)) {
      return false;
    }
    if (first !== null && typeof first === "object" && second !== null) {
      if (Object.keys(first).length !== Object.keys(second).length) {
        return false;
      }
      for (const key in first) {
        if (!isEntryEqual(first[key], second[key])) {
          return false;
        }
      }
      return true;
    }
    return first === second || (Number.isNaN(first) && Number.isNaN(second));
  }

  if (!(Array.isArray(firstDest) && Array.isArray(secondDest))) {
    return false;
  }
  if (firstDest.length !== secondDest.length) {
    return false;
  }
  for (let i = 0, ii = firstDest.length; i < ii; i++) {
    if (!isEntryEqual(firstDest[i], secondDest[i])) {
      return false;
    }
  }
  return true;
}

export { isDestArraysEqual, isDestHashesEqual, PDFHistory };
