const { JSDOM, VirtualConsole } = require('jsdom');
const virtualConsole = new VirtualConsole();
const domspace = require('domspace');
const htmlMinifier = require('html-minifier');
const errors = require('../errors');
const Text = require('./Text');
const mozilla = require('source-map-js');

function extractEncodingFromText(text) {
  let metaCharset;
  for (const metaTagString of text.match(/<meta[^>]+>/gi) || []) {
    if (/\bhttp-equiv=(["']|)\s*Content-Type\s*\1/i.test(metaTagString)) {
      const matchContent = metaTagString.match(
        /\bcontent=(["']|)\s*text\/html;\s*charset=([\w-]*)\s*\1/i
      );
      if (matchContent) {
        metaCharset = matchContent[2];
      }
    } else {
      const matchSimpleCharset = metaTagString.match(
        /\bcharset=(["']|)\s*([\w-]*)\s*\1/i
      );
      if (matchSimpleCharset) {
        metaCharset = matchSimpleCharset[2];
      }
    }
  }
  return metaCharset; // Will be undefined if not found
}

class Html extends Text {
  init(config = {}) {
    super.init(config);
    if (typeof config.isFragment !== 'undefined') {
      this._isFragment = config.isFragment;
      config.isFragment = undefined;
    }
  }

  get baseUrl() {
    let baseUrl = super.baseUrl;
    // Memoize the href of <base> (null if none)
    if (typeof this._baseElementHref === 'undefined') {
      const baseElement = this.parseTree.querySelector('base');
      this._baseElementHref = (baseElement && baseElement.href) || null;
    }
    if (this._baseElementHref) {
      baseUrl = this.assetGraph.resolveUrl(baseUrl, this._baseElementHref);
    }
    return baseUrl;
  }

  get encoding() {
    if (!this._encoding) {
      // An explicit encoding (Content-Type header, data: url charset, assetConfig) takes precedence, but if absent we should
      // look for a <meta http-equiv='Content-Type' ...> tag with a charset before falling back to the defaultEncoding (utf-8)
      if (typeof this._text === 'string') {
        this._encoding =
          extractEncodingFromText(this._text) || this.defaultEncoding;
      } else if (this._rawSrc) {
        this._encoding =
          extractEncodingFromText(
            this._rawSrc.toString(
              'binary',
              0,
              Math.min(1024, this._rawSrc.length)
            )
          ) || this.defaultEncoding;
      } else {
        this._encoding = this.defaultEncoding;
      }
    }
    return this._encoding;
  }

  set encoding(encoding) {
    // An intended side effect of getting this.parseTree before deleting this._rawSrc is that we're sure
    // that the raw source has been decoded into this._text before the original encoding is thrown away.
    const parseTree = this.parseTree;
    if (parseTree.head) {
      const existingMetaElements = parseTree.head.getElementsByTagName('meta');
      let contentTypeMetaElement;

      for (const metaElement of existingMetaElements) {
        if (
          metaElement.hasAttribute('charset') ||
          /^content-type$/i.test(metaElement.getAttribute('http-equiv'))
        ) {
          contentTypeMetaElement = metaElement;
          break;
        }
      }

      if (!contentTypeMetaElement) {
        contentTypeMetaElement = parseTree.createElement('meta');
        parseTree.head.insertBefore(
          contentTypeMetaElement,
          parseTree.head.firstChild
        );
        this.markDirty();
      }
      if (contentTypeMetaElement.hasAttribute('http-equiv')) {
        if (
          (
            contentTypeMetaElement.getAttribute('content') || ''
          ).toLowerCase() !== `text/html; charset=${encoding}`
        ) {
          contentTypeMetaElement.setAttribute(
            'content',
            `text/html; charset=${encoding}`
          );
          this.markDirty();
        }
      } else {
        // Simple <meta charset="...">
        if (contentTypeMetaElement.getAttribute('charset') !== encoding) {
          contentTypeMetaElement.setAttribute('charset', encoding);
          this.markDirty();
        }
      }
    }
    if (encoding !== this.encoding) {
      this._encoding = encoding;
      this.markDirty();
    }
  }

  unload() {
    super.unload();
    this._templateReplacements = {};
    if (this._jsdom) {
      this._jsdom.window.close();
      this._jsdom = undefined;
    }
  }

  get text() {
    if (typeof this._text !== 'string') {
      if (this._parseTree) {
        this._text = this.isFragment
          ? this._parseTree.innerHTML
          : this._jsdom.serialize();
        const templateReplacements = this._templateReplacements;
        this._text = Object.keys(templateReplacements).reduce(
          (text, key) => text.replace(key, templateReplacements[key]),
          this._text
        );
      } else {
        this._text = this._getTextFromRawSrc();
      }
      if (this._toBeMinified) {
        const minifierOptions = {
          // https://github.com/kangax/html-minifier#options-quick-reference
          maxLineLength: Infinity,
          collapseWhitespace: true,
          collapseBooleanAttributes: true,
          removeEmptyAttributes: true,
          removeAttributeQuotes: true,
          // useShortDoctype: true, // Replaces any DOCTYPE with the HTML5 one.
          // removeOptionalTags: true, // Omits </head>, </body>, </html> etc. Too obtrusive?
          removeComments: true,
          removeScriptTypeAttributes: true,
          removeStyleLinkTypeAttributes: true,
          ignoreCustomComments: [
            /^\s*\/?(?:ko|hz)(?:\s|$)/,
            /^ASSETGRAPH DOCUMENT (?:START|END) MARKER$|^#|^\[if|^<!\[endif\]|^esi/,
          ],
          ...this.htmlMinifierOptions,
        };

        this._text = htmlMinifier.minify(this._text, minifierOptions);
      }
    }
    return this._text;
  }

  set text(text) {
    this.unload();

    this._text = text;
    if (this.assetGraph) {
      this.populate();
    }
    this.markDirty();
  }

  get parseTree() {
    if (!this._parseTree) {
      let text;
      if (typeof this._text === 'string') {
        text = this._text;
      } else {
        text = this._getTextFromRawSrc();
      }
      const templateReplacements = (this._templateReplacements = {});
      text = text.replace(/<([%?])[^\1]*?\1>/g, (match, sub1, offset) => {
        const key = `⋖${offset}⋗`;
        templateReplacements[key] = match;
        return key;
      });

      const isFragment = this.isFragment;
      let document;
      try {
        const jsdom = new JSDOM(isFragment ? `<body>${text}</body>` : text, {
          virtualConsole,
          includeNodeLocations: true,
          runScripts: 'outside-only',
        });
        document = jsdom.window.document;
        this._jsdom = jsdom;
      } catch (e) {
        const err = new errors.ParseError({
          message: `Parse error in ${this.urlOrDescription}\n${e.message}`,
          asset: this,
        });
        if (this.assetGraph) {
          this.assetGraph.warn(err);
        } else {
          throw err;
        }
      }
      if (isFragment) {
        // Install the properties of document on the HtmlBodyElement used as the parse tree:
        for (const propertyName in document) {
          if (!(propertyName in document.body) && propertyName !== 'head') {
            if (typeof document[propertyName] === 'function') {
              document.body[propertyName] =
                document[propertyName].bind(document);
            } else {
              document.body[propertyName] = document[propertyName];
            }
          }
        }
        this._parseTree = document.body;
      } else {
        this._parseTree = document;
      }
    }
    return this._parseTree;
  }

  set parseTree(parseTree) {
    this.unload();
    this._parseTree = parseTree;
    this._lastKnownByteLength = undefined;
    if (this.assetGraph) {
      this.populate();
    }
    this.markDirty();
  }

  get isFragment() {
    if (typeof this._isFragment === 'undefined' && this.isLoaded) {
      if (this._parseTree) {
        const document = this.parseTree;
        this._isFragment =
          !document.doctype &&
          !document.body &&
          document.getElementsByTagName('head').length === 0;
      } else {
        this._isFragment = !/<!doctype|<html|<head|<body/i.test(this.text);
      }
    }
    return this._isFragment;
  }

  set isFragment(isFragment) {
    this._isFragment = isFragment;
  }

  _createSourceMapForInlineScriptOrStylesheet(element) {
    const nonInlineAncestor = this.nonInlineAncestor;
    const sourceUrl =
      this.sourceUrl || (nonInlineAncestor ? nonInlineAncestor.url : this.url);
    let location;
    if (element.firstChild) {
      location = this._jsdom.nodeLocation(element.firstChild);
    } else {
      // Empty script or stylesheet
      location = this._jsdom.nodeLocation(element).endTag;
    }
    const sourceMapGenerator = new mozilla.SourceMapGenerator({
      file: sourceUrl,
    });
    const text = element.firstChild ? element.firstChild.nodeValue : '';
    let generatedLineNumber = 1;
    let generatedColumnNumber = 0;
    let previousChar;
    let originalLineNumber = location.startLine;
    let originalColumnNumber = location.startCol;
    let hasAddedMappingForTheCurrentLine = false;
    function addMapping() {
      sourceMapGenerator.addMapping({
        generated: {
          line: generatedLineNumber,
          column: generatedColumnNumber,
        },
        original: {
          line: originalLineNumber,
          column: originalColumnNumber,
        },
        source: sourceUrl,
      });
    }
    addMapping();
    for (let i = 0; i < text.length; i += 1) {
      const ch = text.charAt(i);
      if (ch === '\n') {
        if (previousChar !== '\r') {
          originalLineNumber += 1;
          generatedLineNumber += 1;
          generatedColumnNumber = 0;
          originalColumnNumber = 0;
          hasAddedMappingForTheCurrentLine = false;
        }
      } else if (ch === '\r') {
        if (previousChar !== '\n') {
          originalLineNumber += 1;
          generatedLineNumber += 1;
          generatedColumnNumber = 0;
          originalColumnNumber = 0;
          hasAddedMappingForTheCurrentLine = false;
        }
      } else {
        if (!hasAddedMappingForTheCurrentLine && !/\s/.test(ch)) {
          addMapping();
        }
        originalColumnNumber += 1;
        generatedColumnNumber += 1;
      }
      previousChar = ch;
    }
    addMapping();
    return sourceMapGenerator.toJSON();
  }

  *_visitAllNodesInDom(node = this.parseTree) {
    const stopTraversal = yield node;
    if (!stopTraversal && node.childNodes) {
      for (const childNode of Array.from(node.childNodes)) {
        yield* this._visitAllNodesInDom(childNode);
      }
    }
  }

  findOutgoingRelationsInParseTree() {
    const outgoingRelations = super.findOutgoingRelationsInParseTree();
    if (!this.isLoaded) {
      return outgoingRelations;
    }
    const nodeGenerator = this._visitAllNodesInDom();
    let stopTraversal = false;
    while (true) {
      const { done, value: node } = nodeGenerator.next(stopTraversal);
      if (done) {
        break;
      }
      stopTraversal = false;
      for (const Relation of Html.relations) {
        if (Relation.getRelationsFromNode) {
          let relations = Relation.getRelationsFromNode(node, this);
          if (Relation.stopTraversal && Relation.stopTraversal(node, this)) {
            stopTraversal = true;
          }
          if (relations) {
            if (!Array.isArray(relations)) {
              relations = [relations];
            }
            for (const relation of relations) {
              relation.type = relation.type || Relation.name;
            }
            outgoingRelations.push(...relations);
          }
        }
      }
    }
    return outgoingRelations;
  }

  minify() {
    this._toBeMinified = true;
    // eslint-disable-next-line no-unused-expressions
    this.parseTree; // Side effect: Make sure that reserialize when .text or .rawSrc are accessed
    this.markDirty();
    return this;
  }

  prettyPrint() {
    this._toBeMinified = false;
    domspace(this.parseTree);
    this.markDirty();
    return this;
  }
}

Object.assign(Html.prototype, {
  contentType: 'text/html',

  supportedExtensions: [
    '.html',
    '.template',
    '.xhtml',
    '.shtml',
    '.ko',
    '.ejs',
  ],

  isPretty: false,
});

module.exports = Html;
