// @ts-check
const path = require('path')
const { createHash } = require('crypto')
const { build } = require('vite')
const MagicString = require('magic-string').default

// lazy load babel since it's not used during dev
let babel
/**
 * @return {import('@babel/standalone')}
 */
const loadBabel = () => babel || (babel = require('@babel/standalone'))

// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
// DO NOT ALTER THIS CONTENT
const safari10NoModuleFix = `!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();`

const legacyPolyfillId = 'vite-legacy-polyfill'
const legacyEntryId = 'vite-legacy-entry'
const systemJSInlineCode = `System.import(document.getElementById('${legacyEntryId}').getAttribute('data-src'))`

const detectDynamicImportVarName = '__vite_is_dynamic_import_support'
const detectDynamicImportCode = `try{import("_").catch(()=>1);}catch(e){}window.${detectDynamicImportVarName}=true;`
const dynamicFallbackInlineCode = `!function(){if(window.${detectDynamicImportVarName})return;console.warn("vite: loading legacy build because dynamic import is unsupported, syntax error above should be ignored");var e=document.getElementById("${legacyPolyfillId}"),n=document.createElement("script");n.src=e.src,n.onload=function(){${systemJSInlineCode}},document.body.appendChild(n)}();`

const forceDynamicImportUsage = `export function __vite_legacy_guard(){import('data:text/javascript,')};`

const legacyEnvVarMarker = `__VITE_IS_LEGACY__`

/**
 * @param {import('.').Options} options
 * @returns {import('vite').Plugin[]}
 */
