(function(chaiDom) {
  if (typeof require === 'function' && typeof exports === 'object' && typeof module === 'object') {
    module.exports = chaiDom
  } else if (typeof define === 'function' && define.amd) {
    define(function() {
      return chaiDom
    })
  } else {
    chai.use(chaiDom)
  }
}(function(chai, utils) {
  var flag = utils.flag,

  elToString = function(el) {
    var desc
    if (isNodeList(el)) {
      if (el.length === 0) {
        return 'empty NodeList'
      }

      desc = Array.prototype.slice.call(el, 0, 5).map(elToString).join(', ')
      return el.length > 5 ? desc + '... (+' + (el.length - 5) + ' more)' : desc
    }
    if (!isHTMLElement(el)) {
      return String(el)
    }

    desc = el.tagName.toLowerCase()
    if (el.id) {
      desc += '#' + el.id
    }
    if (el.className) {
      desc += '.' + String(el.className).replace(/\s+/g, '.')
    }
    Array.prototype.forEach.call(el.attributes, function(attr) {
      if (attr.name !== 'class' && attr.name !== 'id') {
        desc += '[' + attr.name + (attr.value ? '="' + attr.value + '"]' : ']')
      }
    })
    return desc
  },

  attrAssert = function(name, val) {
    var el = flag(this, 'object'), actual = el.getAttribute(name)

    if (!flag(this, 'negate') || undefined === val) {
      this.assert(
        !!el.attributes[name]
        , 'expected ' + elToString(el) + ' to have an attribute #{exp}'
        , 'expected ' + elToString(el) + ' not to have an attribute #{exp}'
        , name
      )
    }

    if (undefined !== val) {
      this.assert(
        val === actual
        , 'expected ' + elToString(el) + ' to have an attribute ' + utils.inspect(name) + ' with the value #{exp}, but the value was #{act}'
        , 'expected ' + elToString(el) + ' not to have an attribute ' + utils.inspect(name) + ' with the value #{act}'
        , val
        , actual
      )
    }

    flag(this, 'object', actual)
  },

  isHTMLElement = function(el) {
    return el.nodeType === 1 // window.Node.ELEMENT_NODE
  },

  isNodeList = function(obj) {
    return Object.prototype.toString.call(obj) === '[object NodeList]'
  }

  utils.elToString = elToString
  chai.Assertion.addMethod('attr', attrAssert)
  chai.Assertion.addMethod('attribute', attrAssert)

  chai.Assertion.addMethod('class', function(className) {
    var el = flag(this, 'object')

    if (className instanceof RegExp) {
      return this.assert(
        Array.from(el.classList).some(function(cls) { return className.test(cls) })
        , 'expected ' + elToString(el) + ' to have class matching #{exp}'
        , 'expected ' + elToString(el) + ' not to have class matching #{exp}'
        , className
      )
    }

    this.assert(
      el.classList.contains(className)
      , 'expected ' + elToString(el) + ' to have class #{exp}'
      , 'expected ' + elToString(el) + ' not to have class #{exp}'
      , className
    )
  })

  chai.Assertion.addMethod('id', function(id) {
    var el = flag(this, 'object')
    this.assert(
      el.id == id
      , 'expected ' + elToString(el) + ' to have id #{exp}'
      , 'expected ' + elToString(el) + ' not to have id #{exp}'
      , id
    )
  })

  chai.Assertion.addMethod('html', function(html) {
    var el = flag(this, 'object'), actual = flag(this, 'object').innerHTML

    if (flag(this, 'contains')) {
      this.assert(
        actual.indexOf(html) >= 0
        , 'expected #{act} to contain HTML #{exp}'
        , 'expected #{act} not to contain HTML #{exp}'
        , html
        , actual
      )
    } else {
      this.assert(
        actual === html
        , 'expected ' + elToString(el) + ' to have HTML #{exp}, but the HTML was #{act}'
        , 'expected ' + elToString(el) + ' not to have HTML #{exp}'
        , html
        , actual
      )
    }
  })

  chai.Assertion.addChainableMethod('trimmed', null, function() {
    flag(this, 'trim-text', true)
  })

  chai.Assertion.addProperty('rendered', function() {
    flag(this, 'rendered-text', true)
  })

  chai.Assertion.addMethod('text', function(text) {
    var obj = flag(this, 'object'), contains = flag(this, 'contains'),
        trim = flag(this, 'trim-text'), actual, result
    var property = flag(this, 'rendered-text') ? 'innerText' : 'textContent'

    if (isNodeList(obj)) {
      actual = Array.prototype.map.call(obj, function(el) { return trim ? el[property].trim() : el[property] })
      if (Array.isArray(text)) {
        result = contains ?
          text[flag(this, 'negate') ? 'some' : 'every'](function(t) {
            return Array.prototype.some.call(obj, function(el) {
              return (trim ? el[property].trim() : el[property]) === t
            })
          })
          :
          utils.eql(actual, text)

        actual = actual.join()
        text = text.join()
      } else {
        actual = actual.join('')
        result = contains ? actual.indexOf(text) >= 0 : actual === text
      }
    } else {
      actual = trim ? obj[property].trim() : obj[property]
      result = contains ? actual.indexOf(text) >= 0 : actual === text
    }

    var objDesc = elToString(obj)
    var textMsg = ''

    if (trim) {
      textMsg += 'trimmed '
    }
    if (flag(this, 'rendered-text')) {
      textMsg += 'rendered '
    }
    textMsg += 'text'

    if (contains) {
      this.assert(
        result
        , 'expected ' + objDesc + ' to contain #{exp}, but the ' + textMsg + ' was #{act}'
        , 'expected ' + objDesc + ' not to contain #{exp}, but the ' + textMsg + ' was #{act}'
        , text
        , actual
      )
    } else {
      this.assert(
        result
        , 'expected ' + objDesc + ' to have ' + textMsg + ' #{exp}, but the ' + textMsg + ' was #{act}'
        , 'expected ' + objDesc + ' not to have ' + textMsg + ' #{exp}'
        , text
        , actual
      )
    }
  })

  chai.Assertion.addMethod('value', function(value) {
    var el = flag(this, 'object'), actual = flag(this, 'object').value
    this.assert(
      flag(this, 'object').value === value
      , 'expected ' + elToString(el) + ' to have value #{exp}, but the value was #{act}'
      , 'expected ' + elToString(el) + ' not to have value #{exp}'
      , value
      , actual
    )
  })

  chai.Assertion.overwriteProperty('exist', function(_super) {
    return function() {
      var obj = flag(this, 'object')
      if (isNodeList(obj)) {
        this.assert(
          obj.length > 0
          , 'expected an empty NodeList to have nodes'
          , 'expected ' + elToString(obj) + ' to not exist')
      } else {
        _super.apply(this, arguments)
      }
    }
  })

  chai.Assertion.overwriteProperty('empty', function(_super) {
    return function() {
      var obj = flag(this, 'object')
      if (isHTMLElement(obj)) {
        this.assert(
          obj.children.length === 0
          , 'expected ' + elToString(obj) + ' to be empty'
          , 'expected ' + elToString(obj) + ' to not be empty')
      } else if (isNodeList(obj)) {
        this.assert(
          obj.length === 0
          , 'expected ' + elToString(obj) + ' to be empty'
          , 'expected ' + elToString(obj) + ' to not be empty')
      } else {
        _super.apply(this, arguments)
      }
    }
  })

  chai.Assertion.overwriteChainableMethod('length',
    function(_super) {
      return function(length) {
        var obj = flag(this, 'object')
        if (isNodeList(obj) || isHTMLElement(obj)) {
          var actualLength = obj.children ? obj.children.length : obj.length
          this.assert(
              actualLength === length
            , 'expected ' + elToString(obj) + ' to have #{exp} children but it had #{act} children'
            , 'expected ' + elToString(obj) + ' to not have #{exp} children'
            , length
            , actualLength
          )
        } else {
          _super.apply(this, arguments)
        }
      }
    },
    function(_super) {
      return function() {
        _super.call(this)
      }
    }
  )


  chai.Assertion.overwriteMethod('match', function(_super) {
    return function(selector) {
      var obj = flag(this, 'object')
      if (isHTMLElement(obj)) {
        this.assert(
          obj.matches(selector)
          , 'expected ' + elToString(obj) + ' to match #{exp}'
          , 'expected ' + elToString(obj) + ' to not match #{exp}'
          , selector
        )
      } else if (isNodeList(obj)) {
        this.assert(
          (!!obj.length && Array.prototype.every.call(obj, function(el) { return el.matches(selector) }))
          , 'expected ' + elToString(obj) + ' to match #{exp}'
          , 'expected ' + elToString(obj) + ' to not match #{exp}'
          , selector
        )
      } else {
        _super.apply(this, arguments)
      }
    }
  })

  chai.Assertion.overwriteChainableMethod('contain',
    function(_super) {
      return function(subitem) {
        var obj = flag(this, 'object')
        if (isHTMLElement(obj)) {
          if (typeof subitem === 'string') {
            this.assert(
              !!obj.querySelector(subitem)
              , 'expected ' + elToString(obj) + ' to contain #{exp}'
              , 'expected ' + elToString(obj) + ' to not contain #{exp}'
              , subitem)
          } else {
            this.assert(
              obj.contains(subitem)
              , 'expected ' + elToString(obj) + ' to contain ' + elToString(subitem)
              , 'expected ' + elToString(obj) + ' to not contain ' + elToString(subitem))
          }
        } else {
          _super.apply(this, arguments)
        }
      }
    },
    function(_super) {
      return function() {
        _super.call(this)
      }
    }
  )

  chai.Assertion.addMethod('descendant', function(subitem) {
    var obj = flag(this, 'object'), actual = subitem

    if (typeof subitem === 'string') {
      actual = obj.querySelector(subitem)
      this.assert(
        !!actual
        , 'expected ' + elToString(obj) + ' to have descendant #{exp}'
        , 'expected ' + elToString(obj) + ' to not have descendant #{exp}'
        , subitem)
    } else {
      this.assert(
        obj.contains(subitem)
        , 'expected ' + elToString(obj) + ' to contain ' + elToString(subitem)
        , 'expected ' + elToString(obj) + ' to not contain ' + elToString(subitem))
    }

    flag(this, 'object', actual)
  })

  chai.Assertion.addMethod('descendants', function(selector) {
    var obj = flag(this, 'object'),
        actual = obj.querySelectorAll(selector)
    this.assert(
      !!actual.length
      , 'expected ' + elToString(obj) + ' to have descendants #{exp}'
      , 'expected ' + elToString(obj) + ' to not have descendants #{exp}'
      , selector)
    flag(this, 'object', actual)
  })

  chai.Assertion.addProperty('displayed', function() {
    var el = flag(this, 'object'),
        actual = el.getRootNode({ composed: true }) === document ? window.getComputedStyle(el).display : el.style.display

    this.assert(
      actual !== 'none'
      , 'expected ' + elToString(el) + ' to be displayed, but it was not'
      , 'expected ' + elToString(el) + ' to not be displayed, but it was as ' + actual
      , actual
    )
  })

  chai.Assertion.addProperty('visible', function() {
    var el = flag(this, 'object'),
        actual = document.body.contains(el) ? window.getComputedStyle(el).visibility : el.style.visibility

    this.assert(
      actual !== 'hidden' && actual !== 'collapse'
      , 'expected ' + elToString(el) + ' to be visible, but it was ' + (actual === 'hidden' ? 'hidden' : 'collapsed')
      , 'expected ' + elToString(el) + ' to not be visible, but it was'
      , actual
    )
  })

  chai.Assertion.addMethod('tagName', function(tagName) {
    var el = flag(this, 'object'),
        actual = el.tagName;

    this.assert(
      actual.toUpperCase() === tagName.toUpperCase()
      , 'expected ' + elToString(el) + ' to have tagName ' + tagName + ', but it was ' + actual
      , 'expected ' + elToString(el) + ' to not have tagName ' + tagName + ', but it was ' + actual
      , actual
    )
  })

  chai.Assertion.addMethod('style', function (styleProp, styleValue) {
    var el = flag(this, 'object'),
        style = window.getComputedStyle(el),
        actual = style.getPropertyValue(styleProp).trim();

    this.assert(
      actual === styleValue
      , 'expected ' + elToString(el) + ' to have style property ' + styleProp + ' equal to ' + styleValue + ', but it was equal to ' + actual
      , 'expected ' + elToString(el) + ' to not have style property ' + styleProp + ' equal to ' + styleValue + ', but it was equal to ' + actual
      , actual
    )
  })

  chai.Assertion.overwriteProperty('focus', function() {
    return function () {
      var el = flag(this, 'object'), actual = el.ownerDocument.activeElement

      this.assert(
        el === el.ownerDocument.activeElement
        , 'expected #{this} to have focus'
        , 'expected #{this} not to have focus'
        , el
        , actual
      )

    }
  })

  chai.Assertion.overwriteProperty('checked', function() {
    return function () {
      var el = flag(this, 'object')

      if(!(el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio'))) {
        throw new TypeError(elToString(el) + ' is not a checkbox or radio input');
      }

      this.assert(
        el.checked
        , 'expected ' + elToString(el) + ' to be checked'
        , 'expected ' + elToString(el) + ' to not be checked')
    }
  })
}));
