import { overrideSourceMaps } from './lib/typescript-overrides'

import * as Promise from 'bluebird'
import * as events from 'events'
import * as _ from 'lodash'
import * as webpack from 'webpack'
import { createDeferred } from './deferred'

const path = require('path')
const debug = require('debug')('cypress:webpack')
const debugStats = require('debug')('cypress:webpack:stats')

type FilePath = string
interface BundleObject {
  promise: Promise<FilePath>
  initial: boolean
}

// bundle promises from input spec filename to output bundled file paths
let bundles: {[key: string]: BundleObject} = {}

// we don't automatically load the rules, so that the babel dependencies are
// not required if a user passes in their own configuration
const getDefaultWebpackOptions = (): webpack.Configuration => {
  debug('load default options')

  return {
    mode: 'development',
    module: {
      rules: [
        {
          test: /\.jsx?$/,
          exclude: [/node_modules/],
          use: [
            {
              loader: 'babel-loader',
              options: {
                presets: ['@babel/preset-env'],
              },
            },
          ],
        },
      ],
    },
  }
}

const replaceErrMessage = (err: Error, partToReplace: string, replaceWith = '') => {
  err.message = _.trim(err.message.replace(partToReplace, replaceWith))

  if (err.stack) {
    err.stack = _.trim(err.stack.replace(partToReplace, replaceWith))
  }

  return err
}

const cleanModuleNotFoundError = (err: Error) => {
  const message = err.message

  if (!message.includes('Module not found')) return err

  const startIndex = message.lastIndexOf('resolve ')
  const endIndex = message.lastIndexOf(`doesn't exist`) + `doesn't exist`.length
  const partToReplace = message.substring(startIndex, endIndex)
  const newMessagePart = `Looked for and couldn't find the file at the following paths:`

  return replaceErrMessage(err, partToReplace, newMessagePart)
}

const cleanMultiNonsense = (err: Error) => {
  const message = err.message
  const startIndex = message.indexOf('@ multi')

  if (startIndex < 0) return err

  const partToReplace = message.substring(startIndex)

  return replaceErrMessage(err, partToReplace)
}

const quietErrorMessage = (err: Error) => {
  if (!err || !err.message) return err

  err = cleanModuleNotFoundError(err)
  err = cleanMultiNonsense(err)

  return err
}

/**
 * Configuration object for this Webpack preprocessor
 */
interface PreprocessorOptions {
  webpackOptions?: webpack.Configuration
  watchOptions?: Object
  typescript?: string
  additionalEntries?: string[]
}

interface FileEvent extends events.EventEmitter {
  filePath: FilePath
  outputPath: string
  shouldWatch: boolean
}

/**
 * Cypress asks file preprocessor to bundle the given file
 * and return the full path to produced bundle.
 */
type FilePreprocessor = (file: FileEvent) => Promise<FilePath>

type WebpackPreprocessorFn = (options: PreprocessorOptions) => FilePreprocessor

/**
 * Cypress file preprocessor that can bundle specs
 * using Webpack.
 */
interface WebpackPreprocessor extends WebpackPreprocessorFn {
  /**
   * Default options for Cypress Webpack preprocessor.
   * You can modify these options then pass to the preprocessor.
   * @example
    ```
    const defaults = webpackPreprocessor.defaultOptions
    module.exports = (on) => {
      delete defaults.webpackOptions.module.rules[0].use[0].options.presets
      on('file:preprocessor', webpackPreprocessor(defaults))
    }
    ```
   *
   * @type {Omit<PreprocessorOptions, 'additionalEntries'>}
   * @memberof WebpackPreprocessor
   */
  defaultOptions: Omit<PreprocessorOptions, 'additionalEntries'>
}

/**
 * Webpack preprocessor configuration function. Takes configuration object
 * and returns file preprocessor.
 * @example
  ```
  on('file:preprocessor', webpackPreprocessor(options))
  ```
 */