function viteLegacyPlugin(options = {}) {
  /**
   * @type {import('vite').ResolvedConfig}
   */
  let config
  const targets = options.targets || 'defaults'
  const genLegacy = options.renderLegacyChunks !== false
  const genDynamicFallback = genLegacy

  const debugFlags = (process.env.DEBUG || '').split(',')
  const isDebug =
    debugFlags.includes('vite:*') || debugFlags.includes('vite:legacy')

  const facadeToLegacyChunkMap = new Map()
  const facadeToLegacyPolyfillMap = new Map()
  const facadeToModernPolyfillMap = new Map()
  const modernPolyfills = new Set()
  // System JS relies on the Promise interface. It needs to be polyfilled for IE 11. (array.iterator is mandatory for supporting Promise.all)
  const DEFAULT_LEGACY_POLYFILL = [
    'core-js/modules/es.promise',
    'core-js/modules/es.array.iterator'
  ]
  const legacyPolyfills = new Set(DEFAULT_LEGACY_POLYFILL)

  if (Array.isArray(options.modernPolyfills)) {
    options.modernPolyfills.forEach((i) => {
      modernPolyfills.add(
        i.includes('/') ? `core-js/${i}` : `core-js/modules/${i}.js`
      )
    })
  }
  if (Array.isArray(options.polyfills)) {
    options.polyfills.forEach((i) => {
      if (i.startsWith(`regenerator`)) {
        legacyPolyfills.add(`regenerator-runtime/runtime.js`)
      } else {
        legacyPolyfills.add(
          i.includes('/') ? `core-js/${i}` : `core-js/modules/${i}.js`
        )
      }
    })
  }
  if (Array.isArray(options.additionalLegacyPolyfills)) {
    options.additionalLegacyPolyfills.forEach((i) => {
      legacyPolyfills.add(i)
    })
  }

  /**
   * @type {import('vite').Plugin}
   */
  const legacyConfigPlugin = {
    name: 'vite:legacy-config',

    apply: 'build',
    config(config) {
      if (!config.build) {
        config.build = {}
      }

      if (!config.build.cssTarget) {
        // Hint for esbuild that we are targeting legacy browsers when minifying CSS.
        // Full CSS compat table available at https://github.com/evanw/esbuild/blob/78e04680228cf989bdd7d471e02bbc2c8d345dc9/internal/compat/css_table.go
        // But note that only the `HexRGBA` feature affects the minify outcome.
        // HSL & rebeccapurple values will be minified away regardless the target.
        // So targeting `chrome61` suffices to fix the compatiblity issue.
        config.build.cssTarget = 'chrome61'
      }
    }
  }

  /**
   * @type {import('vite').Plugin}
   */
  const legacyGenerateBundlePlugin = {
    name: 'vite:legacy-generate-polyfill-chunk',
    apply: 'build',

    async generateBundle(opts, bundle) {
      if (config.build.ssr) {
        return
      }

      if (!isLegacyBundle(bundle, opts)) {
        if (!modernPolyfills.size) {
          return
        }
        isDebug &&
          console.log(
            `[@vitejs/plugin-legacy] modern polyfills:`,
            modernPolyfills
          )
        await buildPolyfillChunk(
          'polyfills-modern',
          modernPolyfills,
          bundle,
          facadeToModernPolyfillMap,
          config.build,
          options.externalSystemJS
        )
        return
      }

      if (!genLegacy) {
        return
      }

      // legacy bundle
      if (legacyPolyfills.size || genDynamicFallback) {
        if (!legacyPolyfills.has('es.promise')) {
          // check if the target needs Promise polyfill because SystemJS relies
          // on it
          detectPolyfills(`Promise.resolve()`, targets, legacyPolyfills)
        }

        isDebug &&
          console.log(
            `[@vitejs/plugin-legacy] legacy polyfills:`,
            legacyPolyfills
          )

        await buildPolyfillChunk(
          'polyfills-legacy',
          legacyPolyfills,
          bundle,
          facadeToLegacyPolyfillMap,
          // force using terser for legacy polyfill minification, since esbuild
          // isn't legacy-safe
          config.build,
          options.externalSystemJS
        )
      }
    }
  }

  /**
   * @type {import('vite').Plugin}
   */
  const legacyPostPlugin = {
    name: 'vite:legacy-post-process',
    enforce: 'post',
    apply: 'build',

    configResolved(_config) {
      if (_config.build.lib) {
        throw new Error('@vitejs/plugin-legacy does not support library mode.')
      }
      config = _config

      if (!genLegacy || config.build.ssr) {
        return
      }

      /**
       * @param {string | ((chunkInfo: import('rollup').PreRenderedChunk) => string)} fileNames
       * @param {string?} defaultFileName
       * @returns {string | ((chunkInfo: import('rollup').PreRenderedChunk) => string)}
       */
      const getLegacyOutputFileName = (
        fileNames,
        defaultFileName = '[name]-legacy.[hash].js'
      ) => {
        if (!fileNames) {
          return path.posix.join(config.build.assetsDir, defaultFileName)
        }

        return (chunkInfo) => {
          let fileName =
            typeof fileNames === 'function' ? fileNames(chunkInfo) : fileNames

          if (fileName.includes('[name]')) {
            // [name]-[hash].[format] -> [name]-legacy-[hash].[format]
            fileName = fileName.replace('[name]', '[name]-legacy')
          } else {
            // entry.js -> entry-legacy.js
            fileName = fileName.replace(/(.+)\.(.+)/, '$1-legacy.$2')
          }

          return fileName
        }
      }

      /**
       * @param {import('rollup').OutputOptions} options
       * @returns {import('rollup').OutputOptions}
       */
      const createLegacyOutput = (options = {}) => {
        return {
          ...options,
          format: 'system',
          entryFileNames: getLegacyOutputFileName(options.entryFileNames),
          chunkFileNames: getLegacyOutputFileName(options.chunkFileNames)
        }
      }

      const { rollupOptions } = config.build
      const { output } = rollupOptions
      if (Array.isArray(output)) {
        rollupOptions.output = [...output.map(createLegacyOutput), ...output]
      } else {
        rollupOptions.output = [createLegacyOutput(output), output || {}]
      }
    },

    renderChunk(raw, chunk, opts) {
      if (config.build.ssr) {
        return
      }

      if (!isLegacyChunk(chunk, opts)) {
        if (
          options.modernPolyfills &&
          !Array.isArray(options.modernPolyfills)
        ) {
          // analyze and record modern polyfills
          detectPolyfills(raw, { esmodules: true }, modernPolyfills)
        }

        const ms = new MagicString(raw)

        if (genDynamicFallback && chunk.isEntry) {
          ms.prepend(forceDynamicImportUsage)
        }

        if (raw.includes(legacyEnvVarMarker)) {
          const re = new RegExp(legacyEnvVarMarker, 'g')
          let match
          while ((match = re.exec(raw))) {
            ms.overwrite(
              match.index,
              match.index + legacyEnvVarMarker.length,
              `false`
            )
          }
        }

        if (config.build.sourcemap) {
          return {
            code: ms.toString(),
            map: ms.generateMap({ hires: true })
          }
        }
        return ms.toString()
      }

      if (!genLegacy) {
        return
      }

      // @ts-ignore avoid esbuild transform on legacy chunks since it produces
      // legacy-unsafe code - e.g. rewriting object properties into shorthands
      opts.__vite_skip_esbuild__ = true

      // @ts-ignore force terser for legacy chunks. This only takes effect if
      // minification isn't disabled, because that leaves out the terser plugin
      // entirely.
      opts.__vite_force_terser__ = true

      // @ts-ignore
      // In the `generateBundle` hook,
      // we'll delete the assets from the legacy bundle to avoid emitting duplicate assets.
      // But that's still a waste of computing resource.
      // So we add this flag to avoid emitting the asset in the first place whenever possible.
      opts.__vite_skip_asset_emit__ = true

      // @ts-ignore avoid emitting assets for legacy bundle
      const needPolyfills =
        options.polyfills !== false && !Array.isArray(options.polyfills)

      // transform the legacy chunk with @babel/preset-env
      const sourceMaps = !!config.build.sourcemap
      const { code, map } = loadBabel().transform(raw, {
        babelrc: false,
        configFile: false,
        compact: true,
        sourceMaps,
        inputSourceMap: sourceMaps && chunk.map,
        presets: [
          // forcing our plugin to run before preset-env by wrapping it in a
          // preset so we can catch the injected import statements...
          [
            () => ({
              plugins: [
                recordAndRemovePolyfillBabelPlugin(legacyPolyfills),
                replaceLegacyEnvBabelPlugin(),
                wrapIIFEBabelPlugin()
              ]
            })
          ],
          [
            'env',
            {
              targets,
              modules: false,
              bugfixes: true,
              loose: false,
              useBuiltIns: needPolyfills ? 'usage' : false,
              corejs: needPolyfills
                ? {
                    version: require('core-js/package.json').version,
                    proposals: false
                  }
                : undefined,
              shippedProposals: true,
              ignoreBrowserslistConfig: options.ignoreBrowserslistConfig
            }
          ]
        ]
      })

      return { code, map }
    },

    transformIndexHtml(html, { chunk }) {
      if (config.build.ssr) return
      if (!chunk) return
      if (chunk.fileName.includes('-legacy')) {
        // The legacy bundle is built first, and its index.html isn't actually
        // emitted. Here we simply record its corresponding legacy chunk.
        facadeToLegacyChunkMap.set(chunk.facadeModuleId, chunk.fileName)
        return
      }

      /**
       * @type {import('vite').HtmlTagDescriptor[]}
       */
      const tags = []
      const htmlFilename = chunk.facadeModuleId.replace(/\?.*$/, '')

      // 1. inject modern polyfills
      const modernPolyfillFilename = facadeToModernPolyfillMap.get(
        chunk.facadeModuleId
      )
      if (modernPolyfillFilename) {
        tags.push({
          tag: 'script',
          attrs: {
            type: 'module',
            src: `${config.base}${modernPolyfillFilename}`
          }
        })
      } else if (modernPolyfills.size) {
        throw new Error(
          `No corresponding modern polyfill chunk found for ${htmlFilename}`
        )
      }

      if (!genLegacy) {
        return { html, tags }
      }

      // 2. inject Safari 10 nomodule fix
      tags.push({
        tag: 'script',
        attrs: { nomodule: true },
        children: safari10NoModuleFix,
        injectTo: 'body'
      })

      // 3. inject legacy polyfills
      const legacyPolyfillFilename = facadeToLegacyPolyfillMap.get(
        chunk.facadeModuleId
      )
      if (legacyPolyfillFilename) {
        tags.push({
          tag: 'script',
          attrs: {
            nomodule: true,
            id: legacyPolyfillId,
            src: `${config.base}${legacyPolyfillFilename}`
          },
          injectTo: 'body'
        })
      } else if (legacyPolyfills.size) {
        throw new Error(
          `No corresponding legacy polyfill chunk found for ${htmlFilename}`
        )
      }

      // 4. inject legacy entry
      const legacyEntryFilename = facadeToLegacyChunkMap.get(
        chunk.facadeModuleId
      )
      if (legacyEntryFilename) {
        tags.push({
          tag: 'script',
          attrs: {
            nomodule: true,
            // we set the entry path on the element as an attribute so that the
            // script content will stay consistent - which allows using a constant
            // hash value for CSP.
            id: legacyEntryId,
            'data-src': config.base + legacyEntryFilename
          },
          children: systemJSInlineCode,
          injectTo: 'body'
        })
      } else {
        throw new Error(
          `No corresponding legacy entry chunk found for ${htmlFilename}`
        )
      }

      // 5. inject dynamic import fallback entry
      if (genDynamicFallback && legacyPolyfillFilename && legacyEntryFilename) {
        tags.push({
          tag: 'script',
          attrs: { type: 'module' },
          children: detectDynamicImportCode,
          injectTo: 'head'
        })
        tags.push({
          tag: 'script',
          attrs: { type: 'module' },
          children: dynamicFallbackInlineCode,
          injectTo: 'head'
        })
      }

      return {
        html,
        tags
      }
    },

    generateBundle(opts, bundle) {
      if (config.build.ssr) {
        return
      }

      if (isLegacyBundle(bundle, opts)) {
        // avoid emitting duplicate assets
        for (const name in bundle) {
          if (bundle[name].type === 'asset') {
            delete bundle[name]
          }
        }
      }
    }
  }

  let envInjectionFailed = false
  /**
   * @type {import('vite').Plugin}
   */
  const legacyEnvPlugin = {
    name: 'vite:legacy-env',

    config(config, env) {
      if (env) {
        return {
          define: {
            'import.meta.env.LEGACY':
              env.command === 'serve' || config.build.ssr
                ? false
                : legacyEnvVarMarker
          }
        }
      } else {
        envInjectionFailed = true
      }
    },

    configResolved(config) {
      if (envInjectionFailed) {
        config.logger.warn(
          `[@vitejs/plugin-legacy] import.meta.env.LEGACY was not injected due ` +
            `to incompatible vite version (requires vite@^2.0.0-beta.69).`
        )
      }
    }
  }

  return [
    legacyConfigPlugin,
    legacyGenerateBundlePlugin,
    legacyPostPlugin,
    legacyEnvPlugin
  ]
}

