import type { Plugin, ResolvedConfig as ViteConfig } from 'vite'
import type { Options as EnvOptions } from '@babel/preset-env'
import { OutputChunk, Plugin as RollupPlugin } from 'rollup'
import commonJS from '@rollup/plugin-commonjs'
import dedent from 'dedent'
import babel from '@babel/core'
import chalk from 'chalk'
import { KnownPolyfill, knownPolyfills } from './polyfills'

/** Plugin configuration */
type PluginConfig = {
  /** Define which browsers must be supported */
  targets?: EnvOptions['targets']
  /** Define which polyfills to load from Polyfill.io */
  polyfills?: KnownPolyfill[]
  /** Use inlined `core-js@3` modules instead of Polyfill.io */
  corejs?: boolean
  /** Disable browserslint configuration */
  ignoreBrowserslistConfig?: boolean
  /**
   * The JS version your legacy bundle is expecting. Set this as low as possible,
   * but keep in mind which JS features your code needs.
   *
   * You can use https://kangax.github.io/compat-table/es2016plus/
   * to know which JS version has the features your bundle needs.
   *
   * @default viteConfig.esbuild.target || "es2020"
   */
  ecmaVersion?: string
}

export default (config: PluginConfig = {}): Plugin => {
  return {
    name: 'vite:legacy',
    apply: 'build',
    // Ensure this plugin runs before vite:html
    enforce: 'pre',
    configResolved(viteConfig) {
      let entryChunks: OutputChunk[]
      let legacyChunks = new Map<OutputChunk, OutputChunk>()

      this.generateBundle = async function (_, bundle) {
        entryChunks = Object.values(bundle).filter(
          asset => asset.type == 'chunk' && asset.isEntry
        ) as OutputChunk[]

        viteConfig.logger.info(chalk.cyan('creating legacy bundle...'))
        for (const entryChunk of entryChunks) {
          const legacyChunk = await createLegacyChunk(
            entryChunk,
            config,
            viteConfig
          )
          bundle[legacyChunk.fileName] = legacyChunk
          legacyChunks.set(entryChunk, legacyChunk)
        }
      }

      const target = resolveTarget(config, viteConfig)
      const renderScript = createScriptFactory(target, config)
      const getBasePath = (fileName: string) => viteConfig.base + fileName

      this.transformIndexHtml = html =>
        html.replace(
          /<script type="module" src="([^"]+)"><\/script>/g,
          (match, moduleId) => {
            const entryChunk = entryChunks.find(
              entryChunk => moduleId == getBasePath(entryChunk.fileName)
            )
            const legacyChunk = entryChunk && legacyChunks.get(entryChunk)
            return legacyChunk
              ? renderScript(
                  moduleId,
                  getBasePath(legacyChunk.fileName),
                  !config.corejs &&
                    /\bregeneratorRuntime\b/.test(legacyChunk.code)
                )
              : match
          }
        )
    },
  }
}

const regeneratorUrl = 'https://cdn.jsdelivr.net/npm/[email protected]'

// Only es2018+ are tested since the `script.noModule` check
// is enough for earlier ES targets.
const syntaxTests: { [target: string]: string } = {
  // Spread operator, dot-all regexp, async generator
  es2018: 'void ({...{}}, /0/s, async function*(){})',
  // Optional catch binding
  es2019: 'try{} catch{}',
  // Optional chaining
  es2020: '0?.$',
}

const getBabelEnv = ({
  targets = 'defaults',
  ignoreBrowserslistConfig,
  corejs,
}: PluginConfig): EnvOptions => ({
  bugfixes: true,
  useBuiltIns: corejs && 'usage',
  corejs: corejs ? 3 : undefined,
  targets,
  ignoreBrowserslistConfig,
})

/**
 * The script factory returns a script element that loads the modern bundle
 * when syntax requirements are met, else the legacy bundle is loaded.
 */