// @ts-ignore
const preprocessor: WebpackPreprocessor = (options: PreprocessorOptions = {}): FilePreprocessor => {
  debug('user options: %o', options)

  // we return function that accepts the arguments provided by
  // the event 'file:preprocessor'
  //
  // this function will get called for the support file when a project is loaded
  // (if the support file is not disabled)
  // it will also get called for a spec file when that spec is requested by
  // the Cypress runner
  //
  // when running in the GUI, it will likely get called multiple times
  // with the same filePath, as the user could re-run the tests, causing
  // the supported file and spec file to be requested again
  return (file: FileEvent) => {
    const filePath = file.filePath

    debug('get', filePath)

    // since this function can get called multiple times with the same
    // filePath, we return the cached bundle promise if we already have one
    // since we don't want or need to re-initiate webpack for it
    if (bundles[filePath]) {
      debug(`already have bundle for ${filePath}`)

      return bundles[filePath].promise
    }

    const defaultWebpackOptions = getDefaultWebpackOptions()

    // we're provided a default output path that lives alongside Cypress's
    // app data files so we don't have to worry about where to put the bundled
    // file on disk
    const outputPath = path.extname(file.outputPath) === '.js'
      ? file.outputPath
      : `${file.outputPath}.js`

    const entry = [filePath].concat(options.additionalEntries || [])

    const watchOptions = options.watchOptions || {}

    // user can override the default options
    const webpackOptions: webpack.Configuration = _
    .chain(options.webpackOptions)
    .defaultTo(defaultWebpackOptions)
    .defaults({
      mode: defaultWebpackOptions.mode,
    })
    .assign({
      // we need to set entry and output
      entry,
      output: {
        path: path.dirname(outputPath),
        filename: path.basename(outputPath),
      },
    })
    .tap((opts) => {
      if (opts.devtool === false) {
        // disable any overrides if we've explictly turned off sourcemaps
        overrideSourceMaps(false, options.typescript)

        return
      }

      debug('setting devtool to inline-source-map')

      opts.devtool = 'inline-source-map'

      // override typescript to always generate proper source maps
      overrideSourceMaps(true, options.typescript)
    })
    .value() as any

    debug('webpackOptions: %o', webpackOptions)
    debug('watchOptions: %o', watchOptions)
    if (options.typescript) debug('typescript: %s', options.typescript)

    debug(`input: ${filePath}`)
    debug(`output: ${outputPath}`)

    const compiler = webpack(webpackOptions)

    // we keep a reference to the latest bundle in this scope
    // it's a deferred object that will be resolved or rejected in
    // the `handle` function below and its promise is what is ultimately
    // returned from this function
    let latestBundle = createDeferred<string>()

    // cache the bundle promise, so it can be returned if this function
    // is invoked again with the same filePath
    bundles[filePath] = {
      promise: latestBundle.promise,
      initial: true,
    }

    const rejectWithErr = (err: Error) => {
      err = quietErrorMessage(err)

      // @ts-ignore
      err.filePath = filePath

      debug(`errored bundling ${outputPath}`, err.message)

      latestBundle.reject(err)
    }

    // this function is called when bundling is finished, once at the start
    // and, if watching, each time watching triggers a re-bundle
    const handle = (err: Error, stats: webpack.Stats) => {
      if (err) {
        debug('handle - had error', err.message)

        return rejectWithErr(err)
      }

      const jsonStats = stats.toJson()

      if (stats.hasErrors()) {
        err = new Error('Webpack Compilation Error')

        const errorsToAppend = jsonStats.errors
        // remove stack trace lines since they're useless for debugging
        .map(cleanseError)
        // multiple errors separated by newline
        .join('\n\n')

        err.message += `\n${errorsToAppend}`

        debug('stats had error(s)')

        return rejectWithErr(err)
      }

      // these stats are really only useful for debugging
      if (jsonStats.warnings.length > 0) {
        debug(`warnings for ${outputPath}`)
        debug(jsonStats.warnings)
      }

      debug('finished bundling', outputPath)
      if (debugStats.enabled) {
        /* eslint-disable-next-line no-console */
        console.error(stats.toString({ colors: true }))
      }

      // resolve with the outputPath so Cypress knows where to serve
      // the file from
      // Seems to be a race condition where changing file before next tick
      // does not cause build to rerun
      Promise.delay(0).then(() => {
        latestBundle.resolve(outputPath)
      })
    }

    // this event is triggered when watching and a file is saved
    const plugin = { name: 'CypressWebpackPreprocessor' }

    const onCompile = () => {
      debug('compile', filePath)
      // we overwrite the latest bundle, so that a new call to this function
      // returns a promise that resolves when the bundling is finished
      latestBundle = createDeferred<string>()
      bundles[filePath].promise = latestBundle.promise

      bundles[filePath].promise.finally(() => {
        debug('- compile finished for %s, initial? %s', filePath, bundles[filePath].initial)
        // when the bundling is finished, emit 'rerun' to let Cypress
        // know to rerun the spec, but NOT when it is the initial
        // bundling of the file
        if (!bundles[filePath].initial) {
          file.emit('rerun')
        }

        bundles[filePath].initial = false
      })
      // we suppress unhandled rejections so they don't bubble up to the
      // unhandledRejection handler and crash the process. Cypress will
      // eventually take care of the rejection when the file is requested.
      // note that this does not work if attached to latestBundle.promise
      // for some reason. it only works when attached after .finally  ¯\_(ツ)_/¯
      .suppressUnhandledRejections()
    }

    // when we should watch, we hook into the 'compile' hook so we know when
    // to rerun the tests
    if (file.shouldWatch) {
      debug('watching')

      if (compiler.hooks) {
        // TODO compile.tap takes "string | Tap"
        // so seems we just need to pass plugin.name
        // @ts-ignore
        compiler.hooks.compile.tap(plugin, onCompile)
      } else {
        compiler.plugin('compile', onCompile)
      }
    }

    const bundler = file.shouldWatch ? compiler.watch(watchOptions, handle) : compiler.run(handle)

    // when the spec or project is closed, we need to clean up the cached
    // bundle promise and stop the watcher via `bundler.close()`
    file.on('close', (cb = function () {}) => {
      debug('close', filePath)
      delete bundles[filePath]

      if (file.shouldWatch) {
        // in this case the bundler is webpack.Compiler.Watching
        (bundler as webpack.Compiler.Watching).close(cb)
      }
    })

    // return the promise, which will resolve with the outputPath or reject
    // with any error encountered
    return bundles[filePath].promise
  }
}

// provide a clone of the default options
Object.defineProperty(preprocessor, 'defaultOptions', {
  get () {
    debug('get default options')

    return {
      webpackOptions: getDefaultWebpackOptions(),
      watchOptions: {},
    }
  },
})

// for testing purposes, but do not add this to the typescript interface
// @ts-ignore
preprocessor.__reset = () => {
  bundles = {}
}

function cleanseError (err: string) {
  return err.replace(/\n\s*at.*/g, '').replace(/From previous event:\n?/g, '')
}

export = preprocessor
