import { ProxyTarget } from '@locker/near-membrane-shared';
import { cloneDeep, isFunction } from 'lodash';

import { PluginMeta } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Monaco } from '@grafana/ui';

import { loadScriptIntoSandbox } from './code_loader';
import { forbiddenElements } from './constants';
import { SandboxEnvironment } from './types';
import { logWarning, unboxRegexesFromMembraneProxy } from './utils';

/**
 * Distortions are near-membrane mechanisms to altert JS instrics and DOM APIs.
 *
 * Everytime a plugin tries to use a js instricis (e.g. Array.concat) or a DOM API (e.g. document.createElement)
 * or access any of its attributes a distortion callback is used.
 *
 * The distortion callback has a single parameter which is usually the "native" function responsible
 * for the API, but generally speaking is the value that the plugin would normally get. Note that here by
 * "value" we mean the function the plugin would execute, not the value from executing the function.
 *
 * To compare the native code passed to the distortion callback and know if should we distorted or not we need
 * to get the object descriptors of these native functions using Object.getOwnPropertyDescriptors.
 *
 * For example:
 *
 * If the distortionCallback is asking for a distortion for the `Array.concat` function
 * one will see `ƒ concat() { [native code] }` as the parameter to the distortion callback.
 *
 * Inside the callback we could compare this with the descriptor value:
 *
 * ```
 * function distortionCallback(valueToDistort: unknown){
 *   const descriptor = Object.getOwnPropertyDescriptors(Array.prototype, 'concat')
 *   if (descriptor.value === valueToDistort) {
 *      // distorted replacement function
 *      return ArrayConcatReplacementFunction;
 *   }
 *   // original
 *   return valueToDistort;
 * }
 * ```
 *
 * To avoid the verbosity of the previous code as more and more distortions are applied it is easier to use
 * a Map. Map keys can be objects (including native functions).
 *
 * This allows to simplify the previous code:
 *
 * ```
 * function distortionCallback(valueToDistort: unknown){
 *   if (generalDistortionMap.has(valueToDistort)) {
 *      // Map does the comparison easier
 *      return generalDistortionMap.get(valueToDistort);
 *   }
 *   // original
 *   return valueToDistort;
 * }
 * ```
 *
 * The code in this file defines that generalDistortionMap.
 */

type DistortionMap = Map<
  unknown,
  (originalAttrOrMethod: unknown, pluginMeta: PluginMeta, sandboxEnv?: SandboxEnvironment) => unknown
>;
const generalDistortionMap: DistortionMap = new Map();

const monitorOnly = Boolean(config.featureToggles.frontendSandboxMonitorOnly);

const SANDBOX_LIVE_API_PATCHED = Symbol.for('@SANDBOX_LIVE_API_PATCHED');

export function getGeneralSandboxDistortionMap() {
  if (generalDistortionMap.size === 0) {
    // initialize the distortion map
    distortIframeAttributes(generalDistortionMap);
    distortConsole(generalDistortionMap);
    distortAlert(generalDistortionMap);
    distortAppend(generalDistortionMap);
    distortInsert(generalDistortionMap);
    distortInnerHTML(generalDistortionMap);
    distortCreateElement(generalDistortionMap);
    distortWorkers(generalDistortionMap);
    distortDocument(generalDistortionMap);
    distortMonacoEditor(generalDistortionMap);
    distortPostMessage(generalDistortionMap);
  }
  return generalDistortionMap;
}

function failToSet(originalAttrOrMethod: unknown, meta: PluginMeta) {
  logWarning(`Plugin ${meta.id} tried to set a sandboxed property`, {
    pluginId: meta.id,
    attrOrMethod: String(originalAttrOrMethod),
    entity: 'window',
  });
  if (monitorOnly) {
    return originalAttrOrMethod;
  }
  return () => {
    throw new Error('Plugins are not allowed to set sandboxed properties');
  };
}

