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

/* This is a JavaScript module (JSM) to be imported via
 * ChromeUtils.import() and acts as a singleton. Only the following
 * listed symbols will exposed on import, and only when and where imported.
 */

import { PlacesBackups } from "resource://gre/modules/PlacesBackups.sys.mjs";

import { PlacesSyncUtils } from "resource://gre/modules/PlacesSyncUtils.sys.mjs";
import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";

import { Logger } from "resource://tps/logger.sys.mjs";

export async function DumpBookmarks() {
  let [bookmarks] = await PlacesBackups.getBookmarksTree();
  Logger.logInfo(
    "Dumping Bookmarks...\n" + JSON.stringify(bookmarks, undefined, 2) + "\n\n"
  );
}

/**
 * extend, causes a child object to inherit from a parent
 */
function extend(child, supertype) {
  Object.setPrototypeOf(child.prototype, supertype.prototype);
}
/**
 * PlacesItemProps object, holds properties for places items
 */
function PlacesItemProps(props) {
  this.location = null;
  this.uri = null;
  this.keyword = null;
  this.title = null;
  this.after = null;
  this.before = null;
  this.folder = null;
  this.position = null;
  this.delete = false;
  this.tags = null;
  this.last_item_pos = null;
  this.type = null;

  for (var prop in props) {
    if (prop in this) {
      this[prop] = props[prop];
    }
  }
}

/**
 * PlacesItem object.  Base class for places items.
 */
export function PlacesItem(props) {
  this.props = new PlacesItemProps(props);
  if (this.props.location == null) {
    this.props.location = "menu";
  }
  if ("changes" in props) {
    this.updateProps = new PlacesItemProps(props.changes);
  } else {
    this.updateProps = null;
  }
}

/**
 * Instance methods for generic places items.
 */
