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

import { BinarySearch } from "resource://gre/modules/BinarySearch.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = {};

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "ClipboardHelper",
  "@mozilla.org/widget/clipboardhelper;1",
  Ci.nsIClipboardHelper
);

/**
 * Create a function to format messages.
 *
 * @param  {...any} ftlFiles to be used for formatting messages
 * @returns {Function} a function that can be used to format messsages
 */
function createFormatMessages(...ftlFiles) {
  const strings = new Localization(ftlFiles);

  return async (...ids) => {
    for (const i in ids) {
      if (typeof ids[i] == "string") {
        ids[i] = { id: ids[i] };
      }
    }

    const messages = await strings.formatMessages(ids);
    return messages.map(message => {
      if (message.attributes) {
        return message.attributes.reduce(
          (result, { name, value }) => ({ ...result, [name]: value }),
          {}
        );
      }
      return message.value;
    });
  };
}

/**
 * Base datasource class
 */
export class DataSourceBase {
  #aggregatorApi;

  constructor(aggregatorApi) {
    this.#aggregatorApi = aggregatorApi;
  }

  // proxy consumer api functions to datasource interface

  refreshSingleLineOnScreen(line) {
    this.#aggregatorApi.refreshSingleLineOnScreen(line);
  }

  refreshAllLinesOnScreen() {
    this.#aggregatorApi.refreshAllLinesOnScreen();
  }

  setLayout(layout) {
    this.#aggregatorApi.setLayout(layout);
  }

  setNotification(notification) {
    this.#aggregatorApi.setNotification(notification);
  }

  setDisplayMode(displayMode) {
    this.#aggregatorApi.setDisplayMode(displayMode);
  }

  discardChangesConfirmed() {
    this.#aggregatorApi.discardChangesConfirmed();
  }

  setPrimaryPasswordAuthenticated(isAuthenticated) {
    this.#aggregatorApi.setPrimaryPasswordAuthenticated(isAuthenticated);
  }

  // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
  formatMessages = createFormatMessages("browser/contextual-manager.ftl");
  static ftl = new Localization([
    "branding/brand.ftl",
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "browser/contextual-manager.ftl",
  ]);

  async localizeStrings(strings) {
    const keys = Object.keys(strings);
    // On Linux there are no translation id's for OS auth messsages because it
    // is not supported (see getPlatformFtl), so we need to filter them out
    // to stay consistent with l10nObj.
    const validKeys = keys.filter(key => !!strings[key]?.id);
    const l10nObj = Object.values(strings)
      .filter(({ id }) => id)
      .map(({ id, args = {} }) => ({ id, args }));
    const messages = await DataSourceBase.ftl.formatMessages(l10nObj);

    for (let i = 0; i < messages.length; i++) {
      let { attributes, value } = messages[i];
      if (attributes) {
        value = attributes.reduce(
          (result, { name, value }) => ({ ...result, [name]: value }),
          {}
        );
      }
      strings[validKeys[i]] = value;
    }
    return strings;
  }

  getPlatformFtl(messageId) {
    // OS auth is only supported on Windows and macOS
    if (
      AppConstants.platform == "linux" &&
      messageId.includes("os-auth-dialog")
    ) {
      return null;
    }

    if (AppConstants.platform == "macosx") {
      messageId += "-macosx";
    } else if (AppConstants.platform == "win") {
      messageId += "-win";
    }

    return messageId;
  }

  /**
   * Prototype for the each line.
   * See this link for details:
   * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperties#props
   */
  #linePrototype = {
    /**
     * Reference to the Data Source that owns this line.
     */
    source: this,

    /**
     * Each line has a reference to the actual data record.
     */
    record: { writable: true },

    /**
     * Is line ready to be displayed?
     * Used by the View Model.
     *
     * @returns {boolean} true if line can be sent to the view.
     *          false if line is not ready to be displayed. In this case
     *          data source will start pulling value from the underlying
     *          storage and will push data to screen when it's ready.
     */
    lineIsReady() {
      return true;
    },

    isEditing() {
      return this.editingValue !== undefined;
    },

    copyToClipboard(text) {
      lazy.ClipboardHelper.copyString(
        text,
        null,
        lazy.ClipboardHelper.Sensitive
      );

      this.refreshOnScreen();
    },

    openLinkInTab(url) {
      const { BrowserWindowTracker } = ChromeUtils.importESModule(
        "resource:///modules/BrowserWindowTracker.sys.mjs"
      );
      const browser = BrowserWindowTracker.getTopWindow({
        allowFromInactiveWorkspace: true,
      }).gBrowser;
      browser.addWebTab(url, { inBackground: false });
    },