// sets distortion to protect iframe elements
function distortIframeAttributes(distortions: DistortionMap) {
  const iframeHtmlForbiddenProperties = ['contentDocument', 'contentWindow', 'src', 'srcdoc', 'srcObject', 'srcset'];

  for (const property of iframeHtmlForbiddenProperties) {
    const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, property);
    if (descriptor) {
      function fail(originalAttrOrMethod: unknown, meta: PluginMeta) {
        const pluginId = meta.id;
        logWarning(`Plugin ${pluginId} tried to access iframe.${property}`, {
          pluginId,
          attrOrMethod: property,
          entity: 'iframe',
        });

        if (monitorOnly) {
          return originalAttrOrMethod;
        }

        return () => {
          throw new Error('iframe.' + property + ' is not allowed in sandboxed plugins');
        };
      }

      if (descriptor.value) {
        distortions.set(descriptor.value, fail);
      }
      if (descriptor.set) {
        distortions.set(descriptor.set, fail);
      }
      if (descriptor.get) {
        distortions.set(descriptor.get, fail);
      }
    }
  }
}

// set distortions to always prefix any usage of console
function distortConsole(distortions: DistortionMap) {
  const descriptor = Object.getOwnPropertyDescriptor(window, 'console');
  if (descriptor?.value) {
    function getSandboxConsole(originalAttrOrMethod: unknown, meta: PluginMeta) {
      const pluginId = meta.id;
      // we don't monitor the console because we expect a high volume of calls
      if (monitorOnly) {
        return originalAttrOrMethod;
      }

      function sandboxLog(...args: unknown[]) {
        console.log(`[plugin ${pluginId}]`, ...args);
      }
      return {
        log: sandboxLog,
        warn: sandboxLog,
        error: sandboxLog,
        info: sandboxLog,
        debug: sandboxLog,
        table: sandboxLog,
      };
    }

    distortions.set(descriptor.value, getSandboxConsole);
  }
  if (descriptor?.set) {
    distortions.set(descriptor.set, failToSet);
  }
}

// set distortions to alert to always output to the console
function distortAlert(distortions: DistortionMap) {
  function getAlertDistortion(originalAttrOrMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    logWarning(`Plugin ${pluginId} accessed window.alert`, {
      pluginId,
      attrOrMethod: 'alert',
      entity: 'window',
    });

    if (monitorOnly) {
      return originalAttrOrMethod;
    }

    return function (...args: unknown[]) {
      console.log(`[plugin ${pluginId}]`, ...args);
    };
  }
  const descriptor = Object.getOwnPropertyDescriptor(window, 'alert');
  if (descriptor?.value) {
    distortions.set(descriptor.value, getAlertDistortion);
  }
  if (descriptor?.set) {
    distortions.set(descriptor.set, failToSet);
  }
}

function distortInnerHTML(distortions: DistortionMap) {
  function getInnerHTMLDistortion(originalMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    return function innerHTMLDistortion(this: HTMLElement, ...args: string[]) {
      for (const arg of args) {
        const lowerCase = arg?.toLowerCase() || '';
        for (const forbiddenElement of forbiddenElements) {
          if (lowerCase.includes('<' + forbiddenElement)) {
            logWarning(`Plugin ${pluginId} tried to set ${forbiddenElement} in innerHTML`, {
              pluginId,
              attrOrMethod: 'innerHTML',
              param: forbiddenElement,
              entity: 'HTMLElement',
            });

            if (monitorOnly) {
              continue;
            }
            throw new Error('<' + forbiddenElement + '> is not allowed in sandboxed plugins');
          }
        }
      }

      if (isFunction(originalMethod)) {
        originalMethod.apply(this, args);
      }
    };
  }
  const descriptors = [
    Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML'),
    Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML'),
    Object.getOwnPropertyDescriptor(Element.prototype, 'insertAdjacentHTML'),
    Object.getOwnPropertyDescriptor(DOMParser.prototype, 'parseFromString'),
  ];

  for (const descriptor of descriptors) {
    if (descriptor?.set) {
      distortions.set(descriptor.set, getInnerHTMLDistortion);
    }
    if (descriptor?.value) {
      distortions.set(descriptor.value, getInnerHTMLDistortion);
    }
  }
}

