/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

// Import common head.
{
  /* import-globals-from ../head_common.js */
  let commonFile = do_get_file("../head_common.js", false);
  let uri = Services.io.newFileURI(commonFile);
  Services.scriptloader.loadSubScriptWithOptions(uri.spec, {
    target: this,
    allowUnsafeURL: true,
  });
}

// Put any other stuff relative to this test folder below.

var { CanonicalJSON } = ChromeUtils.importESModule(
  "resource://gre/modules/CanonicalJSON.sys.mjs"
);
var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");

var { PlacesSyncUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/PlacesSyncUtils.sys.mjs"
);
var { SyncedBookmarksMirror } = ChromeUtils.importESModule(
  "resource://gre/modules/SyncedBookmarksMirror.sys.mjs"
);
var { CommonUtils } = ChromeUtils.importESModule(
  "resource://services-common/utils.sys.mjs"
);
var { FileTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/FileTestUtils.sys.mjs"
);
var {
  HTTP_400,
  HTTP_401,
  HTTP_402,
  HTTP_403,
  HTTP_404,
  HTTP_405,
  HTTP_406,
  HTTP_407,
  HTTP_408,
  HTTP_409,
  HTTP_410,
  HTTP_411,
  HTTP_412,
  HTTP_413,
  HTTP_414,
  HTTP_415,
  HTTP_417,
  HTTP_500,
  HTTP_501,
  HTTP_502,
  HTTP_503,
  HTTP_504,
  HTTP_505,
  HttpError,
  HttpServer,
} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");

// These titles are defined in Database::CreateBookmarkRoots
const BookmarksMenuTitle = "menu";
const BookmarksToolbarTitle = "toolbar";
const UnfiledBookmarksTitle = "unfiled";
const MobileBookmarksTitle = "mobile";

function run_test() {
  let bufLog = Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror");
  bufLog.level = Log.Level.All;

  let sqliteLog = Log.repository.getLogger("Sqlite");
  sqliteLog.level = Log.Level.Error;

  let formatter = new Log.BasicFormatter();
  let appender = new Log.DumpAppender(formatter);
  appender.level = Log.Level.All;

  for (let log of [bufLog, sqliteLog]) {
    log.addAppender(appender);
  }

  do_get_profile();
  run_next_test();
}

// A test helper to insert local roots directly into Places, since the public
// bookmarks APIs no longer support custom roots.
async function insertLocalRoot({ guid, title }) {
  await PlacesUtils.withConnectionWrapper(
    "insertLocalRoot",
    async function (db) {
      let dateAdded = PlacesUtils.toPRTime(new Date());
      await db.execute(
        `
        INSERT INTO moz_bookmarks(guid, type, parent, position, title,
                                  dateAdded, lastModified)
        VALUES(:guid, :type, (SELECT id FROM moz_bookmarks
                              WHERE guid = :parentGuid),
               (SELECT COUNT(*) FROM moz_bookmarks
                WHERE parent = (SELECT id FROM moz_bookmarks
                                WHERE guid = :parentGuid)),
               :title, :dateAdded, :dateAdded)`,
        {
          guid,
          type: PlacesUtils.bookmarks.TYPE_FOLDER,
          parentGuid: PlacesUtils.bookmarks.rootGuid,
          title,
          dateAdded,
        }
      );
    }
  );
}

// Returns a `CryptoWrapper`-like object that wraps the Sync record cleartext.
// This exists to avoid importing `record.js` from Sync.
function makeRecord(cleartext) {
  return new Proxy(
    { cleartext },
    {
      get(target, property) {
        if (property == "cleartext") {
          return target.cleartext;
        }
        if (property == "cleartextToString") {
          return () => JSON.stringify(target.cleartext);
        }
        return target.cleartext[property];
      },
      set(target, property, value) {
        if (property == "cleartext") {
          target.cleartext = value;
        } else if (property != "cleartextToString") {
          target.cleartext[property] = value;
        }
      },
      has(target, property) {
        return property == "cleartext" || property in target.cleartext;
      },
      deleteProperty() {},
      ownKeys(target) {
        return ["cleartext", ...Reflect.ownKeys(target)];
      },
    }
  );
}

async function storeRecords(buf, records, options) {
  await buf.store(records.map(makeRecord), options);
}

async function storeChangesInMirror(buf, changesToUpload) {
  let cleartexts = [];
  for (let recordId in changesToUpload) {
    changesToUpload[recordId].synced = true;
    cleartexts.push(changesToUpload[recordId].cleartext);
  }
  await storeRecords(buf, cleartexts, { needsMerge: false });
  await PlacesSyncUtils.bookmarks.pushChanges(changesToUpload);
}

