/* 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/. */
"use strict";

module.metadata = {
  "stability": "stable"
};

const { contract: loaderContract } = require('./content/loader');
const { contract } = require('./util/contract');
const { WorkerHost, connect } = require('./content/utils');
const { Class } = require('./core/heritage');
const { Disposable } = require('./core/disposable');
const { Worker } = require('./content/worker');
const { EventTarget } = require('./event/target');
const { on, emit, once, setListeners } = require('./event/core');
const { isRegExp, isUndefined } = require('./lang/type');
const { merge, omit } = require('./util/object');
const { remove, has, hasAny } = require("./util/array");
const { Rules } = require("./util/rules");
const { processes, frames, remoteRequire } = require('./remote/parent');
remoteRequire('sdk/content/page-mod');

const pagemods = new Map();
const workers = new Map();
const models = new WeakMap();
let modelFor = (mod) => models.get(mod);
let workerFor = (mod) => workers.get(mod)[0];

// Helper functions
let isRegExpOrString = (v) => isRegExp(v) || typeof v === 'string';

let PAGEMOD_ID = 0;

// Validation Contracts
const modOptions = {
  // contentStyle* / contentScript* are sharing the same validation constraints,
  // so they can be mostly reused, except for the messages.
  contentStyle: merge(Object.create(loaderContract.rules.contentScript), {
    msg: 'The `contentStyle` option must be a string or an array of strings.'
  }),
  contentStyleFile: merge(Object.create(loaderContract.rules.contentScriptFile), {
    msg: 'The `contentStyleFile` option must be a local URL or an array of URLs'
  }),
  include: {
    is: ['string', 'array', 'regexp'],
    ok: (rule) => {
      if (isRegExpOrString(rule))
        return true;
      if (Array.isArray(rule) && rule.length > 0)
        return rule.every(isRegExpOrString);
      return false;
    },
    msg: 'The `include` option must always contain atleast one rule as a string, regular expression, or an array of strings and regular expressions.'
  },
  exclude: {
    is: ['string', 'array', 'regexp', 'undefined'],
    ok: (rule) => {
      if (isRegExpOrString(rule) || isUndefined(rule))
        return true;
      if (Array.isArray(rule) && rule.length > 0)
        return rule.every(isRegExpOrString);
      return false;
    },
    msg: 'If set, the `exclude` option must always contain at least one ' +
      'rule as a string, regular expression, or an array of strings and ' +
      'regular expressions.'
  },
  attachTo: {
    is: ['string', 'array', 'undefined'],
    map: function (attachTo) {
      if (!attachTo) return ['top', 'frame'];
      if (typeof attachTo === 'string') return [attachTo];
      return attachTo;
    },
    ok: function (attachTo) {
      return hasAny(attachTo, ['top', 'frame']) &&
        attachTo.every(has.bind(null, ['top', 'frame', 'existing']));
    },
    msg: 'The `attachTo` option must be a string or an array of strings. ' +
      'The only valid options are "existing", "top" and "frame", and must ' +
      'contain at least "top" or "frame" values.'
  },
};

const modContract = contract(merge({}, loaderContract.rules, modOptions));

/**
 * PageMod constructor (exported below).
 * @constructor
 */
const PageMod = Class({
  implements: [
    modContract.properties(modelFor),
    EventTarget,
    Disposable,
  ],
  extends: WorkerHost(workerFor),
  setup: function PageMod(options) {
    let mod = this;
    let model = modContract(options);
    models.set(this, model);
    model.id = PAGEMOD_ID++;

    let include = model.include;
    model.include = Rules();
    model.include.add.apply(model.include, [].concat(include));

    let exclude = isUndefined(model.exclude) ? [] : model.exclude;
    model.exclude = Rules();
    model.exclude.add.apply(model.exclude, [].concat(exclude));

    // Set listeners on {PageMod} itself, not the underlying worker,
    // like `onMessage`, as it'll get piped.
    setListeners(this, options);

    pagemods.set(model.id, this);
    workers.set(this, []);

    function serializeRules(rules) {
      for (let rule of rules) {
        yield isRegExp(rule) ? { type: "regexp", pattern: rule.source, flags: rule.flags }
                             : { type: "string", value: rule };
      }
    }

    model.childOptions = omit(model, ["include", "exclude", "contentScriptOptions"]);
    model.childOptions.include = [...serializeRules(model.include)];
    model.childOptions.exclude = [...serializeRules(model.exclude)];
    model.childOptions.contentScriptOptions = model.contentScriptOptions ?
                                              JSON.stringify(model.contentScriptOptions) :
                                              null;

    processes.port.emit('sdk/page-mod/create', model.childOptions);
  },

  dispose: function(reason) {
    processes.port.emit('sdk/page-mod/destroy', modelFor(this).id);
    pagemods.delete(modelFor(this).id);
    workers.delete(this);
  },

  destroy: function(reason) {
    // Explicit destroy call, i.e. not via unload so destroy the workers
    let list = workers.get(this);
    if (!list)
      return;

    // Triggers dispose which will cause the child page-mod to be destroyed
    Disposable.prototype.destroy.call(this, reason);

    // Destroy any active workers
    for (let worker of list)
      worker.destroy(reason);
  }
});
exports.PageMod = PageMod;

// Whenever a new process starts send over the list of page-mods
processes.forEvery(process => {
  for (let mod of pagemods.values())
    process.port.emit('sdk/page-mod/create', modelFor(mod).childOptions);
});

frames.port.on('sdk/page-mod/worker-create', (frame, modId, workerOptions) => {
  let mod = pagemods.get(modId);
  if (!mod)
    return;

  // Attach the parent side of the worker to the child
  let worker = Worker();

  workers.get(mod).unshift(worker);
  worker.on('*', (event, ...args) => {
    // page-mod's "attach" event needs to be passed a worker
    if (event === 'attach')
      emit(mod, event, worker)
    else
      emit(mod, event, ...args);
  });

  worker.on('detach', () => {
    let array = workers.get(mod);
    if (array)
      remove(array, worker);
  });

  connect(worker, frame, workerOptions);
});
