'use strict'

const EventEmitter = require('events').EventEmitter
const webpack = require('webpack')
const readline = require('readline')
const escape = require('escape-html')

const { chalk, fs, path, logger, env, performance } = require('@vuepress/shared-utils')
const createClientConfig = require('../webpack/createClientConfig')
const createServerConfig = require('../webpack/createServerConfig')
const { createBundleRenderer } = require('vue-server-renderer')
const { normalizeHeadTag, applyUserWebpackConfig } = require('../util/index')

/**
 * Expose Build Process Class.
 */

module.exports = class Build extends EventEmitter {
  constructor (context) {
    super()
    process.env.NODE_ENV = 'production'
    this.context = context
    this.outDir = this.context.outDir
  }

  /**
   * Doing somthing before render pages, e.g. validate and empty output directory,
   * prepare webpack config.
   *
   * @returns {Promise<void>}
   * @api public
   */

  async process () {
    if (this.context.cwd === this.outDir) {
      throw new Error('Unexpected option: "outDir" cannot be set to the current working directory')
    }

    this.context.resolveCacheLoaderOptions()
    await fs.emptyDir(this.outDir)
    logger.debug('Dist directory: ' + chalk.gray(this.outDir))
    this.prepareWebpackConfig()
  }

  /**
   * Compile and render pages.
   *
   * @returns {Promise<void>}
   * @api public
   */

  async render () {
    // compile!
    const stats = await compile([this.clientConfig, this.serverConfig])
    const serverBundle = require(path.resolve(this.outDir, 'manifest/server.json'))
    const clientManifest = require(path.resolve(this.outDir, 'manifest/client.json'))

    // remove manifests after loading them.
    await fs.remove(path.resolve(this.outDir, 'manifest'))

    // ref: https://github.com/vuejs/vuepress/issues/1367
    if (!this.clientConfig.devtool && (!this.clientConfig.plugins
      || !this.clientConfig.plugins.some(p =>
        p instanceof webpack.SourceMapDevToolPlugin
        || p instanceof webpack.EvalSourceMapDevToolPlugin
      ))) {
      await workaroundEmptyStyleChunk(stats, this.outDir)
    }

    // create server renderer using built manifests
    this.renderer = createBundleRenderer(serverBundle, {
      clientManifest,
      runInNewContext: false,
      inject: false,
      shouldPrefetch: this.context.siteConfig.shouldPrefetch || (() => true),
      template: await fs.readFile(this.context.ssrTemplate, 'utf-8')
    })

    // pre-render head tags from user config
    this.userHeadTags = (this.context.siteConfig.head || [])
      .map(renderHeadTag)
      .join('\n  ')

    // if the user does not have a custom 404.md, generate the theme's default
    if (!this.context.pages.some(p => p.path === '/404.html')) {
      this.context.addPage({ path: '/404.html' })
    }

    // render pages
    logger.wait('Rendering static HTML...')

    const pagePaths = []
    for (const page of this.context.pages) {
      pagePaths.push(await this.renderPage(page))
    }

    readline.clearLine(process.stdout, 0)
    readline.cursorTo(process.stdout, 0)

    await this.context.pluginAPI.applyAsyncOption('generated', pagePaths)

    // DONE.
    const relativeDir = path.relative(this.context.cwd, this.outDir)
    logger.success(`Generated static files in ${chalk.cyan(relativeDir)}.`)
    const { duration } = performance.stop()
    logger.developer(`It took a total of ${chalk.cyan(`${duration}ms`)} to run the ${chalk.cyan('vuepress build')}.`)
    console.log()
  }

  /**
   * Prepare webpack config under build.
   *
   * @api private
   */

  prepareWebpackConfig () {
    this.clientConfig = createClientConfig(this.context).toConfig()
    this.serverConfig = createServerConfig(this.context).toConfig()

    const userConfig = this.context.siteConfig.configureWebpack
    if (userConfig) {
      this.clientConfig = applyUserWebpackConfig(userConfig, this.clientConfig, false)
      this.serverConfig = applyUserWebpackConfig(userConfig, this.serverConfig, true)
    }
  }

  /**
   * Render page
   *
   * @param {Page} page
   * @returns {Promise<string>}
   * @api private
   */

  async renderPage (page) {
    const pagePath = decodeURIComponent(page.path)
    readline.clearLine(process.stdout, 0)
    readline.cursorTo(process.stdout, 0)
    process.stdout.write(`Rendering page: ${pagePath}`)

    // #565 Avoid duplicate description meta at SSR.
    const meta = (page.frontmatter && page.frontmatter.meta || []).filter(item => item.name !== 'description')
    const pageMeta = renderPageMeta(meta)

    const context = {
      url: page.path,
      userHeadTags: this.userHeadTags,
      pageMeta,
      title: 'VuePress',
      lang: 'en',
      description: ''
    }

    let html
    try {
      html = await this.renderer.renderToString(context)
    } catch (e) {
      console.error(logger.error(chalk.red(`Error rendering ${pagePath}:`), false))
      throw e
    }
    const filename = pagePath.replace(/\/$/, '/index.html').replace(/^\//, '')
    const filePath = path.resolve(this.outDir, filename)
    await fs.ensureDir(path.dirname(filePath))
    await fs.writeFile(filePath, html)
    return filePath
  }
}

/**
 * Compile a webpack application and return stats json.
 *
 * @param {Object} config
 * @returns {Promise<Object>}
 */

function compile (config) {
  return new Promise((resolve, reject) => {
    webpack(config, (err, stats) => {
      if (err) {
        return reject(err)
      }
      if (stats.hasErrors()) {
        stats.toJson().errors.forEach(err => {
          console.error(err)
        })
        reject(new Error(`Failed to compile with errors.`))
        return
      }
      if (env.isDebug && stats.hasWarnings()) {
        stats.toJson().warnings.forEach(warning => {
          console.warn(warning)
        })
      }
      resolve(stats.toJson({ modules: false }))
    })
  })
}

/**
 * Render head tag
 *
 * @param {Object} tag
 * @returns {string}
 */

function renderHeadTag (tag) {
  const { tagName, attributes, innerHTML, closeTag } = normalizeHeadTag(tag)
  return `<${tagName}${renderAttrs(attributes)}>${innerHTML}${closeTag ? `</${tagName}>` : ``}`
}

/**
 * Render html attributes
 *
 * @param {Object} attrs
 * @returns {string}
 */

function renderAttrs (attrs = {}) {
  const keys = Object.keys(attrs)
  if (keys.length) {
    return ' ' + keys.map(name => `${name}="${escape(attrs[name])}"`).join(' ')
  } else {
    return ''
  }
}

/**
 * Render meta tags
 *
 * @param {Array} meta
 * @returns {Array<string>}
 */

function renderPageMeta (meta) {
  if (!meta) return ''
  return meta.map(m => {
    let res = `<meta`
    Object.keys(m).forEach(key => {
      res += ` ${key}="${escape(m[key])}"`
    })
    return res + `>`
  }).join('')
}

/**
 * find and remove empty style chunk caused by
 * https://github.com/webpack-contrib/mini-css-extract-plugin/issues/85
 * TODO remove when it's fixed
 *
 * @param {Object} stats
 * @param {String} outDir
 * @returns {Promise<void>}
 */

async function workaroundEmptyStyleChunk (stats, outDir) {
  const styleChunk = stats.children[0].assets.find(a => {
    return /styles\.\w{8}\.js$/.test(a.name)
  })
  if (!styleChunk) return
  const styleChunkPath = path.resolve(outDir, styleChunk.name)
  const styleChunkContent = await fs.readFile(styleChunkPath, 'utf-8')
  await fs.remove(styleChunkPath)
  // prepend it to app.js.
  // this is necessary for the webpack runtime to work properly.
  const appChunk = stats.children[0].assets.find(a => {
    return /app\.\w{8}\.js$/.test(a.name)
  })
  const appChunkPath = path.resolve(outDir, appChunk.name)
  const appChunkContent = await fs.readFile(appChunkPath, 'utf-8')
  await fs.writeFile(appChunkPath, styleChunkContent + appChunkContent)
}