function distortCreateElement(distortions: DistortionMap) {
  function getCreateElementDistortion(originalMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    return function createElementDistortion(this: HTMLElement, arg?: string, options?: unknown) {
      if (arg && forbiddenElements.includes(arg)) {
        logWarning(`Plugin ${pluginId} tried to create ${arg}`, {
          pluginId,
          attrOrMethod: 'createElement',
          param: arg,
          entity: 'document',
        });
        if (!monitorOnly) {
          return document.createDocumentFragment();
        }
      }
      if (isFunction(originalMethod)) {
        return originalMethod.apply(this, [arg, options]);
      }
    };
  }
  const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'createElement');
  if (descriptor?.value) {
    distortions.set(descriptor.value, getCreateElementDistortion);
  }
}

function distortInsert(distortions: DistortionMap) {
  function getInsertDistortion(originalMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    return function insertChildDistortion(this: HTMLElement, node?: Node, ref?: Node) {
      const nodeType = node?.nodeName?.toLowerCase() || '';

      if (node && forbiddenElements.includes(nodeType)) {
        logWarning(`Plugin ${pluginId} tried to insert ${nodeType}`, {
          pluginId,
          attrOrMethod: 'insertChild',
          param: nodeType,
          entity: 'HTMLElement',
        });
        if (!monitorOnly) {
          return document.createDocumentFragment();
        }
      }
      if (isFunction(originalMethod)) {
        return originalMethod.call(this, node, ref);
      }
    };
  }

  function getinsertAdjacentElementDistortion(originalMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    return function insertAdjacentElementDistortion(this: HTMLElement, position?: string, node?: Node) {
      const nodeType = node?.nodeName?.toLowerCase() || '';
      if (node && forbiddenElements.includes(nodeType)) {
        logWarning(`Plugin ${pluginId} tried to insert ${nodeType}`, {
          pluginId,
          attrOrMethod: 'insertAdjacentElement',
          param: nodeType,
          entity: 'HTMLElement',
        });

        if (!monitorOnly) {
          return document.createDocumentFragment();
        }
      }
      if (isFunction(originalMethod)) {
        return originalMethod.call(this, position, node);
      }
    };
  }

  const descriptors = [
    Object.getOwnPropertyDescriptor(Node.prototype, 'insertBefore'),
    Object.getOwnPropertyDescriptor(Node.prototype, 'replaceChild'),
  ];

  for (const descriptor of descriptors) {
    if (descriptor?.value) {
      distortions.set(descriptor.set, getInsertDistortion);
    }
  }

  const descriptorAdjacent = Object.getOwnPropertyDescriptor(Element.prototype, 'insertAdjacentElement');
  if (descriptorAdjacent?.value) {
    distortions.set(descriptorAdjacent.set, getinsertAdjacentElementDistortion);
  }
}