PlacesItem.prototype = {
  // an array of possible root folders for places items
  _bookmarkFolders: {
    places: PlacesUtils.bookmarks.rootGuid,
    menu: PlacesUtils.bookmarks.menuGuid,
    tags: PlacesUtils.bookmarks.tagsGuid,
    unfiled: PlacesUtils.bookmarks.unfiledGuid,
    toolbar: PlacesUtils.bookmarks.toolbarGuid,
    mobile: PlacesUtils.bookmarks.mobileGuid,
  },

  _typeMap: new Map([
    [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, PlacesUtils.bookmarks.TYPE_FOLDER],
    [
      PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR,
      PlacesUtils.bookmarks.TYPE_SEPARATOR,
    ],
    [PlacesUtils.TYPE_X_MOZ_PLACE, PlacesUtils.bookmarks.TYPE_BOOKMARK],
  ]),

  toString() {
    var that = this;
    var props = ["uri", "title", "location", "folder"];
    var string =
      (this.props.type ? this.props.type + " " : "") +
      "(" +
      (function () {
        var ret = [];
        for (var i in props) {
          if (that.props[props[i]]) {
            ret.push(props[i] + ": " + that.props[props[i]]);
          }
        }
        return ret;
      })().join(", ") +
      ")";
    return string;
  },

  /**
   * GetPlacesChildGuid
   *
   * Finds the guid of the an item with the specified properties in the places
   * database under the specified parent.
   *
   * @param folder The guid of the folder to search
   * @param type The type of the item to find, or null to match any item;
   *        this is one of the PlacesUtils.bookmarks.TYPE_* values
   * @param title The title of the item to find, or null to match any title
   * @param uri The uri of the item to find, or null to match any uri
   *
   * @return the node id if the item was found, otherwise null
   */
  async GetPlacesChildGuid(folder, type, title, uri) {
    let children = (await PlacesUtils.promiseBookmarksTree(folder)).children;
    if (!children) {
      return null;
    }
    let guid = null;
    for (let node of children) {
      if (node.title == title) {
        let nodeType = this._typeMap.get(node.type);
        if (type == null || type == undefined || nodeType == type) {
          if (uri == undefined || uri == null || node.uri.spec == uri.spec) {
            // Note that this is suspect as we return the *last* matching
            // child, which some tests rely on (ie, an early-return here causes
            // at least 1 test to fail). But that's a yak for another day.
            guid = node.guid;
          }
        }
      }
    }
    return guid;
  },

  /**
   * IsAdjacentTo
   *
   * Determines if this object is immediately adjacent to another.
   *
   * @param itemName The name of the other object; this may be any kind of
   *        places item
   * @param relativePos The relative position of the other object.  If -1,
   *        it means the other object should precede this one, if +1,
   *        the other object should come after this one
   * @return true if this object is immediately adjacent to the other object,
   *         otherwise false
   */
  async IsAdjacentTo(itemName, relativePos) {
    Logger.AssertTrue(
      this.props.folder_id != -1 && this.props.guid != null,
      "Either folder_id or guid was invalid"
    );
    let otherGuid = await this.GetPlacesChildGuid(
      this.props.parentGuid,
      null,
      itemName
    );
    Logger.AssertTrue(otherGuid, "item " + itemName + " not found");
    let other_pos = (await PlacesUtils.bookmarks.fetch(otherGuid)).index;
    let this_pos = (await PlacesUtils.bookmarks.fetch(this.props.guid)).index;
    if (other_pos + relativePos != this_pos) {
      Logger.logPotentialError(
        "Invalid position - " +
          (this.props.title ? this.props.title : this.props.folder) +
          " not " +
          (relativePos == 1 ? "after " : "before ") +
          itemName +
          " for " +
          this.toString()
      );
      return false;
    }
    return true;
  },

  /**
   * GetItemIndex
   *
   * Gets the item index for this places item.
   *
   * @return the item index, or -1 if there's an error
   */
  async GetItemIndex() {
    if (this.props.guid == null) {
      return -1;
    }
    return (await PlacesUtils.bookmarks.fetch(this.props.guid)).index;
  },

  /**
   * GetFolder
   *
   * Gets the folder guid for the specified bookmark folder
   *
   * @param location The full path of the folder, which must begin
   *        with one of the bookmark root folders
   * @return the folder guid if the folder is found, otherwise null
   */
  async GetFolder(location) {
    let folder_parts = location.split("/");
    if (!(folder_parts[0] in this._bookmarkFolders)) {
      return null;
    }
    let folderGuid = this._bookmarkFolders[folder_parts[0]];
    for (let i = 1; i < folder_parts.length; i++) {
      let guid = await this.GetPlacesChildGuid(
        folderGuid,
        PlacesUtils.bookmarks.TYPE_FOLDER,
        folder_parts[i]
      );
      if (guid == null) {
        return null;
      }
      folderGuid = guid;
    }
    return folderGuid;
  },

  /**
   * CreateFolder
   *
   * Creates a bookmark folder.
   *
   * @param location The full path of the folder, which must begin
   *        with one of the bookmark root folders
   * @return the folder id if the folder was created, otherwise -1
   */
  async CreateFolder(location) {
    let folder_parts = location.split("/");
    if (!(folder_parts[0] in this._bookmarkFolders)) {
      return -1;
    }
    let folderGuid = this._bookmarkFolders[folder_parts[0]];
    for (let i = 1; i < folder_parts.length; i++) {
      let subfolderGuid = await this.GetPlacesChildGuid(
        folderGuid,
        PlacesUtils.bookmarks.TYPE_FOLDER,
        folder_parts[i]
      );
      if (subfolderGuid == null) {
        let { guid } = await PlacesUtils.bookmarks.insert({
          parentGuid: folderGuid,
          name: folder_parts[i],
          type: PlacesUtils.bookmarks.TYPE_FOLDER,
        });
        folderGuid = guid;
      } else {
        folderGuid = subfolderGuid;
      }
    }
    return folderGuid;
  },

  /**
   * GetOrCreateFolder
   *
   * Locates the specified folder; if not found it is created.
   *
   * @param location The full path of the folder, which must begin
   *        with one of the bookmark root folders
   * @return the folder id if the folder was found or created, otherwise -1
   */
  async GetOrCreateFolder(location) {
    let parentGuid = await this.GetFolder(location);
    if (parentGuid == null) {
      parentGuid = await this.CreateFolder(location);
    }
    return parentGuid;
  },

  /**
   * CheckPosition
   *
   * Verifies the position of this places item.
   *
   * @param before The name of the places item that this item should be
            before, or null if this check should be skipped
   * @param after The name of the places item that this item should be
            after, or null if this check should be skipped
   * @param last_item_pos The index of the places item above this one,
   *        or null if this check should be skipped
   * @return true if this item is in the correct position, otherwise false
   */
  async CheckPosition(before, after, last_item_pos) {
    if (after) {
      if (!(await this.IsAdjacentTo(after, 1))) {
        return false;
      }
    }
    if (before) {
      if (!(await this.IsAdjacentTo(before, -1))) {
        return false;
      }
    }
    if (last_item_pos != null && last_item_pos > -1) {
      let index = await this.GetItemIndex();
      if (index != last_item_pos + 1) {
        Logger.logPotentialError(
          "Item not found at the expected index, got " +
            index +
            ", expected " +
            (last_item_pos + 1) +
            " for " +
            this.toString()
        );
        return false;
      }
    }
    return true;
  },

  /**
   * SetLocation
   *
   * Moves this places item to a different folder.
   *
   * @param location The full path of the folder to which to move this
   *        places item, which must begin with one of the bookmark root
   *        folders; if null, no changes are made
   * @return nothing if successful, otherwise an exception is thrown
   */
  async SetLocation(location) {
    if (location != null) {
      let newfolderGuid = await this.GetOrCreateFolder(location);
      Logger.AssertTrue(
        newfolderGuid,
        "Location " + location + " doesn't exist; can't change item's location"
      );
      await PlacesUtils.bookmarks.update({
        guid: this.props.guid,
        parentGuid: newfolderGuid,
        index: PlacesUtils.bookmarks.DEFAULT_INDEX,
      });
      this.props.parentGuid = newfolderGuid;
    }
  },

  /**
   * SetPosition
   *
   * Updates the position of this places item within this item's current
   * folder.  Use SetLocation to change folders.
   *
   * @param position The new index this item should be moved to; if null,
   *        no changes are made; if -1, this item is moved to the bottom of
   *        the current folder. Otherwise, must be a string which is the
   *        title of an existing item in the folder, who's current position
   *        is used as the index.
   * @return nothing if successful, otherwise an exception is thrown
   */
  async SetPosition(position) {
    if (position == null) {
      return;
    }
    let index = -1;
    if (position != -1) {
      let existingGuid = await this.GetPlacesChildGuid(
        this.props.parentGuid,
        null,
        position
      );
      if (existingGuid) {
        index = (await PlacesUtils.bookmarks.fetch(existingGuid)).index;
      }
      Logger.AssertTrue(
        index != -1,
        "position " + position + " is invalid; unable to change position"
      );
    }
    await PlacesUtils.bookmarks.update({ guid: this.props.guid, index });
  },

  /**
   * Update the title of this places item
   *
   * @param title The new title to set for this item; if null, no changes
   *        are made
   * @return nothing
   */
  async SetTitle(title) {
    if (title != null) {
      await PlacesUtils.bookmarks.update({ guid: this.props.guid, title });
    }
  },
};