function createScriptFactory(target: string, config: PluginConfig) {
  const polyfills: string[] = (config.polyfills || [])
    .filter(name => {
      if (!knownPolyfills.includes(name)) {
        throw Error(`Unknown polyfill: "${name}"`)
      }
      return true
    })
    .sort()

  // Include polyfills for the expected JavaScript version.
  if (!config.corejs) {
    const targetYear = parseTargetYear(target)
    for (let year = Math.min(targetYear, 2019); year >= 2015; --year) {
      polyfills.unshift('es' + year)
    }
  }

  // Polyfills are only loaded for the legacy bundle.
  const polyfillHost = 'https://polyfill.io/v3/polyfill.min.js?version=3.53.1'
  const polyfillScript =
    polyfills.length > 0 &&
    `load('${polyfillHost}&features=${polyfills.join(',')}')`

  // The modern bundle is *not* loaded when its JavaScript version is unsupported.
  const syntaxTest = syntaxTests[target]

  // The modern bundle is *not* loaded when import/export syntax is unsupported.
  const moduleTest = 'script.noModule.$'

  return (
    modernBundleId: string,
    legacyBundleId: string,
    needsRegenerator: boolean
  ) => dedent`
    <script>
      (function() {
        var script = document.createElement('script')
        function load(src, type) {
          var s = script.cloneNode()
          if (type) s.type = type
          s.src = src
          document.head.appendChild(s)
        }
        try {
          ${joinLines(
            moduleTest,
            syntaxTest && `eval('${syntaxTest}')`,
            `load('${modernBundleId}', 'module')`
          )}
        } catch(e) {
          ${joinLines(
            polyfillScript,
            needsRegenerator && `load('${regeneratorUrl}')`,
            `load('${legacyBundleId}')`
          )}
        }
      })()
    </script>
  `
}

function joinLines(...lines: (string | false)[]) {
  return lines.filter(Boolean).join('\n')
}

function toArray<T>(value: T | T[]): T[] {
  return Array.isArray(value) ? value : [value]
}

function resolveTarget(config: PluginConfig, viteConfig: ViteConfig): string {
  let result = config.ecmaVersion
  if (!result) {
    let { target } = viteConfig.build
    if (!target) {
      target =
        typeof viteConfig.esbuild !== 'function' &&
        (viteConfig.esbuild || {}).target!
    }
    if (target) {
      result = toArray(target as string).find(value => /^es\d+$/i.test(value))
    }
  }
  if (result && /^es\d+$/i.test(result)) {
    return result.toLowerCase()
  }
  return 'es2020'
}

/** Convert `esbuildTarget` to a version year (eg: "es6" ➜ 2015). */
function parseTargetYear(target: string) {
  if (target == 'es5' || target == 'esnext') {
    throw Error('[vite-legacy] Unsupported "esbuildTarget" value: ${target}')
  }
  const version = Number(/\d+/.exec(target)![0])
  return version + (version < 2000 ? 2009 : 0)
}

async function createLegacyChunk(
  mainChunk: OutputChunk,
  config: PluginConfig,
  viteConfig: ViteConfig
): Promise<OutputChunk> {
  const viteBuild = viteConfig.build

  // Transform the modern bundle into a dinosaur.
  const transformed = await babel.transformAsync(mainChunk.code, {
    configFile: false,
    inputSourceMap: mainChunk.map ?? undefined,
    sourceMaps: viteBuild.sourcemap,
    presets: [[require('@babel/preset-env'), getBabelEnv(config)]],
    plugins: !config.corejs
      ? [require('@babel/plugin-transform-regenerator')]
      : null,
  })

  const { code, map } = transformed || {}
  if (!code) {
    throw Error('[vite-plugin-legacy] Failed to transform modern bundle')
  }

  const legacyPath = mainChunk.fileName.replace(/\.js$/, '.legacy.js')
  const legacyPlugins: RollupPlugin[] = []

  // core-js imports are CommonJS modules.
  if (config.corejs)
    legacyPlugins.push(
      commonJS({
        sourceMap: !!viteBuild.sourcemap,
      })
    )

  // Provide our transformed code to Rollup.
  legacyPlugins.push({
    name: 'vite-legacy:resolve',
    resolveId(id) {
      if (id == legacyPath) return id
      if (/^(core-js|regenerator-runtime)\//.test(id)) {
        return require.resolve(id)
      }
    },
    load(id) {
      if (id == legacyPath) {
        return { code, map }
      }
    },
  })

  // Use rollup-plugin-terser even if "minify" option is esbuild.
  if (viteBuild.minify)
    legacyPlugins.push(
      require('rollup-plugin-terser').terser(viteBuild.terserOptions)
    )

  const rollup = require('rollup').rollup as typeof import('rollup').rollup

  // Prepare the module graph.
  const bundle = await rollup({
    input: legacyPath,
    plugins: legacyPlugins,
  })

  // Generate the legacy bundle.
  const { output } = await bundle.generate({
    entryFileNames: legacyPath,
    format: 'iife',
    sourcemap: viteBuild.sourcemap,
    sourcemapExcludeSources: true,
    inlineDynamicImports: true,
  })

  return output[0]
}