function inspectChangeRecords(changeRecords) {
  let results = { updated: [], deleted: [] };
  for (let [id, record] of Object.entries(changeRecords)) {
    (record.tombstone ? results.deleted : results.updated).push(id);
  }
  results.updated.sort();
  results.deleted.sort();
  return results;
}

async function promiseManyDatesAdded(guids) {
  let datesAdded = new Map();
  let db = await PlacesUtils.promiseDBConnection();
  for (let chunk of PlacesUtils.chunkArray(guids, 100)) {
    let rows = await db.executeCached(
      `
      SELECT guid, dateAdded FROM moz_bookmarks
      WHERE guid IN (${new Array(chunk.length).fill("?").join(",")})`,
      chunk
    );
    if (rows.length != chunk.length) {
      throw new TypeError("Can't fetch date added for nonexistent items");
    }
    for (let row of rows) {
      let dateAdded = row.getResultByName("dateAdded") / 1000;
      datesAdded.set(row.getResultByName("guid"), dateAdded);
    }
  }
  return datesAdded;
}

async function fetchLocalTree(rootGuid) {
  function bookmarkNodeToInfo(node) {
    let { guid, index, title, typeCode: type } = node;
    let itemInfo = { guid, index, title, type };
    if (node.annos) {
      let syncableAnnos = node.annos.filter(anno =>
        [PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI].includes(
          anno.name
        )
      );
      if (syncableAnnos.length) {
        itemInfo.annos = syncableAnnos;
      }
    }
    if (node.uri) {
      itemInfo.url = node.uri;
    }
    if (node.keyword) {
      itemInfo.keyword = node.keyword;
    }
    if (node.children) {
      itemInfo.children = node.children.map(bookmarkNodeToInfo);
    }
    if (node.tags) {
      itemInfo.tags = node.tags.split(",").sort();
    }
    return itemInfo;
  }
  let root = await PlacesUtils.promiseBookmarksTree(rootGuid);
  return bookmarkNodeToInfo(root);
}

async function assertLocalTree(rootGuid, expected, message) {
  let actual = await fetchLocalTree(rootGuid);
  if (!ObjectUtils.deepEqual(actual, expected)) {
    info(
      `Expected structure for ${rootGuid}: ${CanonicalJSON.stringify(expected)}`
    );
    info(
      `Actual structure for ${rootGuid}:   ${CanonicalJSON.stringify(actual)}`
    );
    throw new Assert.constructor.AssertionError({ actual, expected, message });
  }
}

function makeLivemarkServer() {
  let server = new HttpServer();
  server.registerPrefixHandler("/feed/", do_get_file("./livemark.xml"));
  server.start(-1);
  return {
    server,
    get site() {
      let { identity } = server;
      let host = identity.primaryHost.includes(":")
        ? `[${identity.primaryHost}]`
        : identity.primaryHost;
      return `${identity.primaryScheme}://${host}:${identity.primaryPort}`;
    },
    stopServer() {
      return new Promise(resolve => server.stop(resolve));
    },
  };
}

function shuffle(array) {
  let results = [];
  for (let i = 0; i < array.length; ++i) {
    let randomIndex = Math.floor(Math.random() * (i + 1));
    results[i] = results[randomIndex];
    results[randomIndex] = array[i];
  }
  return results;
}

async function fetchAllKeywords(info) {
  let entries = [];
  await PlacesUtils.keywords.fetch(info, entry => entries.push(entry));
  return entries;
}

async function openMirror(name, options = {}) {
  let buf = await SyncedBookmarksMirror.open({
    path: `${name}_buf.sqlite`,
    recordStepTelemetry(...args) {
      if (options.recordStepTelemetry) {
        options.recordStepTelemetry.call(this, ...args);
      }
    },
    recordValidationTelemetry(...args) {
      if (options.recordValidationTelemetry) {
        options.recordValidationTelemetry.call(this, ...args);
      }
    },
  });
  return buf;
}

function BookmarkObserver({ ignoreDates = true, skipTags = false } = {}) {
  this.notifications = [];
  this.ignoreDates = ignoreDates;
  this.skipTags = skipTags;
  this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
}

