/*
 * Copyright (c) 2016 SoapBox Innovations Inc.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
*/

'use strict';

;(function (window, linkify) {
  var linkifyHtml = function (linkify) {
    'use strict';

    var HTML5NamedCharRefs = {};

    function EntityParser(named) {
      this.named = named;
    }

    var HEXCHARCODE = /^#[xX]([A-Fa-f0-9]+)$/;
    var CHARCODE = /^#([0-9]+)$/;
    var NAMED = /^([A-Za-z0-9]+)$/;

    EntityParser.prototype.parse = function (entity) {
      if (!entity) {
        return;
      }
      var matches = entity.match(HEXCHARCODE);
      if (matches) {
        return '&#x' + matches[1] + ';';
      }
      matches = entity.match(CHARCODE);
      if (matches) {
        return '&#' + matches[1] + ';';
      }
      matches = entity.match(NAMED);
      if (matches) {
        return '&' + matches[1] + ';';
      }
    };

    var WSP = /[\t\n\f ]/;
    var ALPHA = /[A-Za-z]/;
    var CRLF = /\r\n?/g;

    function isSpace(char) {
      return WSP.test(char);
    }

    function isAlpha(char) {
      return ALPHA.test(char);
    }

    function preprocessInput(input) {
      return input.replace(CRLF, "\n");
    }

    function EventedTokenizer(delegate, entityParser) {
      this.delegate = delegate;
      this.entityParser = entityParser;

      this.state = null;
      this.input = null;

      this.index = -1;
      this.line = -1;
      this.column = -1;
      this.tagLine = -1;
      this.tagColumn = -1;

      this.reset();
    }

    EventedTokenizer.prototype = {
      reset: function reset() {
        this.state = 'beforeData';
        this.input = '';

        this.index = 0;
        this.line = 1;
        this.column = 0;

        this.tagLine = -1;
        this.tagColumn = -1;

        this.delegate.reset();
      },

      tokenize: function tokenize(input) {
        this.reset();
        this.tokenizePart(input);
        this.tokenizeEOF();
      },

      tokenizePart: function tokenizePart(input) {
        this.input += preprocessInput(input);

        while (this.index < this.input.length) {
          this.states[this.state].call(this);
        }
      },

      tokenizeEOF: function tokenizeEOF() {
        this.flushData();
      },

      flushData: function flushData() {
        if (this.state === 'data') {
          this.delegate.finishData();
          this.state = 'beforeData';
        }
      },

      peek: function peek() {
        return this.input.charAt(this.index);
      },

      consume: function consume() {
        var char = this.peek();

        this.index++;

        if (char === "\n") {
          this.line++;
          this.column = 0;
        } else {
          this.column++;
        }

        return char;
      },

      consumeCharRef: function consumeCharRef() {
        var endIndex = this.input.indexOf(';', this.index);
        if (endIndex === -1) {
          return;
        }
        var entity = this.input.slice(this.index, endIndex);
        var chars = this.entityParser.parse(entity);
        if (chars) {
          this.index = endIndex + 1;
          return chars;
        }
      },

      markTagStart: function markTagStart() {
        this.tagLine = this.line;
        this.tagColumn = this.column;
      },

      states: {
        beforeData: function beforeData() {
          var char = this.peek();

          if (char === "<") {
            this.state = 'tagOpen';
            this.markTagStart();
            this.consume();
          } else {
            this.state = 'data';
            this.delegate.beginData();
          }
        },

        data: function data() {
          var char = this.peek();

          if (char === "<") {
            this.delegate.finishData();
            this.state = 'tagOpen';
            this.markTagStart();
            this.consume();
          } else if (char === "&") {
            this.consume();
            this.delegate.appendToData(this.consumeCharRef() || "&");
          } else {
            this.consume();
            this.delegate.appendToData(char);
          }
        },

        tagOpen: function tagOpen() {
          var char = this.consume();

          if (char === "!") {
            this.state = 'markupDeclaration';
          } else if (char === "/") {
            this.state = 'endTagOpen';
          } else if (isAlpha(char)) {
            this.state = 'tagName';
            this.delegate.beginStartTag();
            this.delegate.appendToTagName(char.toLowerCase());
          }
        },

        markupDeclaration: function markupDeclaration() {
          var char = this.consume();

          if (char === "-" && this.input.charAt(this.index) === "-") {
            this.index++;
            this.state = 'commentStart';
            this.delegate.beginComment();
          }
        },

        commentStart: function commentStart() {
          var char = this.consume();

          if (char === "-") {
            this.state = 'commentStartDash';
          } else if (char === ">") {
            this.delegate.finishComment();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToCommentData(char);
            this.state = 'comment';
          }
        },

        commentStartDash: function commentStartDash() {
          var char = this.consume();

          if (char === "-") {
            this.state = 'commentEnd';
          } else if (char === ">") {
            this.delegate.finishComment();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToCommentData("-");
            this.state = 'comment';
          }
        },

        comment: function comment() {
          var char = this.consume();

          if (char === "-") {
            this.state = 'commentEndDash';
          } else {
            this.delegate.appendToCommentData(char);
          }
        },

        commentEndDash: function commentEndDash() {
          var char = this.consume();

          if (char === "-") {
            this.state = 'commentEnd';
          } else {
            this.delegate.appendToCommentData("-" + char);
            this.state = 'comment';
          }
        },

        commentEnd: function commentEnd() {
          var char = this.consume();

          if (char === ">") {
            this.delegate.finishComment();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToCommentData("--" + char);
            this.state = 'comment';
          }
        },

        tagName: function tagName() {
          var char = this.consume();

          if (isSpace(char)) {
            this.state = 'beforeAttributeName';
          } else if (char === "/") {
            this.state = 'selfClosingStartTag';
          } else if (char === ">") {
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToTagName(char);
          }
        },

        beforeAttributeName: function beforeAttributeName() {
          var char = this.consume();

          if (isSpace(char)) {
            return;
          } else if (char === "/") {
            this.state = 'selfClosingStartTag';
          } else if (char === ">") {
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.state = 'attributeName';
            this.delegate.beginAttribute();
            this.delegate.appendToAttributeName(char);
          }
        },

        attributeName: function attributeName() {
          var char = this.consume();

          if (isSpace(char)) {
            this.state = 'afterAttributeName';
          } else if (char === "/") {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.state = 'selfClosingStartTag';
          } else if (char === "=") {
            this.state = 'beforeAttributeValue';
          } else if (char === ">") {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToAttributeName(char);
          }
        },

        afterAttributeName: function afterAttributeName() {
          var char = this.consume();

          if (isSpace(char)) {
            return;
          } else if (char === "/") {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.state = 'selfClosingStartTag';
          } else if (char === "=") {
            this.state = 'beforeAttributeValue';
          } else if (char === ">") {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.state = 'attributeName';
            this.delegate.beginAttribute();
            this.delegate.appendToAttributeName(char);
          }
        },

        beforeAttributeValue: function beforeAttributeValue() {
          var char = this.consume();

          if (isSpace(char)) {} else if (char === '"') {
            this.state = 'attributeValueDoubleQuoted';
            this.delegate.beginAttributeValue(true);
          } else if (char === "'") {
            this.state = 'attributeValueSingleQuoted';
            this.delegate.beginAttributeValue(true);
          } else if (char === ">") {
            this.delegate.beginAttributeValue(false);
            this.delegate.finishAttributeValue();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.state = 'attributeValueUnquoted';
            this.delegate.beginAttributeValue(false);
            this.delegate.appendToAttributeValue(char);
          }
        },

        attributeValueDoubleQuoted: function attributeValueDoubleQuoted() {
          var char = this.consume();

          if (char === '"') {
            this.delegate.finishAttributeValue();
            this.state = 'afterAttributeValueQuoted';
          } else if (char === "&") {
            this.delegate.appendToAttributeValue(this.consumeCharRef('"') || "&");
          } else {
            this.delegate.appendToAttributeValue(char);
          }
        },

        attributeValueSingleQuoted: function attributeValueSingleQuoted() {
          var char = this.consume();

          if (char === "'") {
            this.delegate.finishAttributeValue();
            this.state = 'afterAttributeValueQuoted';
          } else if (char === "&") {
            this.delegate.appendToAttributeValue(this.consumeCharRef("'") || "&");
          } else {
            this.delegate.appendToAttributeValue(char);
          }
        },

        attributeValueUnquoted: function attributeValueUnquoted() {
          var char = this.consume();

          if (isSpace(char)) {
            this.delegate.finishAttributeValue();
            this.state = 'beforeAttributeName';
          } else if (char === "&") {
            this.delegate.appendToAttributeValue(this.consumeCharRef(">") || "&");
          } else if (char === ">") {
            this.delegate.finishAttributeValue();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.delegate.appendToAttributeValue(char);
          }
        },

        afterAttributeValueQuoted: function afterAttributeValueQuoted() {
          var char = this.peek();

          if (isSpace(char)) {
            this.consume();
            this.state = 'beforeAttributeName';
          } else if (char === "/") {
            this.consume();
            this.state = 'selfClosingStartTag';
          } else if (char === ">") {
            this.consume();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.state = 'beforeAttributeName';
          }
        },

        selfClosingStartTag: function selfClosingStartTag() {
          var char = this.peek();

          if (char === ">") {
            this.consume();
            this.delegate.markTagAsSelfClosing();
            this.delegate.finishTag();
            this.state = 'beforeData';
          } else {
            this.state = 'beforeAttributeName';
          }
        },

        endTagOpen: function endTagOpen() {
          var char = this.consume();

          if (isAlpha(char)) {
            this.state = 'tagName';
            this.delegate.beginEndTag();
            this.delegate.appendToTagName(char.toLowerCase());
          }
        }
      }
    };

    function Tokenizer(entityParser, options) {
      this.token = null;
      this.startLine = 1;
      this.startColumn = 0;
      this.options = options || {};
      this.tokenizer = new EventedTokenizer(this, entityParser);
    }

    Tokenizer.prototype = {
      tokenize: function tokenize(input) {
        this.tokens = [];
        this.tokenizer.tokenize(input);
        return this.tokens;
      },

      tokenizePart: function tokenizePart(input) {
        this.tokens = [];
        this.tokenizer.tokenizePart(input);
        return this.tokens;
      },

      tokenizeEOF: function tokenizeEOF() {
        this.tokens = [];
        this.tokenizer.tokenizeEOF();
        return this.tokens[0];
      },

      reset: function reset() {
        this.token = null;
        this.startLine = 1;
        this.startColumn = 0;
      },

      addLocInfo: function addLocInfo() {
        if (this.options.loc) {
          this.token.loc = {
            start: {
              line: this.startLine,
              column: this.startColumn
            },
            end: {
              line: this.tokenizer.line,
              column: this.tokenizer.column
            }
          };
        }
        this.startLine = this.tokenizer.line;
        this.startColumn = this.tokenizer.column;
      },

      // Data

      beginData: function beginData() {
        this.token = {
          type: 'Chars',
          chars: ''
        };
        this.tokens.push(this.token);
      },

      appendToData: function appendToData(char) {
        this.token.chars += char;
      },

      finishData: function finishData() {
        this.addLocInfo();
      },

      // Comment

      beginComment: function beginComment() {
        this.token = {
          type: 'Comment',
          chars: ''
        };
        this.tokens.push(this.token);
      },

      appendToCommentData: function appendToCommentData(char) {
        this.token.chars += char;
      },

      finishComment: function finishComment() {
        this.addLocInfo();
      },

      // Tags - basic

      beginStartTag: function beginStartTag() {
        this.token = {
          type: 'StartTag',
          tagName: '',
          attributes: [],
          selfClosing: false
        };
        this.tokens.push(this.token);
      },

      beginEndTag: function beginEndTag() {
        this.token = {
          type: 'EndTag',
          tagName: ''
        };
        this.tokens.push(this.token);
      },

      finishTag: function finishTag() {
        this.addLocInfo();
      },

      markTagAsSelfClosing: function markTagAsSelfClosing() {
        this.token.selfClosing = true;
      },

      // Tags - name

      appendToTagName: function appendToTagName(char) {
        this.token.tagName += char;
      },

      // Tags - attributes

      beginAttribute: function beginAttribute() {
        this._currentAttribute = ["", "", null];
        this.token.attributes.push(this._currentAttribute);
      },

      appendToAttributeName: function appendToAttributeName(char) {
        this._currentAttribute[0] += char;
      },

      beginAttributeValue: function beginAttributeValue(isQuoted) {
        this._currentAttribute[2] = isQuoted;
      },

      appendToAttributeValue: function appendToAttributeValue(char) {
        this._currentAttribute[1] = this._currentAttribute[1] || "";
        this._currentAttribute[1] += char;
      },

      finishAttributeValue: function finishAttributeValue() {}
    };

    function tokenize(input, options) {
      var tokenizer = new Tokenizer(new EntityParser(HTML5NamedCharRefs), options);
      return tokenizer.tokenize(input);
    }

    var HTML5Tokenizer = {
      HTML5NamedCharRefs: HTML5NamedCharRefs,
      EntityParser: EntityParser,
      EventedTokenizer: EventedTokenizer,
      Tokenizer: Tokenizer,
      tokenize: tokenize
    };

    var options = linkify.options;
    var Options = options.Options;


    var StartTag = 'StartTag';
    var EndTag = 'EndTag';
    var Chars = 'Chars';
    var Comment = 'Comment';

    /**
    	`tokens` and `token` in this section refer to tokens generated by the HTML
    	parser.
    */
    function linkifyHtml(str) {
      var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1];

      var tokens = HTML5Tokenizer.tokenize(str);
      var linkifiedTokens = [];
      var linkified = [];
      var i;

      opts = new Options(opts);

      // Linkify the tokens given by the parser
      for (i = 0; i < tokens.length; i++) {
        var token = tokens[i];

        if (token.type === StartTag) {
          linkifiedTokens.push(token);

          // Ignore all the contents of ignored tags
          var tagName = token.tagName.toUpperCase();
          var isIgnored = tagName === 'A' || options.contains(opts.ignoreTags, tagName);
          if (!isIgnored) {
            continue;
          }

          var preskipLen = linkifiedTokens.length;
          skipTagTokens(tagName, tokens, ++i, linkifiedTokens);
          i += linkifiedTokens.length - preskipLen - 1;
          continue;
        } else if (token.type !== Chars) {
          // Skip this token, it's not important
          linkifiedTokens.push(token);
          continue;
        }

        // Valid text token, linkify it!
        var linkifedChars = linkifyChars(token.chars, opts);
        linkifiedTokens.push.apply(linkifiedTokens, linkifedChars);
      }

      // Convert the tokens back into a string
      for (i = 0; i < linkifiedTokens.length; i++) {
        var _token = linkifiedTokens[i];
        switch (_token.type) {
          case StartTag:
            var link = '<' + _token.tagName;
            if (_token.attributes.length > 0) {
              var attrs = attrsToStrings(_token.attributes);
              link += ' ' + attrs.join(' ');
            }
            link += '>';
            linkified.push(link);
            break;
          case EndTag:
            linkified.push('</' + _token.tagName + '>');
            break;
          case Chars:
            linkified.push(escapeText(_token.chars));
            break;
          case Comment:
            linkified.push('<!--' + escapeText(_token.chars) + '-->');
            break;
        }
      }

      return linkified.join('');
    }

    /**
    	`tokens` and `token` in this section referes to tokens returned by
    	`linkify.tokenize`. `linkified` will contain HTML Parser-style tokens
    */
    function linkifyChars(str, opts) {
      var tokens = linkify.tokenize(str);
      var result = [];

      for (var i = 0; i < tokens.length; i++) {
        var token = tokens[i];

        if (token.type === 'nl' && opts.nl2br) {
          result.push({
            type: StartTag,
            tagName: 'br',
            attributes: [],
            selfClosing: true
          });
          continue;
        } else if (!token.isLink || !opts.check(token)) {
          result.push({ type: Chars, chars: token.toString() });
          continue;
        }

        var _opts$resolve = opts.resolve(token);

        var href = _opts$resolve.href;
        var formatted = _opts$resolve.formatted;
        var formattedHref = _opts$resolve.formattedHref;
        var tagName = _opts$resolve.tagName;
        var className = _opts$resolve.className;
        var target = _opts$resolve.target;
        var attributes = _opts$resolve.attributes;

        // Build up attributes

        var attributeArray = [['href', formattedHref]];

        if (className) {
          attributeArray.push(['class', className]);
        }

        if (target) {
          attributeArray.push(['target', target]);
        }

        for (var attr in attributes) {
          attributeArray.push([attr, attributes[attr]]);
        }

        // Add the required tokens
        result.push({
          type: StartTag,
          tagName: tagName,
          attributes: attributeArray,
          selfClosing: false
        });
        result.push({ type: Chars, chars: formatted });
        result.push({ type: EndTag, tagName: tagName });
      }

      return result;
    }

    /**
    	Returns a list of tokens skipped until the closing tag of tagName.

    	* `tagName` is the closing tag which will prompt us to stop skipping
    	* `tokens` is the array of tokens generated by HTML5Tokenizer which
    	* `i` is the index immediately after the opening tag to skip
    	* `skippedTokens` is an array which skipped tokens are being pushed into

    	Caveats

    	* Assumes that i is the first token after the given opening tagName
    	* The closing tag will be skipped, but nothing after it
    	* Will track whether there is a nested tag of the same type
    */
    function skipTagTokens(tagName, tokens, i, skippedTokens) {

      // number of tokens of this type on the [fictional] stack
      var stackCount = 1;

      while (i < tokens.length && stackCount > 0) {
        var token = tokens[i];
        if (token.type === StartTag && token.tagName.toUpperCase() === tagName) {
          // Nested tag of the same type, "add to stack"
          stackCount++;
        } else if (token.type === EndTag && token.tagName.toUpperCase() === tagName) {
          // Closing tag
          stackCount--;
        }
        skippedTokens.push(token);
        i++;
      }

      // Note that if stackCount > 0 here, the HTML is probably invalid
      return skippedTokens;
    }

    function escapeText(text) {
      // Not required, HTML tokenizer ensures this occurs properly
      return text;
    }

    function escapeAttr(attr) {
      return attr.replace(/"/g, '&quot;');
    }

    function attrsToStrings(attrs) {
      var attrStrs = [];
      for (var i = 0; i < attrs.length; i++) {
        var _attrs$i = attrs[i];
        var name = _attrs$i[0];
        var value = _attrs$i[1];

        attrStrs.push(name + '="' + escapeAttr(value) + '"');
      }
      return attrStrs;
    }

    return linkifyHtml;
  }(linkify);
  window.linkifyHtml = linkifyHtml;
})(window, linkify);
