/* 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 { getAttachEventType } = require('../content/utils');
const { Class } = require('../core/heritage');
const { Disposable } = require('../core/disposable');
const { WeakReference } = require('../core/reference');
const { WorkerChild } = require('./worker-child');
const { EventTarget } = require('../event/target');
const { on, emit, once, setListeners } = require('../event/core');
const { on: domOn, removeListener: domOff } = require('../dom/events');
const { isRegExp, isUndefined } = require('../lang/type');
const { merge } = require('../util/object');
const { isBrowser, getFrames } = require('../window/utils');
const { getTabs, getTabContentWindow, getTabForContentWindow,
        getURI: getTabURI } = require('../tabs/utils');
const { ignoreWindow } = require('../private-browsing/utils');
const { Style } = require("../stylesheet/style");
const { attach, detach } = require("../content/mod");
const { has, hasAny } = require("../util/array");
const { Rules } = require("../util/rules");
const { List, addListItem, removeListItem } = require('../util/list');
const { when } = require("../system/unload");
const { uuid } = require('../util/uuid');
const { frames, process } = require('../remote/child');

const pagemods = new Map();
const styles = new WeakMap();
let styleFor = (mod) => styles.get(mod);

// Helper functions
let modMatchesURI = (mod, uri) => mod.include.matchesAny(uri) && !mod.exclude.matchesAny(uri);

/**
 * PageMod constructor (exported below).
 * @constructor
 */
const ChildPageMod = Class({
  implements: [
    EventTarget,
    Disposable,
  ],
  setup: function PageMod(model) {
    merge(this, model);

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

    function deserializeRules(rules) {
      for (let rule of rules) {
        yield rule.type == "string" ? rule.value
                                    : new RegExp(rule.pattern, rule.flags);
      }
    }

    let include = [...deserializeRules(this.include)];
    this.include = Rules();
    this.include.add.apply(this.include, include);

    let exclude = [...deserializeRules(this.exclude)];
    this.exclude = Rules();
    this.exclude.add.apply(this.exclude, exclude);

    if (this.contentStyle || this.contentStyleFile) {
      styles.set(this, Style({
        uri: this.contentStyleFile,
        source: this.contentStyle
      }));
    }

    pagemods.set(this.id, this);
    this.seenDocuments = new WeakMap();

    // `applyOnExistingDocuments` has to be called after `pagemods.add()`
    // otherwise its calls to `onContent` method won't do anything.
    if (has(this.attachTo, 'existing'))
      applyOnExistingDocuments(this);
  },

  dispose: function() {
    let style = styleFor(this);
    if (style)
      detach(style);

    for (let i in this.include)
      this.include.remove(this.include[i]);

    pagemods.delete(this.id);
  }
});

function onContentWindow({ target: document }) {
  // Return if we have no pagemods
  if (pagemods.size === 0)
    return;

  let window = document.defaultView;
  // XML documents don't have windows, and we don't yet support them.
  if (!window)
    return;

  // Frame event listeners are bound to the frame the event came from by default
  let frame = this;
  // We apply only on documents in tabs of Firefox
  if (!frame.isTab)
    return;

  // When the tab is private, only addons with 'private-browsing' flag in
  // their package.json can apply content script to private documents
  if (ignoreWindow(window))
    return;

  for (let pagemod of pagemods.values()) {
    if (modMatchesURI(pagemod, window.location.href))
      onContent(pagemod, window);
  }
}
frames.addEventListener("DOMDocElementInserted", onContentWindow, true);

function applyOnExistingDocuments (mod) {
  for (let frame of frames) {
    // Fake a newly created document
    let window = frame.content;
    // on startup with e10s, contentWindow might not exist yet,
    // in which case we will get notified by "document-element-inserted".
    if (!window || !window.frames)
      return;
    let uri = window.location.href;
    if (has(mod.attachTo, "top") && modMatchesURI(mod, uri))
      onContent(mod, window);
    if (has(mod.attachTo, "frame"))
      getFrames(window).
        filter(iframe => modMatchesURI(mod, iframe.location.href)).
        forEach(frame => onContent(mod, frame));
  }
}

function createWorker(mod, window) {
  let workerId = String(uuid());

  // Instruct the parent to connect to this worker. Do this first so the parent
  // side is connected before the worker attempts to send any messages there
  let frame = frames.getFrameForWindow(window.top);
  frame.port.emit('sdk/page-mod/worker-create', mod.id, {
    id: workerId,
    url: window.location.href
  });

  // Create a child worker and notify the parent
  let worker = WorkerChild({
    id: workerId,
    window: window,
    contentScript: mod.contentScript,
    contentScriptFile: mod.contentScriptFile,
    contentScriptOptions: mod.contentScriptOptions
  });

  once(worker, 'detach', () => worker.destroy());
}

function onContent (mod, window) {
  let isTopDocument = window.top === window;
  // Is a top level document and `top` is not set, ignore
  if (isTopDocument && !has(mod.attachTo, "top"))
    return;
  // Is a frame document and `frame` is not set, ignore
  if (!isTopDocument && !has(mod.attachTo, "frame"))
    return;

  // ensure we attach only once per document
  let seen = mod.seenDocuments;
  if (seen.has(window.document))
    return;
  seen.set(window.document, true);

  let style = styleFor(mod);
  if (style)
    attach(style, window);

  // Immediately evaluate content script if the document state is already
  // matching contentScriptWhen expectations
  if (isMatchingAttachState(mod, window)) {
    createWorker(mod, window);
    return;
  }

  let eventName = getAttachEventType(mod) || 'load';
  domOn(window, eventName, function onReady (e) {
    if (e.target.defaultView !== window)
      return;
    domOff(window, eventName, onReady, true);
    createWorker(mod, window);

    // Attaching is asynchronous so if the document is already loaded we will
    // miss the pageshow event so send a synthetic one.
    if (window.document.readyState == "complete") {
      mod.on('attach', worker => {
        try {
          worker.send('pageshow');
          emit(worker, 'pageshow');
        }
        catch (e) {
          // This can fail if an earlier attach listener destroyed the worker
        }
      });
    }
  }, true);
}

function isMatchingAttachState (mod, window) {
  let state = window.document.readyState;
  return 'start' === mod.contentScriptWhen ||
      // Is `load` event already dispatched?
      'complete' === state ||
      // Is DOMContentLoaded already dispatched and waiting for it?
      ('ready' === mod.contentScriptWhen && state === 'interactive')
}

process.port.on('sdk/page-mod/create', (process, model) => {
  if (pagemods.has(model.id))
    return;

  new ChildPageMod(model);
});

process.port.on('sdk/page-mod/destroy', (process, id) => {
  let mod = pagemods.get(id);
  if (mod)
    mod.destroy();
});
