/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */
'use strict';

async function insertNodesAndExecuteScripts(
  source: Document | Element,
  target: Node,
  CSPnonce: string | null,
) {
  const ownerDocument = target.ownerDocument || target;

  // We need to remove the script content for any scripts that would not run based on CSP
  // We restore the script content after moving the nodes into the target
  const badNonceScriptNodes: Map<Element, string> = new Map();
  if (CSPnonce) {
    const scripts = source.querySelectorAll('script');
    for (let i = 0; i < scripts.length; i++) {
      const script = scripts[i];
      if (
        !script.hasAttribute('src') &&
        script.getAttribute('nonce') !== CSPnonce
      ) {
        badNonceScriptNodes.set(script, script.textContent);
        script.textContent = '';
      }
    }
  }
  let lastChild = null;
  while (source.firstChild) {
    const node = source.firstChild;
    if (lastChild === node) {
      throw new Error('Infinite loop.');
    }
    lastChild = node;

    if (node.nodeType === 1) {
      const element: Element = (node: any);
      if (
        // $FlowFixMe[prop-missing]
        element.dataset != null &&
        (element.dataset.rxi != null ||
          element.dataset.rri != null ||
          element.dataset.rci != null ||
          element.dataset.rsi != null)
      ) {
        // Fizz external runtime instructions are expected to be in the body.
        // When we have renderIntoContainer and renderDocument this will be
        // more enforceable. At the moment you can misconfigure your stream and end up
        // with instructions that are deep in the document
        (ownerDocument.body: any).appendChild(element);
      } else {
        target.appendChild(element);

        if (element.nodeName === 'SCRIPT') {
          await executeScript(element);
        } else {
          const scripts = element.querySelectorAll('script');
          for (let i = 0; i < scripts.length; i++) {
            const script = scripts[i];
            await executeScript(script);
          }
        }
      }
    } else {
      target.appendChild(node);
    }
  }

  // restore the textContent now that we have finished attempting to execute scripts
  badNonceScriptNodes.forEach((scriptContent, script) => {
    script.textContent = scriptContent;
  });
}

async function executeScript(script: Element) {
  const ownerDocument = script.ownerDocument;
  if (script.parentNode == null) {
    throw new Error(
      'executeScript expects to be called on script nodes that are currently in a document',
    );
  }
  const parent = script.parentNode;
  const scriptSrc = script.getAttribute('src');
  if (scriptSrc) {
    if (document !== ownerDocument) {
      throw new Error(
        'You must set the current document to the global document to use script src in tests',
      );
    }

    try {
      // $FlowFixMe
      require(scriptSrc);
    } catch (x) {
      const event = new window.ErrorEvent('error', {error: x});
      window.dispatchEvent(event);
    }
  } else {
    const newScript = ownerDocument.createElement('script');
    newScript.textContent = script.textContent;
    // make sure to add nonce back to script if it exists
    for (let i = 0; i < script.attributes.length; i++) {
      const attribute = script.attributes[i];
      newScript.setAttribute(attribute.name, attribute.value);
    }

    parent.insertBefore(newScript, script);
    parent.removeChild(script);
  }
}

function mergeOptions(options: Object, defaultOptions: Object): Object {
  return {
    ...defaultOptions,
    ...options,
  };
}

function stripExternalRuntimeInNodes(
  nodes: HTMLElement[] | HTMLCollection<HTMLElement>,
  externalRuntimeSrc: string | null,
): HTMLElement[] {
  if (!Array.isArray(nodes)) {
    nodes = Array.from(nodes);
  }
  if (externalRuntimeSrc == null) {
    return nodes;
  }
  return nodes.filter(
    n =>
      (n.tagName !== 'SCRIPT' && n.tagName !== 'script') ||
      n.getAttribute('src') !== externalRuntimeSrc,
  );
}

// Since JSDOM doesn't implement a streaming HTML parser, we manually overwrite
// readyState here (currently read by ReactDOMServerExternalRuntime). This does
// not trigger event callbacks, but we do not rely on any right now.
async function withLoadingReadyState<T>(
  fn: () => T,
  document: Document,
): Promise<T> {
  // JSDOM implements readyState in document's direct prototype, but this may
  // change in later versions
  let prevDescriptor = null;
  let proto: Object = document;
  while (proto != null) {
    prevDescriptor = Object.getOwnPropertyDescriptor(proto, 'readyState');
    if (prevDescriptor != null) {
      break;
    }
    proto = Object.getPrototypeOf(proto);
  }
  Object.defineProperty(document, 'readyState', {
    get() {
      return 'loading';
    },
    configurable: true,
  });
  const result = await fn();
  // $FlowFixMe[incompatible-type]
  delete document.readyState;
  if (prevDescriptor) {
    Object.defineProperty(proto, 'readyState', prevDescriptor);
  }
  return result;
}

function getVisibleChildren(element: Element): React$Node {
  const children = [];
  let node: any = element.firstChild;
  while (node) {
    if (node.nodeType === 1) {
      if (
        ((node.tagName !== 'SCRIPT' && node.tagName !== 'script') ||
          node.hasAttribute('data-meaningful')) &&
        node.tagName !== 'TEMPLATE' &&
        node.tagName !== 'template' &&
        !node.hasAttribute('hidden') &&
        !node.hasAttribute('aria-hidden')
      ) {
        const props: any = {};
        const attributes = node.attributes;
        for (let i = 0; i < attributes.length; i++) {
          if (
            attributes[i].name === 'id' &&
            attributes[i].value.includes(':')
          ) {
            // We assume this is a React added ID that's a non-visual implementation detail.
            continue;
          }
          props[attributes[i].name] = attributes[i].value;
        }
        props.children = getVisibleChildren(node);
        children.push(
          require('react').createElement(node.tagName.toLowerCase(), props),
        );
      }
    } else if (node.nodeType === 3) {
      children.push(node.data);
    }
    node = node.nextSibling;
  }
  return children.length === 0
    ? undefined
    : children.length === 1
      ? children[0]
      : children;
}

export {
  insertNodesAndExecuteScripts,
  mergeOptions,
  stripExternalRuntimeInNodes,
  withLoadingReadyState,
  getVisibleChildren,
};