/**
 * @param {string} code
 * @param {any} targets
 * @param {Set<string>} list
 */
function detectPolyfills(code, targets, list) {
  const { ast } = loadBabel().transform(code, {
    ast: true,
    babelrc: false,
    configFile: false,
    presets: [
      [
        'env',
        {
          targets,
          modules: false,
          useBuiltIns: 'usage',
          corejs: { version: 3, proposals: false },
          shippedProposals: true,
          ignoreBrowserslistConfig: true
        }
      ]
    ]
  })
  for (const node of ast.program.body) {
    if (node.type === 'ImportDeclaration') {
      const source = node.source.value
      if (
        source.startsWith('core-js/') ||
        source.startsWith('regenerator-runtime/')
      ) {
        list.add(source)
      }
    }
  }
}

/**
 * @param {string} name
 * @param {Set<string>} imports
 * @param {import('rollup').OutputBundle} bundle
 * @param {Map<string, string>} facadeToChunkMap
 * @param {import('vite').BuildOptions} buildOptions
 */
async function buildPolyfillChunk(
  name,
  imports,
  bundle,
  facadeToChunkMap,
  buildOptions,
  externalSystemJS
) {
  let { minify, assetsDir } = buildOptions
  minify = minify ? 'terser' : false
  const res = await build({
    // so that everything is resolved from here
    root: __dirname,
    configFile: false,
    logLevel: 'error',
    plugins: [polyfillsPlugin(imports, externalSystemJS)],
    build: {
      write: false,
      target: false,
      minify,
      assetsDir,
      rollupOptions: {
        input: {
          [name]: polyfillId
        },
        output: {
          format: name.includes('legacy') ? 'iife' : 'es',
          manualChunks: undefined
        }
      }
    }
  })
  const _polyfillChunk = Array.isArray(res) ? res[0] : res
  if (!('output' in _polyfillChunk)) return
  const polyfillChunk = _polyfillChunk.output[0]

  // associate the polyfill chunk to every entry chunk so that we can retrieve
  // the polyfill filename in index html transform
  for (const key in bundle) {
    const chunk = bundle[key]
    if (chunk.type === 'chunk' && chunk.facadeModuleId) {
      facadeToChunkMap.set(chunk.facadeModuleId, polyfillChunk.fileName)
    }
  }

  // add the chunk to the bundle
  bundle[polyfillChunk.name] = polyfillChunk
}

