/* 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/. */

var tests = [
  {
    desc:
      "nsNavHistoryFolderResultNode: Basic test, asynchronously open and " +
      "close container with a single child",

    loading(node, newState, oldState) {
      this.checkStateChanged("loading", 1);
      this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
    },

    opened(node, newState, oldState) {
      this.checkStateChanged("opened", 1);
      this.checkState("loading", 1);
      this.checkArgs("opened", node, oldState, node.STATE_LOADING);

      print("Checking node children");
      compareArrayToResult(this.data, node);

      print("Closing container");
      node.containerOpen = false;
    },

    closed(node, newState, oldState) {
      this.checkStateChanged("closed", 1);
      this.checkState("opened", 1);
      this.checkArgs("closed", node, oldState, node.STATE_OPENED);
      this.success();
    },
  },

  {
    desc:
      "nsNavHistoryFolderResultNode: After async open and no changes, " +
      "second open should be synchronous",

    loading(node, newState, oldState) {
      this.checkStateChanged("loading", 1);
      this.checkState("closed", 0);
      this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
    },

    opened(node, newState, oldState) {
      let cnt = this.checkStateChanged("opened", 1, 2);
      let expectOldState = cnt === 1 ? node.STATE_LOADING : node.STATE_CLOSED;
      this.checkArgs("opened", node, oldState, expectOldState);

      print("Checking node children");
      compareArrayToResult(this.data, node);

      print("Closing container");
      node.containerOpen = false;
    },

    closed(node, newState, oldState) {
      let cnt = this.checkStateChanged("closed", 1, 2);
      this.checkArgs("closed", node, oldState, node.STATE_OPENED);

      switch (cnt) {
        case 1:
          node.containerOpen = true;
          break;
        case 2:
          this.success();
          break;
      }
    },
  },

  {
    desc:
      "nsNavHistoryFolderResultNode: After closing container in " +
      "loading(), opened() should not be called",

    loading(node, newState, oldState) {
      this.checkStateChanged("loading", 1);
      this.checkArgs("loading", node, oldState, node.STATE_CLOSED);
      print("Closing container");
      node.containerOpen = false;
    },

    opened() {
      do_throw("opened should not be called");
    },

    closed(node, newState, oldState) {
      this.checkStateChanged("closed", 1);
      this.checkState("loading", 1);
      this.checkArgs("closed", node, oldState, node.STATE_LOADING);
      this.success();
    },
  },
];

/**
 * Instances of this class become the prototypes of the test objects above.
 * Each test can therefore use the methods of this class, or they can override
 * them if they want.  To run a test, call setup() and then run().
 */
function Test() {
  // This maps a state name to the number of times it's been observed.
  this.stateCounts = {};
  // Promise object resolved when the next test can be run.
  this.deferNextTest = Promise.withResolvers();
}

Test.prototype = {
  /**
   * Call this when an observer observes a container state change to sanity
   * check the arguments.
   *
   * @param {string} aNewState
   *   The name of the new state. Used only for printing out helpful info.
   * @param {nsINavHistoryContainerResultNode} aNode
   *   The node argument passed to containerStateChanged.
   * @param {string} aOldState
   *   The old state argument passed to containerStateChanged.
   * @param {string} aExpectOldState
   *   The expected old state.
   */
  checkArgs(aNewState, aNode, aOldState, aExpectOldState) {
    print("Node passed on " + aNewState + " should be result.root");
    Assert.equal(this.result.root, aNode);
    print("Old state passed on " + aNewState + " should be " + aExpectOldState);

    // aOldState comes from xpconnect and will therefore be defined.  It may be
    // zero, though, so use strict equality just to make sure aExpectOldState is
    // also defined.
    Assert.strictEqual(aOldState, aExpectOldState);
  },

  /**
   * Call this when an observer observes a container state change.  It registers
   * the state change and ensures that it has been observed the given number
   * of times.  See checkState for parameter explanations.
   *
   * @param {string} aState
   * @param {number} aExpectedMin
   * @param {number} aExpectedMax
   * @returns {number}
   *   The number of times aState has been observed, including the new observation.
   */
  checkStateChanged(aState, aExpectedMin, aExpectedMax) {
    print(aState + " state change observed");
    if (!this.stateCounts.hasOwnProperty(aState)) {
      this.stateCounts[aState] = 0;
    }
    this.stateCounts[aState]++;
    return this.checkState(aState, aExpectedMin, aExpectedMax);
  },

  /**
   * Ensures that the state has been observed the given number of times.
   *
   * @param {string} aState
   *   The name of the state.
   * @param {number} aExpectedMin
   *   The state must have been observed at least this number of times.
   * @param {number} [aExpectedMax]
   *   The state must have been observed at most this number of times.
   *   This parameter is optional. If undefined, it's set to aExpectedMin.
   * @returns {number}
   *   The number of times aState has been observed, including the new observation.
   */
  checkState(aState, aExpectedMin, aExpectedMax) {
    let cnt = this.stateCounts[aState] || 0;
    if (aExpectedMax === undefined) {
      aExpectedMax = aExpectedMin;
    }
    if (aExpectedMin === aExpectedMax) {
      print(
        aState +
          " should be observed only " +
          aExpectedMin +
          " times (actual = " +
          cnt +
          ")"
      );
    } else {
      print(
        aState +
          " should be observed at least " +
          aExpectedMin +
          " times and at most " +
          aExpectedMax +
          " times (actual = " +
          cnt +
          ")"
      );
    }
    Assert.ok(cnt >= aExpectedMin && cnt <= aExpectedMax);
    return cnt;
  },

  /**
   * Asynchronously opens the root of the test's result.
   */
  openContainer() {
    // Set up the result observer.  It delegates to this object's callbacks and
    // wraps them in a try-catch so that errors don't get eaten.
    let self = this;
    this.observer = {
      containerStateChanged(container, oldState, newState) {
        print(
          "New state passed to containerStateChanged() should equal the " +
            "container's current state"
        );
        Assert.equal(newState, container.state);

        try {
          switch (newState) {
            case Ci.nsINavHistoryContainerResultNode.STATE_LOADING:
              self.loading(container, newState, oldState);
              break;
            case Ci.nsINavHistoryContainerResultNode.STATE_OPENED:
              self.opened(container, newState, oldState);
              break;
            case Ci.nsINavHistoryContainerResultNode.STATE_CLOSED:
              self.closed(container, newState, oldState);
              break;
            default:
              do_throw("Unexpected new state! " + newState);
          }
        } catch (err) {
          do_throw(err);
        }
      },
    };
    this.result.addObserver(this.observer);

    print("Opening container");
    this.result.root.containerOpen = true;
  },

  /**
   * Starts the test and returns a promise resolved when the test completes.
   */
  run() {
    this.openContainer();
    return this.deferNextTest.promise;
  },

  /**
   * This must be called before run().  It adds a bookmark and sets up the
   * test's result.  Override if need be.
   */
  async setup() {
    // Populate the database with different types of bookmark items.
    this.data = DataHelper.makeDataArray([
      { type: "bookmark" },
      { type: "separator" },
      { type: "folder" },
      { type: "bookmark", uri: "place:terms=foo" },
    ]);
    await task_populateDB(this.data);

    // Make a query.
    this.query = PlacesUtils.history.getNewQuery();
    this.query.setParents([DataHelper.defaults.bookmark.parentGuid]);
    this.opts = PlacesUtils.history.getNewQueryOptions();
    this.opts.asyncEnabled = true;
    this.result = PlacesUtils.history.executeQuery(this.query, this.opts);
  },

  /**
   * Call this when the test has succeeded.  It cleans up resources and starts
   * the next test.
   */
  success() {
    this.result.removeObserver(this.observer);

    // Resolve the promise object that indicates that the next test can be run.
    this.deferNextTest.resolve();
  },
};