/**
 * Bookmark class constructor.  Initializes instance properties.
 */
export function Bookmark(props) {
  PlacesItem.call(this, props);
  if (this.props.title == null) {
    this.props.title = this.props.uri;
  }
  this.props.type = "bookmark";
}

/**
 * Bookmark instance methods.
 */
Bookmark.prototype = {
  /**
   * SetKeyword
   *
   * Update this bookmark's keyword.
   *
   * @param keyword The keyword to set for this bookmark; if null, no
   *        changes are made
   * @return nothing
   */
  async SetKeyword(keyword) {
    if (keyword != null) {
      // Mirror logic from PlacesSyncUtils's updateBookmarkMetadata
      let entry = await PlacesUtils.keywords.fetch({ url: this.props.uri });
      if (entry) {
        await PlacesUtils.keywords.remove(entry);
      }
      await PlacesUtils.keywords.insert({ keyword, url: this.props.uri });
    }
  },

  /**
   * SetUri
   *
   * Updates this bookmark's URI.
   *
   * @param uri The new URI to set for this boomark; if null, no changes
   *        are made
   * @return nothing
   */
  async SetUri(uri) {
    if (uri) {
      let url = Services.io.newURI(uri);
      await PlacesUtils.bookmarks.update({ guid: this.props.guid, url });
    }
  },

  /**
   * SetTags
   *
   * Updates this bookmark's tags.
   *
   * @param tags An array of tags which should be associated with this
   *        bookmark; any previous tags are removed; if this param is null,
   *        no changes are made.  If this param is an empty array, all
   *        tags are removed from this bookmark.
   * @return nothing
   */
  SetTags(tags) {
    if (tags != null) {
      let URI = Services.io.newURI(this.props.uri);
      PlacesUtils.tagging.untagURI(URI, null);
      if (tags.length) {
        PlacesUtils.tagging.tagURI(URI, tags);
      }
    }
  },

  /**
   * Create
   *
   * Creates the bookmark described by this object's properties.
   *
   * @return the id of the created bookmark
   */
  async Create() {
    this.props.parentGuid = await this.GetOrCreateFolder(this.props.location);
    Logger.AssertTrue(
      this.props.parentGuid,
      "Unable to create " +
        "bookmark, error creating folder " +
        this.props.location
    );
    let bookmarkURI = Services.io.newURI(this.props.uri);
    let { guid } = await PlacesUtils.bookmarks.insert({
      parentGuid: this.props.parentGuid,
      url: bookmarkURI,
      title: this.props.title,
    });
    this.props.guid = guid;
    await this.SetKeyword(this.props.keyword);
    await this.SetTags(this.props.tags);
    return this.props.guid;
  },

  /**
   * Update
   *
   * Updates this bookmark's properties according the properties on this
   * object's 'updateProps' property.
   *
   * @return nothing
   */
  async Update() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Update");
    await this.SetTitle(this.updateProps.title);
    await this.SetUri(this.updateProps.uri);
    await this.SetKeyword(this.updateProps.keyword);
    await this.SetTags(this.updateProps.tags);
    await this.SetLocation(this.updateProps.location);
    await this.SetPosition(this.updateProps.position);
  },

  /**
   * Find
   *
   * Locates the bookmark which corresponds to this object's properties.
   *
   * @return the bookmark guid if the bookmark was found, otherwise null
   */
  async Find() {
    this.props.parentGuid = await this.GetFolder(this.props.location);

    if (this.props.parentGuid == null) {
      Logger.logError("Unable to find folder " + this.props.location);
      return null;
    }
    let bookmarkTitle = this.props.title;
    this.props.guid = await this.GetPlacesChildGuid(
      this.props.parentGuid,
      null,
      bookmarkTitle,
      this.props.uri
    );

    if (!this.props.guid) {
      Logger.logPotentialError(this.toString() + " not found");
      return null;
    }
    if (this.props.keyword != null) {
      let { keyword } = await PlacesSyncUtils.bookmarks.fetch(this.props.guid);
      if (keyword != this.props.keyword) {
        Logger.logPotentialError(
          "Incorrect keyword - expected: " +
            this.props.keyword +
            ", actual: " +
            keyword +
            " for " +
            this.toString()
        );
        return null;
      }
    }
    if (this.props.tags != null) {
      try {
        let URI = Services.io.newURI(this.props.uri);
        let tags = PlacesUtils.tagging.getTagsForURI(URI);
        tags.sort();
        this.props.tags.sort();
        if (JSON.stringify(tags) != JSON.stringify(this.props.tags)) {
          Logger.logPotentialError(
            "Wrong tags - expected: " +
              JSON.stringify(this.props.tags) +
              ", actual: " +
              JSON.stringify(tags) +
              " for " +
              this.toString()
          );
          return null;
        }
      } catch (e) {
        Logger.logPotentialError("error processing tags " + e);
        return null;
      }
    }
    if (
      !(await this.CheckPosition(
        this.props.before,
        this.props.after,
        this.props.last_item_pos
      ))
    ) {
      return null;
    }
    return this.props.guid;
  },

  /**
   * Remove
   *
   * Removes this bookmark.  The bookmark should have been located previously
   * by a call to Find.
   *
   * @return nothing
   */
  async Remove() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Remove");
    await PlacesUtils.bookmarks.remove(this.props.guid);
  },
};