const polyfillId = '\0vite/legacy-polyfills'

/**
 * @param {Set<string>} imports
 * @return {import('rollup').Plugin}
 */
function polyfillsPlugin(imports, externalSystemJS) {
  return {
    name: 'vite:legacy-polyfills',
    resolveId(id) {
      if (id === polyfillId) {
        return id
      }
    },
    load(id) {
      if (id === polyfillId) {
        return (
          [...imports].map((i) => `import "${i}";`).join('') +
          (externalSystemJS ? '' : `import "systemjs/dist/s.min.js";`)
        )
      }
    }
  }
}

/**
 * @param {import('rollup').RenderedChunk} chunk
 * @param {import('rollup').NormalizedOutputOptions} options
 */
function isLegacyChunk(chunk, options) {
  return options.format === 'system' && chunk.fileName.includes('-legacy')
}

/**
 * @param {import('rollup').OutputBundle} bundle
 * @param {import('rollup').NormalizedOutputOptions} options
 */
function isLegacyBundle(bundle, options) {
  if (options.format === 'system') {
    const entryChunk = Object.values(bundle).find(
      (output) => output.type === 'chunk' && output.isEntry
    )

    return !!entryChunk && entryChunk.fileName.includes('-legacy')
  }

  return false
}

/**
 * @param {Set<string>} polyfills
 */