/**
 * This makes it a little bit easier to use the functions of head_queries.js.
 */
var DataHelper = {
  defaults: {
    bookmark: {
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
      uri: "http://example.com/",
      title: "test bookmark",
    },

    folder: {
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
      title: "test folder",
    },

    separator: {
      parentGuid: PlacesUtils.bookmarks.unfiledGuid,
    },
  },

  /**
   * Converts an array of simple bookmark item descriptions to the more verbose
   * format required by task_populateDB() in head_queries.js.
   *
   * @param {object[]} aData
   *   An array of objects, each of which describes a bookmark item.
   * @param {string} aData.type
   *   The type of item to create.
   * @param {string} [aData.uri]
   *   The URI to use for the item.
   * @returns {object[]}
   *   An array of objects suitable for passing to populateDB().
   */
  makeDataArray(aData) {
    let self = this;
    return aData.map(function (dat) {
      let type = dat.type;
      dat = self._makeDataWithDefaults(dat, self.defaults[type]);
      switch (type) {
        case "bookmark":
          return {
            isBookmark: true,
            uri: dat.uri,
            parentGuid: dat.parentGuid,
            index: PlacesUtils.bookmarks.DEFAULT_INDEX,
            title: dat.title,
            isInQuery: true,
          };
        case "separator":
          return {
            isSeparator: true,
            parentGuid: dat.parentGuid,
            index: PlacesUtils.bookmarks.DEFAULT_INDEX,
            isInQuery: true,
          };
        case "folder":
          return {
            isFolder: true,
            parentGuid: dat.parentGuid,
            index: PlacesUtils.bookmarks.DEFAULT_INDEX,
            title: dat.title,
            isInQuery: true,
          };
        default:
          do_throw("Unknown data type when populating DB: " + type);
          return undefined;
      }
    });
  },

  /**
   * Returns a copy of aData, except that any properties that are undefined but
   * defined in aDefaults are set to the corresponding values in aDefaults.
   *
   * @param {object} aData
   *   An array of objects, each of which describes a bookmark item.
   * @param {string} aData.type
   *   The type of item to create.
   * @param {string} [aData.uri]
   *   The URI to use for the item.
   * @param {object} aDefaults
   *   An object describing the default bookmark item.
   * @param {string} aDefaults.parentGuid
   *   The parent folder to create the bookmark item in by default.
   * @param {string} [aDefaults.uri]
   *   The URI to use as the default.
   * @param {string} [aDefaults.title]
   *   The title to use as the default
   * @returns {object}
   *   A copy of aData with defaults values set.
   */
  _makeDataWithDefaults(aData, aDefaults) {
    let dat = {};
    for (let [prop, val] of Object.entries(aDefaults)) {
      dat[prop] = aData.hasOwnProperty(prop) ? aData[prop] : val;
    }
    return dat;
  },
};

add_task(async function test_async() {
  for (let test of tests) {
    await PlacesUtils.bookmarks.eraseEverything();

    Object.setPrototypeOf(test, new Test());
    await test.setup();

    print("------ Running test: " + test.desc);
    await test.run();
  }

  await PlacesUtils.bookmarks.eraseEverything();
  print("All tests done, exiting");
});
