/* global API msg */// msg.js
/* global StyleInjector */
/* global prefs */
'use strict';

(() => {
  if (window.INJECTED === 1) return;

  /** true -> when the page styles are received,
   * false -> when disableAll mode is on at start, the styles won't be sent
   * so while disableAll lasts we can ignore messages about style updates because
   * the tab will explicitly ask for all styles in bulk when disableAll mode ends */
  let hasStyles = false;
  let isDisabled = false;
  let isTab = !chrome.tabs || location.pathname !== '/popup.html';
  const order = {main: [], prio: []};
  const calcOrder = ({id}) =>
    (order.prio[id] || 0) * 1e6 ||
    order.main[id] ||
    id + .5e6; // no order = at the end of `main`
  const isFrame = window !== parent;
  const isFrameAboutBlank = isFrame && location.href === 'about:blank';
  const isUnstylable = !chrome.app && document instanceof XMLDocument;
  const styleInjector = StyleInjector({
    compare: (a, b) => calcOrder(a) - calcOrder(b),
    onUpdate: onInjectorUpdate,
  });
  // dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited)
  let matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href.split('#')[0]) ||
    location.href;

  // save it now because chrome.runtime will be unavailable in the orphaned script
  const orphanEventId = chrome.runtime.id;
  let isOrphaned;
  // firefox doesn't orphanize content scripts so the old elements stay
  if (!chrome.app) styleInjector.clearOrphans();

  /** @type chrome.runtime.Port */
  let port;
  let lazyBadge = isFrame;
  let parentDomain;

  /* about:blank iframes are often used by sites for file upload or background tasks
   * and they may break if unexpected DOM stuff is present at `load` event
   * so we'll add the styles only if the iframe becomes visible */
  const xoEventId = `${Math.random()}`;
  /** @type IntersectionObserver */
  let xo;
  window[Symbol.for('xo')] = (el, cb) => {
    if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'});
    el.addEventListener(xoEventId, cb, {once: true});
    xo.observe(el);
  };

  // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
  const ready = init();

  // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
  if (!isTab) {
    chrome.tabs.getCurrent(tab => {
      isTab = Boolean(tab);
      if (tab && styleInjector.list.length) updateCount();
    });
  }

  msg.onTab(applyOnMessage);

  if (!chrome.tabs) {
    window.dispatchEvent(new CustomEvent(orphanEventId));
    window.addEventListener(orphanEventId, orphanCheck, true);
  }

  // detect media change in content script
  // FIXME: move this to background page when following bugs are fixed:
  // https://bugzilla.mozilla.org/show_bug.cgi?id=1561546
  // https://bugs.chromium.org/p/chromium/issues/detail?id=968651
  const media = window.matchMedia('(prefers-color-scheme: dark)');
  media.addListener(() => API.colorScheme.updateSystemPreferDark().catch(console.error));

  function onInjectorUpdate() {
    if (!isOrphaned) {
      updateCount();
      const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
      onOff('disableAll', updateDisableAll);
      if (isFrame) {
        updateExposeIframes();
        onOff('exposeIframes', updateExposeIframes);
      }
    }
  }

  async function init() {
    if (isUnstylable) {
      await API.styleViaAPI({method: 'styleApply'});
    } else {
      const SYM_ID = 'styles';
      const SYM = Symbol.for(SYM_ID);
      const parentStyles = isFrameAboutBlank &&
        tryCatch(() => parent[parent.Symbol.for(SYM_ID)]);
      const styles =
        window[SYM] ||
        parentStyles && await new Promise(onFrameElementInView) && parentStyles ||
        !isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) ||
        await API.styles.getSectionsByUrl(matchUrl, null, true);
      if (styles.cfg) {
        isDisabled = styles.cfg.disableAll;
        Object.assign(order, styles.cfg.order);
      }
      hasStyles = !isDisabled;
      if (hasStyles) {
        window[SYM] = styles;
        await styleInjector.apply(styles);
      } else {
        delete window[SYM];
        prefs.subscribe('disableAll', updateDisableAll);
      }
      styleInjector.toggle(hasStyles);
    }
  }

  /** Must be executed inside try/catch */
  function getStylesViaXhr() {
    const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
    const url = 'blob:' + chrome.runtime.getURL(blobId);
    document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, false); // synchronous
    xhr.send();
    URL.revokeObjectURL(url);
    return JSON.parse(xhr.response);
  }

  function applyOnMessage(request) {
    const {method} = request;
    if (isUnstylable) {
      if (method === 'urlChanged') {
        request.method = 'styleReplaceAll';
      }
      if (/^(style|updateCount)/.test(method)) {
        API.styleViaAPI(request);
        return;
      }
    }

    const {style} = request;
    switch (method) {
      case 'ping':
        return true;

      case 'styleDeleted':
        styleInjector.remove(style.id);
        break;

      case 'styleUpdated':
        if (!hasStyles && isDisabled) break;
        if (style.enabled) {
          API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
            sections[style.id]
              ? styleInjector.apply(sections)
              : styleInjector.remove(style.id));
        } else {
          styleInjector.remove(style.id);
        }
        break;

      case 'styleAdded':
        if (!hasStyles && isDisabled) break;
        if (style.enabled) {
          API.styles.getSectionsByUrl(matchUrl, style.id)
            .then(styleInjector.apply);
        }
        break;

      case 'styleSort':
        Object.assign(order, request.order);
        styleInjector.sort();
        break;

      case 'urlChanged':
        if (!hasStyles && isDisabled || matchUrl === request.url) break;
        matchUrl = request.url;
        API.styles.getSectionsByUrl(matchUrl).then(sections => {
          hasStyles = true;
          styleInjector.replace(sections);
        });
        break;

      case 'backgroundReady':
        ready.catch(err =>
          msg.isIgnorableError(err)
            ? init()
            : console.error(err));
        break;

      case 'updateCount':
        updateCount();
        break;
    }
  }

  function updateDisableAll(key, disableAll) {
    isDisabled = disableAll;
    if (isUnstylable) {
      API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
    } else if (!hasStyles && !disableAll) {
      init();
    } else {
      styleInjector.toggle(!disableAll);
    }
  }

  async function updateExposeIframes(key, value = prefs.get('exposeIframes')) {
    const attr = 'stylus-iframe';
    const el = document.documentElement;
    if (!el) return; // got no styles so styleInjector didn't wait for <html>
    if (!value || !styleInjector.list.length) {
      el.removeAttribute(attr);
    } else {
      if (!parentDomain) parentDomain = await API.getTabUrlPrefix();
      // Check first to avoid triggering DOM mutation
      if (el.getAttribute(attr) !== parentDomain) {
        el.setAttribute(attr, parentDomain);
      }
    }
  }

  function updateCount() {
    if (!isTab) return;
    if (isFrame) {
      if (!port && styleInjector.list.length) {
        port = chrome.runtime.connect({name: 'iframe'});
      } else if (port && !styleInjector.list.length) {
        port.disconnect();
      }
      if (lazyBadge && performance.now() > 1000) lazyBadge = false;
    }
    (isUnstylable ?
      API.styleViaAPI({method: 'updateCount'}) :
      API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
    ).catch(msg.ignoreError);
  }

  function onFrameElementInView(cb) {
    parent[parent.Symbol.for('xo')](frameElement, cb);
  }

  /** @param {IntersectionObserverEntry[]} entries */
  function onIntersect(entries) {
    for (const e of entries) {
      if (e.isIntersecting) {
        xo.unobserve(e.target);
        e.target.dispatchEvent(new Event(xoEventId));
      }
    }
  }

  function tryCatch(func, ...args) {
    try {
      return func(...args);
    } catch (e) {}
  }

  function orphanCheck() {
    if (tryCatch(() => chrome.i18n.getUILanguage())) return;
    // In Chrome content script is orphaned on an extension update/reload
    // so we need to detach event listeners
    window.removeEventListener(orphanEventId, orphanCheck, true);
    isOrphaned = true;
    setTimeout(styleInjector.clear, 1000); // avoiding FOUC
    tryCatch(msg.off, applyOnMessage);
  }
})();