function recordAndRemovePolyfillBabelPlugin(polyfills) {
  return ({ types: t }) => ({
    name: 'vite-remove-polyfill-import',
    post({ path }) {
      path.get('body').forEach((p) => {
        if (t.isImportDeclaration(p)) {
          polyfills.add(p.node.source.value)
          p.remove()
        }
      })
    }
  })
}

function replaceLegacyEnvBabelPlugin() {
  return ({ types: t }) => ({
    name: 'vite-replace-env-legacy',
    visitor: {
      Identifier(path) {
        if (path.node.name === legacyEnvVarMarker) {
          path.replaceWith(t.booleanLiteral(true))
        }
      }
    }
  })
}

function wrapIIFEBabelPlugin() {
  return ({ types: t, template }) => {
    const buildIIFE = template(';(function(){%%body%%})();')

    return {
      name: 'vite-wrap-iife',
      post({ path }) {
        if (!this.isWrapped) {
          this.isWrapped = true
          path.replaceWith(t.program(buildIIFE({ body: path.node.body })))
        }
      }
    }
  }
}

module.exports = viteLegacyPlugin

viteLegacyPlugin.default = viteLegacyPlugin

viteLegacyPlugin.cspHashes = [
  createHash('sha256').update(safari10NoModuleFix).digest('base64'),
  createHash('sha256').update(systemJSInlineCode).digest('base64'),
  createHash('sha256').update(detectDynamicImportCode).digest('base64'),
  createHash('sha256').update(dynamicFallbackInlineCode).digest('base64')
]