    /**
     * Simple version of Copy command. Line still needs to add "Copy" command.
     * Override if copied value != displayed value.
     */
    executeCopy() {
      this.copyToClipboard(this.value);
    },

    executeOpen() {
      this.openLinkInTab(this.href);
    },

    executeEditInProgress(value) {
      this.editingValue = value;
      this.refreshOnScreen();
    },

    executeCancel() {
      delete this.editingValue;
      this.refreshOnScreen();
    },

    get template() {
      return "editingValue" in this ? "editingLineTemplate" : undefined;
    },

    refreshOnScreen() {
      this.source.refreshSingleLineOnScreen(this);
    },
    setLayout(data) {
      this.source.setLayout(data);
    },
  };

  /**
   * Creates collapsible section header line.
   *
   * @returns {object} section header line
   */
  createHeaderLine() {
    const result = {
      value: {},
      collapsed: false,
      start: true,
      end: true,
      source: this,

      /**
       * Use different templates depending on the collapsed state.
       */
      get template() {
        return this.collapsed
          ? "collapsedSectionTemplate"
          : "expandedSectionTemplate";
      },

      lineIsReady: () => true,

      commands: [{ id: "Toggle" }],

      executeToggle() {
        this.collapsed = !this.collapsed;
        this.source.refreshAllLinesOnScreen();
      },
    };

    return result;
  }

  /**
   * Create a prototype to be used for data lines,
   * provides common set of features like Copy command.
   *
   * @param {object} properties to customize data line
   * @returns {object} data line prototype
   */
  prototypeDataLine(properties) {
    return Object.create(this.#linePrototype, properties);
  }

  lines = [];
  #collator = new Intl.Collator();
  #linesToForget;

  /**
   * Code to run before reloading data source.
   * It will start tracking which lines are no longer at the source so
   * afterReloadingDataSource() can remove them.
   */
  beforeReloadingDataSource() {
    this.#linesToForget = new Set(this.lines);
  }

  /**
   * Code to run after reloading data source.
   * It will forget lines that are no longer at the source and refresh screen.
   */
  afterReloadingDataSource() {
    // We do a null checks on `linesToForget` despite being initialized to a
    // Set in `beforeReloadingDataSource`. We should re-evaluate the callsites
    // of before/afterReloadingDataSource.
    if (this.#linesToForget?.size) {
      for (let i = this.lines.length; i >= 0; i--) {
        if (this.#linesToForget.has(this.lines[i])) {
          this.lines.splice(i, 1);
        }
      }
    }

    this.#linesToForget = null;
    this.refreshAllLinesOnScreen();
  }

  /**
   * Add or update line associated with the record.
   *
   * @param {object} record with which line is associated
   * @param {*} id sortable line id
   * @param {*} fieldPrototype to be used when creating a line.
   */
  addOrUpdateLine(record, id, fieldPrototype) {
    let [found, index] = BinarySearch.search(
      (target, value) => this.#collator.compare(target, value.id),
      this.lines,
      id
    );

    if (found) {
      this.#linesToForget?.delete(this.lines[index]);
    } else {
      const line = Object.create(fieldPrototype, { id: { value: id } });
      this.lines.splice(index, 0, line);
    }
    this.lines[index].record = record;
    return this.lines[index];
  }

  cancelDialog() {
    this.setLayout(null);
  }

  *enumerateLinesForMatchingRecords(searchText, stats, match) {
    stats.total = 0;
    stats.count = 0;

    if (searchText) {
      let i = 0;
      while (i < this.lines.length) {
        const currentRecord = this.lines[i].record;
        stats.total += 1;

        if (match(currentRecord)) {
          // Record matches, yield all it's lines
          while (
            i < this.lines.length &&
            currentRecord == this.lines[i].record
          ) {
            this.lines[i].concealed = true;
            yield this.lines[i];
            i += 1;
          }
          stats.count += 1;
        } else {
          // Record does not match, skip until the next one
          while (
            i < this.lines.length &&
            currentRecord == this.lines[i].record
          ) {
            i += 1;
          }
        }
      }
    } else {
      // No search text is provided - send all lines out, count records
      let currentRecord;
      for (const line of this.lines) {
        line.concealed = true;
        yield line;

        if (line.record != currentRecord) {
          stats.total += 1;
          currentRecord = line.record;
        }
      }
      stats.count = stats.total;
    }
  }
}