// set distortions to append elements to the document
function distortAppend(distortions: DistortionMap) {
  // append accepts an array of nodes to append https://developer.mozilla.org/en-US/docs/Web/API/Node/append
  function getAppendDistortion(originalMethod: unknown, meta: PluginMeta) {
    const pluginId = meta.id;
    return function appendDistortion(this: HTMLElement, ...args: Node[]) {
      let acceptedNodes = args;
      const filteredAcceptedNodes = args?.filter((node) => !forbiddenElements.includes(node.nodeName.toLowerCase()));
      if (!monitorOnly) {
        acceptedNodes = filteredAcceptedNodes;
      }

      if (acceptedNodes.length !== filteredAcceptedNodes.length) {
        logWarning(`Plugin ${pluginId} tried to append fobiddenElements`, {
          pluginId,
          attrOrMethod: 'append',
          param: args?.filter((node) => forbiddenElements.includes(node.nodeName.toLowerCase()))?.join(',') || '',
          entity: 'HTMLElement',
        });
      }

      if (isFunction(originalMethod)) {
        originalMethod.apply(this, acceptedNodes);
      }
      // https://developer.mozilla.org/en-US/docs/Web/API/Element/append#return_value
      return undefined;
    };
  }

  // appendChild accepts a single node to add https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild
  function getAppendChildDistortion(originalMethod: unknown, meta: PluginMeta, sandboxEnv?: SandboxEnvironment) {
    const pluginId = meta.id;
    return function appendChildDistortion(this: HTMLElement, arg?: Node) {
      const nodeType = arg?.nodeName?.toLowerCase() || '';
      if (arg && forbiddenElements.includes(nodeType)) {
        logWarning(`Plugin ${pluginId} tried to append ${nodeType}`, {
          pluginId,
          attrOrMethod: 'appendChild',
          param: nodeType,
          entity: 'HTMLElement',
        });

        if (!monitorOnly) {
          return document.createDocumentFragment();
        }
      }
      // if the node is a script, load it into the sandbox
      // this allows webpack chunks to be loaded into the sandbox
      // loadScriptIntoSandbox has restrictions on what scripts can be loaded
      if (sandboxEnv && arg && nodeType === 'script' && arg instanceof HTMLScriptElement) {
        loadScriptIntoSandbox(arg.src, meta, sandboxEnv)
          .then(() => {
            arg.onload?.call(arg, new Event('load'));
          })
          .catch((err) => {
            arg.onerror?.call(arg, new ErrorEvent('error', { error: err }));
          });
        return undefined;
      }
      if (isFunction(originalMethod)) {
        return originalMethod.call(this, arg);
      }
    };
  }

  const descriptors = [
    Object.getOwnPropertyDescriptor(Element.prototype, 'append'),
    Object.getOwnPropertyDescriptor(Element.prototype, 'prepend'),
    Object.getOwnPropertyDescriptor(Element.prototype, 'after'),
    Object.getOwnPropertyDescriptor(Element.prototype, 'before'),
    Object.getOwnPropertyDescriptor(Document.prototype, 'append'),
    Object.getOwnPropertyDescriptor(Document.prototype, 'prepend'),
  ];

  for (const descriptor of descriptors) {
    if (descriptor?.value) {
      distortions.set(descriptor.value, getAppendDistortion);
    }
  }

  const appendChildDescriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'appendChild');
  if (appendChildDescriptor?.value) {
    distortions.set(appendChildDescriptor.value, getAppendChildDistortion);
  }
}

// this is not a distortion for security reasons but to make plugins using web workers work correctly.
function distortWorkers(distortions: DistortionMap) {
  const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, 'postMessage');
  function getPostMessageDistortion(originalMethod: unknown) {
    return function postMessageDistortion(this: Worker, ...args: unknown[]) {
      // proxies can't be serialized by postMessage algorithm
      // the only way to pass it through is to send a cloned version
      // objects passed to postMessage should be clonable
      try {
        const newArgs: unknown[] = cloneDeep(args);
        if (isFunction(originalMethod)) {
          originalMethod.apply(this, newArgs);
        }
      } catch (e) {
        throw new Error('postMessage arguments are invalid objects');
      }
    };
  }
  if (descriptor?.value) {
    distortions.set(descriptor.value, getPostMessageDistortion);
  }
}

// this is not a distortion for security reasons but to make plugins using document.defaultView work correctly.
function distortDocument(distortions: DistortionMap) {
  const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'defaultView');
  if (descriptor?.get) {
    distortions.set(descriptor.get, () => {
      return () => {
        return window;
      };
    });
  }

  const documentForbiddenMethods = ['write'];
  for (const method of documentForbiddenMethods) {
    const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, method);
    if (descriptor?.set) {
      distortions.set(descriptor.set, failToSet);
    }
    if (descriptor?.value) {
      distortions.set(descriptor.value, failToSet);
    }
  }
}