extend(Bookmark, PlacesItem);

/**
 * BookmarkFolder class constructor. Initializes instance properties.
 */
export function BookmarkFolder(props) {
  PlacesItem.call(this, props);
  this.props.type = "folder";
}

/**
 * BookmarkFolder instance methods
 */
BookmarkFolder.prototype = {
  /**
   * Create
   *
   * Creates the bookmark folder described by this object's properties.
   *
   * @return the id of the created bookmark folder
   */
  async Create() {
    this.props.parentGuid = await this.GetOrCreateFolder(this.props.location);
    Logger.AssertTrue(
      this.props.parentGuid,
      "Unable to create " +
        "folder, error creating parent folder " +
        this.props.location
    );
    let { guid } = await PlacesUtils.bookmarks.insert({
      parentGuid: this.props.parentGuid,
      title: this.props.folder,
      index: PlacesUtils.bookmarks.DEFAULT_INDEX,
      type: PlacesUtils.bookmarks.TYPE_FOLDER,
    });
    this.props.guid = guid;
    return this.props.parentGuid;
  },

  /**
   * Find
   *
   * Locates the bookmark folder which corresponds to this object's
   * properties.
   *
   * @return the folder guid if the folder was found, otherwise null
   */
  async Find() {
    this.props.parentGuid = await this.GetFolder(this.props.location);
    if (this.props.parentGuid == null) {
      Logger.logError("Unable to find folder " + this.props.location);
      return null;
    }
    this.props.guid = await this.GetPlacesChildGuid(
      this.props.parentGuid,
      PlacesUtils.bookmarks.TYPE_FOLDER,
      this.props.folder
    );
    if (this.props.guid == null) {
      return null;
    }
    if (
      !(await this.CheckPosition(
        this.props.before,
        this.props.after,
        this.props.last_item_pos
      ))
    ) {
      return null;
    }
    return this.props.guid;
  },

  /**
   * Remove
   *
   * Removes this folder.  The folder should have been located previously
   * by a call to Find.
   *
   * @return nothing
   */
  async Remove() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Remove");
    await PlacesUtils.bookmarks.remove(this.props.guid);
  },

  /**
   * Update
   *
   * Updates this bookmark's properties according the properties on this
   * object's 'updateProps' property.
   *
   * @return nothing
   */
  async Update() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Update");
    await this.SetLocation(this.updateProps.location);
    await this.SetPosition(this.updateProps.position);
    await this.SetTitle(this.updateProps.folder);
  },
};