BookmarkObserver.prototype = {
  handlePlacesEvents(events) {
    for (let event of events) {
      switch (event.type) {
        case "bookmark-added": {
          if (this.skipTags && event.isTagging) {
            continue;
          }
          let params = {
            itemId: event.id,
            parentId: event.parentId,
            index: event.index,
            type: event.itemType,
            urlHref: event.url,
            title: event.title,
            guid: event.guid,
            parentGuid: event.parentGuid,
            source: event.source,
            tags: event.tags,
            frecency: event.frecency,
            hidden: event.hidden,
            visitCount: event.visitCount,
          };
          if (!this.ignoreDates) {
            params.dateAdded = event.dateAdded;
            params.lastVisitDate = event.lastVisitDate;
          }
          this.notifications.push({ name: "bookmark-added", params });
          break;
        }
        case "bookmark-removed": {
          if (this.skipTags && event.isTagging) {
            continue;
          }
          // Since we are now skipping tags on the listener side we don't
          // prevent unTagging notifications from going out. These events cause empty
          // tags folders to be removed which creates another bookmark-removed notification
          if (
            this.skipTags &&
            event.parentGuid == PlacesUtils.bookmarks.tagsGuid
          ) {
            continue;
          }
          let params = {
            itemId: event.id,
            parentId: event.parentId,
            index: event.index,
            type: event.itemType,
            urlHref: event.url || null,
            title: event.title,
            guid: event.guid,
            parentGuid: event.parentGuid,
            source: event.source,
          };
          this.notifications.push({ name: "bookmark-removed", params });
          break;
        }
        case "bookmark-moved": {
          const params = {
            itemId: event.id,
            type: event.itemType,
            urlHref: event.url,
            source: event.source,
            guid: event.guid,
            newIndex: event.index,
            newParentGuid: event.parentGuid,
            oldIndex: event.oldIndex,
            oldParentGuid: event.oldParentGuid,
            isTagging: event.isTagging,
            title: event.title,
            tags: event.tags,
            frecency: event.frecency,
            hidden: event.hidden,
            visitCount: event.visitCount,
            dateAdded: event.dateAdded,
            lastVisitDate: event.lastVisitDate,
          };
          this.notifications.push({ name: "bookmark-moved", params });
          break;
        }
        case "bookmark-guid-changed": {
          const params = {
            itemId: event.id,
            type: event.itemType,
            urlHref: event.url,
            guid: event.guid,
            parentGuid: event.parentGuid,
            source: event.source,
            isTagging: event.isTagging,
          };
          this.notifications.push({ name: "bookmark-guid-changed", params });
          break;
        }
        case "bookmark-title-changed": {
          const params = {
            itemId: event.id,
            guid: event.guid,
            title: event.title,
            parentGuid: event.parentGuid,
          };
          this.notifications.push({ name: "bookmark-title-changed", params });
          break;
        }
        case "bookmark-url-changed": {
          const params = {
            itemId: event.id,
            type: event.itemType,
            urlHref: event.url,
            guid: event.guid,
            parentGuid: event.parentGuid,
            source: event.source,
            isTagging: event.isTagging,
          };
          this.notifications.push({ name: "bookmark-url-changed", params });
          break;
        }
      }
    }
  },

  check(expectedNotifications) {
    PlacesUtils.observers.removeListener(
      [
        "bookmark-added",
        "bookmark-removed",
        "bookmark-moved",
        "bookmark-guid-changed",
        "bookmark-title-changed",
        "bookmark-url-changed",
      ],
      this.handlePlacesEvents
    );
    if (!ObjectUtils.deepEqual(this.notifications, expectedNotifications)) {
      info(`Expected notifications: ${JSON.stringify(expectedNotifications)}`);
      info(`Actual notifications: ${JSON.stringify(this.notifications)}`);
      throw new Assert.constructor.AssertionError({
        actual: this.notifications,
        expected: expectedNotifications,
      });
    }
  },
};

function expectBookmarkChangeNotifications(options) {
  let observer = new BookmarkObserver(options);
  PlacesUtils.observers.addListener(
    [
      "bookmark-added",
      "bookmark-removed",
      "bookmark-moved",
      "bookmark-guid-changed",
      "bookmark-title-changed",
      "bookmark-url-changed",
    ],
    observer.handlePlacesEvents
  );
  return observer;
}

// Copies a support file to a temporary fixture file, allowing the support
// file to be reused for multiple tests.
async function setupFixtureFile(fixturePath) {
  let fixtureFile = do_get_file(fixturePath);
  let tempFile = FileTestUtils.getTempFile(fixturePath);
  await IOUtils.copy(fixtureFile.path, tempFile.path);
  return tempFile;
}
