const path = require('path')
const hash = require('hash-sum')
const parse = require('./parser')
const genId = require('./utils/gen-id')
const querystring = require('querystring')
const loaderUtils = require('loader-utils')
const normalize = require('./utils/normalize')
const tryRequire = require('./utils/try-require')

// internal lib loaders
const selectorPath = normalize.lib('selector')
const styleCompilerPath = normalize.lib('style-compiler/index')
const templateCompilerPath = normalize.lib('template-compiler/index')
const templatePreprocessorPath = normalize.lib('template-compiler/preprocessor')
const componentNormalizerPath = normalize.lib('component-normalizer')

// dep loaders
const styleLoaderPath = normalize.dep('vue-style-loader')
const hotReloadAPIPath = normalize.dep('vue-hot-reload-api')

// check whether default js loader exists
const hasBabel = !!tryRequire('babel-loader')
const hasBuble = !!tryRequire('buble-loader')

const rewriterInjectRE = /\b(css(?:-loader)?(?:\?[^!]+)?)(?:!|$)/

const defaultLang = {
  template: 'html',
  styles: 'css',
  script: 'js'
}

// When extracting parts from the source vue file, we want to apply the
// loaders chained before vue-loader, but exclude some loaders that simply
// produces side effects such as linting.
function getRawRequest (
  { resource, loaderIndex, loaders },
  excludedPreLoaders = /eslint-loader/
) {
  return loaderUtils.getRemainingRequest({
    resource: resource,
    loaderIndex: loaderIndex,
    loaders: loaders.filter(loader => !excludedPreLoaders.test(loader.path))
  })
}