async function distortMonacoEditor(distortions: DistortionMap) {
  // We rely on `monaco` being instanciated inside `window.monaco`.
  // this is the same object passed down to plugins using monaco editor for their editors
  // this `window.monaco` is an instance of monaco but not the same as if we
  // import `monaco-editor` directly in this file.
  // Short of abusing the `window.monaco` object we would have to modify grafana-ui to export
  // the monaco instance directly in the ReactMonacoEditor component
  const monacoEditor: Monaco = Reflect.get(window, 'monaco');

  // do not double patch
  if (!monacoEditor || Object.hasOwn(monacoEditor, SANDBOX_LIVE_API_PATCHED)) {
    return;
  }
  const originalSetMonarchTokensProvider = monacoEditor.languages.setMonarchTokensProvider;

  // NOTE: this function in particular is called only once per intialized custom language inside a plugin which is a
  // rare ocurrance but if not patched it'll break the syntax highlighting for the custom language.
  function getSetMonarchTokensProvider() {
    return function (...args: Parameters<typeof originalSetMonarchTokensProvider>) {
      if (args.length !== 2) {
        return originalSetMonarchTokensProvider.apply(monacoEditor, args);
      }
      return originalSetMonarchTokensProvider.call(
        monacoEditor,
        args[0],
        unboxRegexesFromMembraneProxy(args[1]) as (typeof args)[1]
      );
    };
  }
  distortions.set(monacoEditor.languages.setMonarchTokensProvider, getSetMonarchTokensProvider);
  Reflect.set(monacoEditor, SANDBOX_LIVE_API_PATCHED, {});
}

async function distortPostMessage(distortions: DistortionMap) {
  const descriptor = Object.getOwnPropertyDescriptor(window, 'postMessage');

  function getPostMessageDistortion(originalMethod: unknown) {
    return function postMessageDistortion(this: Window, ...args: unknown[]) {
      // proxies can't be serialized by postMessage algorithm
      // the only way to pass it through is to send a cloned version
      // objects passed to postMessage should be clonable
      try {
        const newArgs: unknown[] = cloneDeep(args);
        if (isFunction(originalMethod)) {
          originalMethod.apply(this, newArgs);
        }
      } catch (e) {
        throw new Error('postMessage arguments are invalid objects');
      }
    };
  }

  if (descriptor?.value) {
    distortions.set(descriptor.value, getPostMessageDistortion);
  }
}

/**
 * "Live" APIs are APIs that can only be distorted at runtime.
 * This could be because the objects we want to patch only become available after specific states are reached,
 * or because the libraries we want to patch are lazy-loaded and we don't have access to their definitions.
 * We put here only distortions that can't be static because they are dynamicly loaded
 */
export function distortLiveApis(originalValue: ProxyTarget): ProxyTarget | undefined {
  distortMonacoEditor(generalDistortionMap);

  // This distorts the `history.replace` function in react-router-dom.
  // constructed for each browser history and is only accessible within the react context.
  // Note that this distortion does not affect `String.prototype.replace` calls.
  // because they don't go through distortions
  if (
    originalValue instanceof Function &&
    originalValue.name === 'replace' &&
    originalValue.prototype.constructor.length === 2
  ) {
    return function replace(this: unknown, ...args: unknown[]) {
      // validate history.replace signature further
      if (args && args[0] && typeof args[0] === 'string' && args[1] && !(args[1] instanceof Function)) {
        const newArgs = cloneDeep(args);
        return Reflect.apply(originalValue, this, newArgs);
      }
      return Reflect.apply(originalValue, this, args);
    };
  }
  return;
}