extend(BookmarkFolder, PlacesItem);

/**
 * Separator class constructor. Initializes instance properties.
 */
export function Separator(props) {
  PlacesItem.call(this, props);
  this.props.type = "separator";
}

/**
 * Separator instance methods.
 */
Separator.prototype = {
  /**
   * Create
   *
   * Creates the bookmark separator described by this object's properties.
   *
   * @return the id of the created separator
   */
  async Create() {
    this.props.parentGuid = await this.GetOrCreateFolder(this.props.location);
    Logger.AssertTrue(
      this.props.parentGuid,
      "Unable to create " +
        "folder, error creating parent folder " +
        this.props.location
    );
    let { guid } = await PlacesUtils.bookmarks.insert({
      parentGuid: this.props.parentGuid,
      type: PlacesUtils.bookmarks.TYPE_SEPARATOR,
    });
    this.props.guid = guid;
    return guid;
  },

  /**
   * Find
   *
   * Locates the bookmark separator which corresponds to this object's
   * properties.
   *
   * @return the item guid if the separator was found, otherwise null
   */
  async Find() {
    this.props.parentGuid = await this.GetFolder(this.props.location);
    if (this.props.parentGuid == null) {
      Logger.logError("Unable to find folder " + this.props.location);
      return null;
    }
    if (this.props.before == null && this.props.last_item_pos == null) {
      Logger.logPotentialError(
        "Separator requires 'before' attribute if it's the" +
          "first item in the list"
      );
      return null;
    }
    let expected_pos = -1;
    if (this.props.before) {
      let otherGuid = this.GetPlacesChildGuid(
        this.props.parentGuid,
        null,
        this.props.before
      );
      if (otherGuid == null) {
        Logger.logPotentialError(
          "Can't find places item " +
            this.props.before +
            " for locating separator"
        );
        return null;
      }
      expected_pos = (await PlacesUtils.bookmarks.fetch(otherGuid)).index - 1;
    } else {
      expected_pos = this.props.last_item_pos + 1;
    }
    // Note these are IDs instead of GUIDs.
    let children = await PlacesSyncUtils.bookmarks.fetchChildRecordIds(
      this.props.parentGuid
    );
    this.props.guid = children[expected_pos];
    if (this.props.guid == null) {
      Logger.logPotentialError(
        "No separator found at position " + expected_pos
      );
      return null;
    }
    let info = await PlacesUtils.bookmarks.fetch(this.props.guid);
    if (info.type != PlacesUtils.bookmarks.TYPE_SEPARATOR) {
      Logger.logPotentialError(
        "Places item at position " + expected_pos + " is not a separator"
      );
      return null;
    }
    return this.props.guid;
  },

  /**
   * Update
   *
   * Updates this separator's properties according the properties on this
   * object's 'updateProps' property.
   *
   * @return nothing
   */
  async Update() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Update");
    await this.SetLocation(this.updateProps.location);
    await this.SetPosition(this.updateProps.position);
    return true;
  },

  /**
   * Remove
   *
   * Removes this separator.  The separator should have been located
   * previously by a call to Find.
   *
   * @return nothing
   */
  async Remove() {
    Logger.AssertTrue(this.props.guid, "Invalid guid during Update");
    await PlacesUtils.bookmarks.remove(this.props.guid);
  },
};

extend(Separator, PlacesItem);