module.exports = function (content) {
  this.cacheable()
  const isServer = this.target === 'node'
  const isProduction = this.minimize || process.env.NODE_ENV === 'production'

  const loaderContext = this
  const query = loaderUtils.getOptions(this) || {}
  const options = Object.assign(
    {
      esModule: true
    },
    this.options.vue,
    this.vue,
    query
  )

  // disable esModule in inject mode
  // because import/export must be top-level
  if (query.inject) {
    options.esModule = false
  }

  // #824 avoid multiple webpack runs complaining about unknown option
  Object.defineProperty(this.options, '__vueOptions__', {
    value: options,
    enumerable: false,
    configurable: true
  })

  const rawRequest = getRawRequest(this, options.excludedPreLoaders)
  const filePath = this.resourcePath
  const fileName = path.basename(filePath)

  const context =
    (this._compiler && this._compiler.context) ||
    this.options.context ||
    process.cwd()
  const sourceRoot = path.dirname(path.relative(context, filePath))
  const moduleId = 'data-v-' + genId(filePath, context, options.hashKey)
  const shortFilePath = path
    .relative(context, filePath)
    .replace(/^(\.\.\/)+/, '')

  let cssLoaderOptions = ''
  if (!isProduction && this.sourceMap && options.cssSourceMap !== false) {
    cssLoaderOptions += '?sourceMap'
  }
  if (isProduction) {
    cssLoaderOptions += (cssLoaderOptions ? '&' : '?') + 'minimize'
  }

  const bubleOptions =
    hasBuble && options.buble ? '?' + JSON.stringify(options.buble) : ''

  let output = ''
  const bustCache = !isProduction && options.cacheBusting !== false
  const parts = parse(content, fileName, this.sourceMap, sourceRoot, bustCache)
  const hasScoped = parts.styles.some(({ scoped }) => scoped)
  const templateAttrs =
    parts.template && parts.template.attrs && parts.template.attrs
  const hasComment = templateAttrs && templateAttrs.comments
  const functionalTemplate = templateAttrs && templateAttrs.functional
  const bubleTemplateOptions = Object.assign({}, options.buble)
  bubleTemplateOptions.transforms = Object.assign(
    {},
    bubleTemplateOptions.transforms
  )
  bubleTemplateOptions.transforms.stripWithFunctional = functionalTemplate

  const templateCompilerOptions =
    '?' +
    JSON.stringify({
      id: moduleId,
      hasScoped,
      hasComment,
      transformToRequire: options.transformToRequire,
      preserveWhitespace: options.preserveWhitespace,
      buble: bubleTemplateOptions,
      // only pass compilerModules if it's a path string
      compilerModules:
        typeof options.compilerModules === 'string'
          ? options.compilerModules
          : undefined
    })

  const defaultLoaders = {
    html: templateCompilerPath + templateCompilerOptions,
    css: options.extractCSS
      ? getCSSExtractLoader()
      : styleLoaderPath + '!' + 'css-loader' + cssLoaderOptions,
    js: hasBuble
      ? 'buble-loader' + bubleOptions
      : hasBabel ? 'babel-loader' : ''
  }

  // check if there are custom loaders specified via
  // webpack config, otherwise use defaults
  const loaders = Object.assign({}, defaultLoaders, options.loaders)
  const preLoaders = options.preLoaders || {}
  const postLoaders = options.postLoaders || {}

  const needsHotReload =
    !isServer && !isProduction && (parts.script || parts.template)

  if (needsHotReload) {
    output += 'var disposed = false\n'
  }

  // add requires for styles
  let cssModules
  if (parts.styles.length) {
    let styleInjectionCode = 'function injectStyle (ssrContext) {\n'
    if (needsHotReload) {
      styleInjectionCode += `  if (disposed) return\n`
    }
    if (isServer) {
      styleInjectionCode += `var i\n`
    }
    parts.styles.forEach((style, i) => {
      // require style
      let requireString = style.src
        ? getRequireForImport('styles', style, style.scoped)
        : getRequire('styles', style, i, style.scoped)

      const hasStyleLoader = requireString.indexOf('style-loader') > -1
      const hasVueStyleLoader = requireString.indexOf('vue-style-loader') > -1
      // vue-style-loader exposes inject functions during SSR so they are
      // always called
      const invokeStyle =
        isServer && hasVueStyleLoader
          ? code => `;(i=${code},i.__inject__&&i.__inject__(ssrContext),i)\n`
          : code => `  ${code}\n`

      const moduleName = style.module === true ? '$style' : style.module
      // setCssModule
      if (moduleName) {
        if (!cssModules) {
          cssModules = {}
          if (needsHotReload) {
            output += `var cssModules = {}\n`
          }
        }
        if (moduleName in cssModules) {
          loaderContext.emitError(
            'CSS module name "' + moduleName + '" is not unique!'
          )
          styleInjectionCode += invokeStyle(requireString)
        } else {
          cssModules[moduleName] = true

          // `(vue-)style-loader` exposes the name-to-hash map directly
          // `css-loader` exposes it in `.locals`
          // add `.locals` if the user configured to not use style-loader.
          if (!hasStyleLoader) {
            requireString += '.locals'
          }

          if (!needsHotReload) {
            styleInjectionCode += invokeStyle(
              'this["' + moduleName + '"] = ' + requireString
            )
          } else {
            // handle hot reload for CSS modules.
            // we store the exported locals in an object and proxy to it by
            // defining getters inside component instances' lifecycle hook.
            styleInjectionCode +=
              invokeStyle(`cssModules["${moduleName}"] = ${requireString}`) +
              `Object.defineProperty(this, "${moduleName}", { get: function () { return cssModules["${moduleName}"] }})\n`

            const requirePath = style.src
              ? getRequireForImportString('styles', style, style.scoped)
              : getRequireString('styles', style, i, style.scoped)

            output +=
              `module.hot && module.hot.accept([${requirePath}], function () {\n` +
              // 1. check if style has been injected
              `  var oldLocals = cssModules["${moduleName}"]\n` +
              `  if (!oldLocals) return\n` +
              // 2. re-import (side effect: updates the <style>)
              `  var newLocals = ${requireString}\n` +
              // 3. compare new and old locals to see if selectors changed
              `  if (JSON.stringify(newLocals) === JSON.stringify(oldLocals)) return\n` +
              // 4. locals changed. Update and force re-render.
              `  cssModules["${moduleName}"] = newLocals\n` +
              `  require("${hotReloadAPIPath}").rerender("${moduleId}")\n` +
              `})\n`
          }
        }
      } else {
        styleInjectionCode += invokeStyle(requireString)
      }
    })
    styleInjectionCode += '}\n'
    output += styleInjectionCode
  }

  // we require the component normalizer function, and call it like so:
  // normalizeComponent(
  //   scriptExports,
  //   compiledTemplate,
  //   functionalTemplate,
  //   injectStyles,
  //   scopeId,
  //   moduleIdentifier (server only)
  // )
  output +=
    'var normalizeComponent = require(' +
    loaderUtils.stringifyRequest(loaderContext, '!' + componentNormalizerPath) +
    ')\n'

  // <script>
  output += '/* script */\n'
  const script = parts.script
  if (script) {
    if (options.esModule) {
      output += script.src
        ? getImportForImport('script', script)
        : getImport('script', script) + '\n'
    } else {
      output +=
        'var __vue_script__ = ' +
        (script.src
          ? getRequireForImport('script', script)
          : getRequire('script', script)) +
        '\n'
    }
    // inject loader interop
    if (query.inject) {
      output += '__vue_script__ = __vue_script__(injections)\n'
    }
  } else {
    output += 'var __vue_script__ = null\n'
  }

  // <template>
  output += '/* template */\n'
  const template = parts.template
  if (template) {
    if (options.esModule) {
      output +=
        (template.src
          ? getImportForImport('template', template)
          : getImport('template', template)) + '\n'
    } else {
      output +=
        'var __vue_template__ = ' +
        (template.src
          ? getRequireForImport('template', template)
          : getRequire('template', template)) +
        '\n'
    }
  } else {
    output += 'var __vue_template__ = null\n'
  }

  // template functional
  output += '/* template functional */\n  '
  output +=
    'var __vue_template_functional__ = ' +
    (functionalTemplate ? 'true' : 'false') +
    '\n'

  // style
  output += '/* styles */\n'
  output +=
    'var __vue_styles__ = ' +
    (parts.styles.length ? 'injectStyle' : 'null') +
    '\n'

  // scopeId
  output += '/* scopeId */\n'
  output +=
    'var __vue_scopeId__ = ' +
    (hasScoped ? JSON.stringify(moduleId) : 'null') +
    '\n'

  // moduleIdentifier (server only)
  output += '/* moduleIdentifier (server only) */\n'
  output +=
    'var __vue_module_identifier__ = ' +
    (isServer ? JSON.stringify(hash(this.request)) : 'null') +
    '\n'

  // close normalizeComponent call
  output +=
    'var Component = normalizeComponent(\n' +
    '  __vue_script__,\n' +
    '  __vue_template__,\n' +
    '  __vue_template_functional__,\n' +
    '  __vue_styles__,\n' +
    '  __vue_scopeId__,\n' +
    '  __vue_module_identifier__\n' +
    ')\n'

  // development-only code
  if (!isProduction) {
    // add filename in dev
    output +=
      'Component.options.__file = ' + JSON.stringify(shortFilePath) + '\n'
    // check named exports
    output +=
      'if (Component.esModule && Object.keys(Component.esModule).some(function (key) {' +
      '  return key !== "default" && key.substr(0, 2) !== "__"' +
      '})) {' +
      '  console.error("named exports are not supported in *.vue files.")' +
      '}\n'
  }

  // add requires for customBlocks
  if (parts.customBlocks && parts.customBlocks.length) {
    let addedPrefix = false

    parts.customBlocks.forEach((customBlock, i) => {
      if (loaders[customBlock.type]) {
        // require customBlock
        customBlock.src = customBlock.attrs.src
        const requireString = customBlock.src
          ? getRequireForImport(customBlock.type, customBlock)
          : getRequire(customBlock.type, customBlock, i)

        if (!addedPrefix) {
          output += '\n/* customBlocks */\n'
          addedPrefix = true
        }

        output +=
          'var customBlock = ' + requireString + '\n' +
          'if (typeof customBlock === "function") {' +
          '  customBlock(Component)' +
          '}\n'
      }
    })

    output += '\n'
  }

  if (!query.inject) {
    // hot reload
    if (needsHotReload) {
      output +=
        '\n/* hot reload */\n' +
        'if (module.hot) {(function () {\n' +
        '  var hotAPI = require("' + hotReloadAPIPath + '")\n' +
        '  hotAPI.install(require("vue"), false)\n' +
        '  if (!hotAPI.compatible) return\n' +
        '  module.hot.accept()\n' +
        '  if (!module.hot.data) {\n' +
        // initial insert
        '    hotAPI.createRecord("' + moduleId + '", Component.options)\n' +
        '  } else {\n'
      // update
      if (cssModules) {
        output +=
          '    if (module.hot.data.cssModules && Object.keys(module.hot.data.cssModules) !== Object.keys(cssModules)) {\n' +
          '      delete Component.options._Ctor\n' +
          '    }\n'
      }
      output +=
        `    hotAPI.${
          functionalTemplate ? 'rerender' : 'reload'
        }("${moduleId}", Component.options)\n' + '  }\n`
      // dispose
      output +=
        '  module.hot.dispose(function (data) {\n' +
        (cssModules ? '    data.cssModules = cssModules\n' : '') +
        '    disposed = true\n' +
        '  })\n'
      output += '})()}\n'
    }
    // final export
    if (options.esModule) {
      output += '\nexport default Component.exports\n'
    } else {
      output += '\nmodule.exports = Component.exports\n'
    }
  } else {
    // inject-loader support
    output =
      '\n/* dependency injection */\n' +
      'module.exports = function (injections) {\n' +
      output +
      '\n' +
      '\nreturn Component.exports\n}'
  }

  // done
  return output

  // --- helpers ---

  function getRequire (type, part, index, scoped) {
    return 'require(' + getRequireString(type, part, index, scoped) + ')'
  }

  function getImport (type, part, index, scoped) {
    return (
      'import __vue_' + type + '__ from ' +
      getRequireString(type, part, index, scoped)
    )
  }

  function getRequireString (type, part, index, scoped) {
    return loaderUtils.stringifyRequest(
      loaderContext,
      // disable all configuration loaders
      '!!' +
        // get loader string for pre-processors
        getLoaderString(type, part, index, scoped) +
        // select the corresponding part from the vue file
        getSelectorString(type, index || 0) +
        // the url to the actual vue file, including remaining requests
        rawRequest
    )
  }

  function getRequireForImport (type, impt, scoped) {
    return 'require(' + getRequireForImportString(type, impt, scoped) + ')'
  }

  function getImportForImport (type, impt, scoped) {
    return (
      'import __vue_' + type + '__ from ' +
      getRequireForImportString(type, impt, scoped)
    )
  }

  function getRequireForImportString (type, impt, scoped) {
    return loaderUtils.stringifyRequest(
      loaderContext,
      '!!' + getLoaderString(type, impt, -1, scoped) + impt.src
    )
  }

  function addCssModulesToLoader (loader, part, index) {
    if (!part.module) return loader
    const option = options.cssModules || {}
    const DEFAULT_OPTIONS = {
      modules: true
    }
    const OPTIONS = {
      localIdentName: '[hash:base64]',
      importLoaders: true
    }
    return loader.replace(/((?:^|!)css(?:-loader)?)(\?[^!]*)?/, (m, $1, $2) => {
      // $1: !css-loader
      // $2: ?a=b
      const query = loaderUtils.parseQuery($2 || '?')
      Object.assign(query, OPTIONS, option, DEFAULT_OPTIONS)
      if (index !== -1) {
        // Note:
        //   Class name is generated according to its filename.
        //   Different <style> tags in the same .vue file may generate same names.
        //   Append `_[index]` to class name to avoid this.
        query.localIdentName += '_' + index
      }
      return $1 + '?' + JSON.stringify(query)
    })
  }

  function buildCustomBlockLoaderString (attrs) {
    const noSrcAttrs = Object.assign({}, attrs)
    delete noSrcAttrs.src
    const qs = querystring.stringify(noSrcAttrs)
    return qs ? '?' + qs : qs
  }

  // stringify an Array of loader objects
  function stringifyLoaders (loaders) {
    return loaders
      .map(
        obj =>
          obj && typeof obj === 'object' && typeof obj.loader === 'string'
            ? obj.loader +
              (obj.options ? '?' + JSON.stringify(obj.options) : '')
            : obj
      )
      .join('!')
  }

  function getLoaderString (type, part, index, scoped) {
    let loader = getRawLoaderString(type, part, index, scoped)
    const lang = getLangString(type, part)
    if (preLoaders[lang]) {
      loader = loader + ensureBang(preLoaders[lang])
    }
    if (postLoaders[lang]) {
      loader = ensureBang(postLoaders[lang]) + loader
    }
    return loader
  }

  function getLangString (type, { lang }) {
    if (type === 'script' || type === 'template' || type === 'styles') {
      return lang || defaultLang[type]
    } else {
      return type
    }
  }

  function getRawLoaderString (type, part, index, scoped) {
    let lang = part.lang || defaultLang[type]

    let styleCompiler = ''
    if (type === 'styles') {
      // style compiler that needs to be applied for all styles
      styleCompiler =
        styleCompilerPath +
        '?' +
        JSON.stringify({
          // a marker for vue-style-loader to know that this is an import from a vue file
          vue: true,
          id: moduleId,
          scoped: !!scoped,
          hasInlineConfig: !!query.postcss
        }) +
        '!'
      // normalize scss/sass if no specific loaders have been provided
      if (!loaders[lang]) {
        if (lang === 'sass') {
          lang = 'sass?indentedSyntax'
        } else if (lang === 'scss') {
          lang = 'sass'
        }
      }
    }

    let loader =
      options.extractCSS && type === 'styles'
        ? loaders[lang] || getCSSExtractLoader(lang)
        : loaders[lang]

    const injectString =
      type === 'script' && query.inject ? 'inject-loader!' : ''

    if (loader != null) {
      if (Array.isArray(loader)) {
        loader = stringifyLoaders(loader)
      } else if (typeof loader === 'object') {
        loader = stringifyLoaders([loader])
      }
      if (type === 'styles') {
        // add css modules
        loader = addCssModulesToLoader(loader, part, index)
        // inject rewriter before css loader for extractTextPlugin use cases
        if (rewriterInjectRE.test(loader)) {
          loader = loader.replace(
            rewriterInjectRE,
            (m, $1) => ensureBang($1) + styleCompiler
          )
        } else {
          loader = ensureBang(loader) + styleCompiler
        }
      }
      // if user defines custom loaders for html, add template compiler to it
      if (type === 'template' && loader.indexOf(defaultLoaders.html) < 0) {
        loader = defaultLoaders.html + '!' + loader
      }
      return injectString + ensureBang(loader)
    } else {
      // unknown lang, infer the loader to be used
      switch (type) {
        case 'template':
          return (
            defaultLoaders.html +
            '!' +
            templatePreprocessorPath +
            '?engine=' +
            lang +
            '!'
          )
        case 'styles':
          loader = addCssModulesToLoader(defaultLoaders.css, part, index)
          return loader + '!' + styleCompiler + ensureBang(ensureLoader(lang))
        case 'script':
          return injectString + ensureBang(ensureLoader(lang))
        default:
          loader = loaders[type]
          if (Array.isArray(loader)) {
            loader = stringifyLoaders(loader)
          }
          return ensureBang(loader + buildCustomBlockLoaderString(part.attrs))
      }
    }
  }

  // sass => sass-loader
  // sass-loader => sass-loader
  // sass?indentedSyntax!css => sass-loader?indentedSyntax!css-loader
  function ensureLoader (lang) {
    return lang
      .split('!')
      .map(loader =>
        loader.replace(
          /^([\w-]+)(\?.*)?/,
          (_, name, query) =>
            (/-loader$/.test(name) ? name : name + '-loader') + (query || '')
        )
      )
      .join('!')
  }

  function getSelectorString (type, index) {
    return (
      selectorPath +
      '?type=' +
      (type === 'script' || type === 'template' || type === 'styles'
        ? type
        : 'customBlocks') +
      '&index=' + index +
      (bustCache ? '&bustCache' : '') +
      '!'
    )
  }

  function ensureBang (loader) {
    if (loader.charAt(loader.length - 1) !== '!') {
      return loader + '!'
    } else {
      return loader
    }
  }

  function getCSSExtractLoader (lang) {
    let extractor
    const op = options.extractCSS
    // extractCSS option is an instance of ExtractTextPlugin
    if (typeof op.extract === 'function') {
      extractor = op
    } else {
      extractor = tryRequire('extract-text-webpack-plugin')
      if (!extractor) {
        throw new Error(
          '[vue-loader] extractCSS: true requires extract-text-webpack-plugin ' +
            'as a peer dependency.'
        )
      }
    }
    const langLoader = lang ? ensureBang(ensureLoader(lang)) : ''
    return extractor.extract({
      use: 'css-loader' + cssLoaderOptions + '!' + langLoader,
      fallback: 'vue-style-loader'
    })
  }
}
