import type { HtmlTagDescriptor } from 'vite'
import type { Plugin } from 'vitest/config'
import type { ParentBrowserProject } from './projectParent'
import { createReadStream, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dynamicImportPlugin } from '@vitest/mocker/node'
import { distClientRoot as uiClientRoot } from '@vitest/ui'
import { toArray } from '@vitest/utils/helpers'
import MagicString from 'magic-string'
import { dirname, join, resolve } from 'pathe'
import sirv from 'sirv'
import {
  isFileServingAllowed,
  isValidApiRequest,
  resolveApiServerConfig,
  resolveFsAllow,
  rolldownVersion,
  distDir as vitestDist,
} from 'vitest/node'
import { distRoot } from './constants'
import { createOrchestratorMiddleware } from './middlewares/orchestratorMiddleware'
import { createTesterMiddleware } from './middlewares/testerMiddleware'
import BrowserContext from './plugins/pluginContext'

export type { BrowserCommand } from 'vitest/node'

const versionRegexp = /(?:\?|&)v=\w{8}/

export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
  function isPackageExists(pkg: string, root: string) {
    return parentServer.vitest.packageInstaller.isPackageExists?.(pkg, {
      paths: [root],
    })
  }

  return [
    {
      enforce: 'pre',
      name: 'vitest:browser',
      async configureServer(server) {
        parentServer.setServer(server)

        // eslint-disable-next-line prefer-arrow-callback
        server.middlewares.use(function vitestHeaders(_req, res, next) {
          const headers = server.config.server.headers
          if (headers) {
            for (const name in headers) {
              res.setHeader(name, headers[name]!)
            }
          }
          next()
        })
        // strip _vitest_original query added by importActual so that
        // the plugin pipeline sees the original import id (e.g. virtual modules's load hook).
        server.middlewares.use((req, _res, next) => {
          if (
            req.url?.includes('_vitest_original')
            && parentServer.project.config.browser.provider?.name === 'playwright'
          ) {
            req.url = req.url
              .replace(/[?&]_vitest_original(?=[&#]|$)/, '')
              .replace(/[?&]ext\b[^&#]*/, '')
              .replace(/\?$/, '')
          }
          next()
        })
        server.middlewares.use(createOrchestratorMiddleware(parentServer))
        server.middlewares.use(createTesterMiddleware(parentServer))

        server.middlewares.use(
          `${base}favicon.svg`,
          (_, res) => {
            const content = readFileSync(resolve(distRoot, 'client/favicon.svg'))
            res.write(content, 'utf-8')
            res.end()
          },
        )

        // Serve coverage HTML at ./coverage if configured
        const coverageHtmlDir = parentServer.vitest.config.coverage?.htmlDir
        if (coverageHtmlDir) {
          server.middlewares.use(
            '/__vitest_test__/coverage',
            sirv(coverageHtmlDir, {
              single: true,
              dev: true,
              setHeaders: (res) => {
                const csp = res.getHeader('Content-Security-Policy')
                if (typeof csp === 'string') {
                  // add frame-ancestors to allow the iframe to be loaded by Vitest,
                  // but keep the rest of the CSP
                  res.setHeader(
                    'Content-Security-Policy',
                    csp.replace(/frame-ancestors [^;]+/, 'frame-ancestors *'),
                  )
                }
                res.setHeader(
                  'Cache-Control',
                  'public,max-age=0,must-revalidate',
                )
              },
            }),
          )
        }

        server.middlewares.use((req, res, next) => {
          // 9000 mega head move
          // Vite always caches optimized dependencies, but users might mock
          // them in _some_ tests, while keeping original modules in others
          // there is no way to configure that in Vite, so we patch it here
          // to always ignore the cache-control set by Vite in the next middleware
          if (req.url && versionRegexp.test(req.url) && !req.url.includes('chunk-')) {
            res.setHeader('Cache-Control', 'no-cache')
            const setHeader = res.setHeader.bind(res)
            res.setHeader = function (name, value) {
              if (name === 'Cache-Control') {
                return res
              }
              return setHeader(name, value)
            }
          }
          next()
        })
        // handle attachments the same way as in packages/ui/node/index.ts
        server.middlewares.use((req, res, next) => {
          if (!req.url) {
            return next()
          }

          const url = new URL(req.url, 'http://localhost')

          if (url.pathname !== '/__vitest_attachment__') {
            return next()
          }

          const path = url.searchParams.get('path')
          const contentType = url.searchParams.get('contentType')

          if (!isValidApiRequest(parentServer.config, req) || !contentType || !path) {
            return next()
          }

          const fsPath = decodeURIComponent(path)

          if (!isFileServingAllowed(parentServer.vite.config, fsPath)) {
            return next()
          }

          try {
            res.setHeader(
              'content-type',
              contentType,
            )

            return createReadStream(fsPath)
              .pipe(res)
              .on('close', () => res.end())
          }
          catch (err) {
            return next(err)
          }
        })
      },
    },
    {
      name: 'vitest:browser:tests',
      enforce: 'pre',
      async config() {
        // this plugin can be used in different projects, but all of them
        // have the same `include` pattern, so it doesn't matter which project we use
        const project = parentServer.project
        // only glob benchmarks when a browser-enabled bench project exists in
        // the workspace — keeps the optimizeDeps entries symmetrical across
        // the test/bench project clones so the optimizer doesn't re-scan when
        // the user switches modes
        const hasBrowserBenchProject = parentServer.vitest.projects.some(p =>
          p.config.browser.enabled && p.config.benchmark.enabled,
        )
        const benchInclude = hasBrowserBenchProject
          ? project.config.benchmark.include
          : []
        const dir = project.config.dir || project.config.root
        const [{ testFiles: browserTestFiles }, browserBenchFiles] = await Promise.all([
          project.globTestFiles(),
          benchInclude.length > 0
            ? project.globFiles(
                benchInclude,
                project.config.benchmark.exclude ?? project.config.exclude,
                dir,
              )
            : [],
        ])
        const setupFiles = toArray(project.config.setupFiles)

        // replace env values - cannot be reassign at runtime
        const define: Record<string, string> = {}
        for (const env in (project.config.env || {})) {
          const stringValue = JSON.stringify(project.config.env[env])
          define[`import.meta.env.${env}`] = stringValue
        }

        const entries: string[] = [
          ...new Set([...browserTestFiles, ...browserBenchFiles]),
          ...setupFiles,
          resolve(vitestDist, 'index.js'),
          resolve(vitestDist, 'browser.js'),
          resolve(vitestDist, 'runners.js'),
          resolve(vitestDist, 'utils.js'),
          ...(project.config.snapshotSerializers || []),
        ]

        const exclude = [
          'vitest',
          'vitest/browser',
          'vitest/internal/browser',
          'vite/module-runner',
          '@vitest/browser/utils',
          '@vitest/browser/context',
          '@vitest/browser/client',
          '@vitest/utils',
          '@vitest/utils/source-map',
          '@vitest/spy',
          '@vitest/utils/error',
          'std-env',
          'tinybench',
          'tinyspy',
          'tinyrainbow',
          'pathe',
          'msw',
          'msw/browser',
        ]

        if (typeof project.config.diff === 'string') {
          entries.push(project.config.diff)
        }

        if (parentServer.vitest.coverageProvider) {
          const coverage = parentServer.vitest._coverageOptions
          const provider = coverage.provider
          if (provider === 'v8') {
            const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root])
            if (path) {
              entries.push(path)
              exclude.push('@vitest/coverage-v8/browser')
            }
          }
          else if (provider === 'istanbul') {
            const path = tryResolve('@vitest/coverage-istanbul', [parentServer.config.root])
            if (path) {
              entries.push(path)
              exclude.push('@vitest/coverage-istanbul')
            }
          }
          else if (provider === 'custom' && coverage.customProviderModule) {
            entries.push(coverage.customProviderModule)
          }
        }

        const include = [
          'vitest > expect-type',
          'vitest > magic-string',
          'vitest > chai',
        ]

        const provider = parentServer.config.browser.provider || [...parentServer.children][0]?.provider
        if (provider?.name === 'preview') {
          include.push(
            '@vitest/browser-preview > @testing-library/user-event',
            '@vitest/browser-preview > @testing-library/dom',
          )
        }

        const fileRoot = browserTestFiles[0] ? dirname(browserTestFiles[0]) : project.config.root

        const svelte = isPackageExists('vitest-browser-svelte', fileRoot)
        if (svelte) {
          exclude.push('vitest-browser-svelte')
        }

        // since we override the resolution in the esbuild plugin, Vite can no longer optimizer it
        const vue = isPackageExists('vitest-browser-vue', fileRoot)
        if (vue) {
          // we override them in the esbuild plugin so optimizer can no longer intercept it
          include.push(
            'vitest-browser-vue',
            'vitest-browser-vue > @vue/test-utils',
            'vitest-browser-vue > @vue/test-utils > @vue/compiler-core',
          )
        }
        const vueTestUtils = isPackageExists('@vue/test-utils', fileRoot)
        if (vueTestUtils) {
          include.push('@vue/test-utils')
        }

        const otelConfig = project.config.experimental.openTelemetry
        if (otelConfig?.enabled && otelConfig.browserSdkPath) {
          entries.push(otelConfig.browserSdkPath)
          include.push('@opentelemetry/api')
        }
        else {
          exclude.push('@opentelemetry/api')
        }

        return {
          define,
          resolve: {
            dedupe: ['vitest'],
          },
          optimizeDeps: {
            entries,
            exclude,
            include,
          },
        }
      },
      async resolveId(id) {
        if (!/\?browserv=\w+$/.test(id)) {
          return
        }

        let useId = id.slice(0, id.lastIndexOf('?'))
        if (useId.startsWith('/@fs/')) {
          useId = useId.slice(5)
        }

        if (/^\w:/.test(useId)) {
          useId = useId.replace(/\\/g, '/')
        }

        return useId
      },
    },
    {
      name: 'vitest:browser:resolve-virtual',
      async resolveId(rawId) {
        if (rawId === '/mockServiceWorker.js') {
          return this.resolve('msw/mockServiceWorker.js', distRoot, {
            skipSelf: true,
          })
        }
      },
    },
    {
      name: 'vitest:browser:assets',
      configureServer(server) {
        server.middlewares.use(
          '/__vitest__',
          sirv(uiClientRoot),
        )
      },
      resolveId(id) {
        if (id.startsWith('/__vitest_browser__/')) {
          return resolve(distRoot, 'client', id.slice(1))
        }
      },
      transform(code, id) {
        if (id.includes(parentServer.vite.config.cacheDir) && id.includes('loupe.js')) {
          // loupe bundle has a nasty require('util') call that leaves a warning in the console
          const utilRequire = 'nodeUtil = require_util();'
          return code.replace(utilRequire, ' '.repeat(utilRequire.length))
        }
      },
    },
    BrowserContext(parentServer),
    dynamicImportPlugin({
      globalThisAccessor: '"__vitest_browser_runner__"',
      filter(id, environment) {
        if (environment.name !== 'client') {
          return false
        }
        if (id.includes(distRoot)) {
          return false
        }
        return true
      },
    }),
    {
      name: 'vitest:browser:config',
      enforce: 'post',
      async config(viteConfig) {
        // Enables using ignore hint for coverage providers with @preserve keyword
        // Only set esbuild options when not using rolldown-vite (Vite 8+),
        // which uses oxc for transformation instead of esbuild
        if (!rolldownVersion && viteConfig.esbuild !== false) {
          viteConfig.esbuild ||= {}
          viteConfig.esbuild.legalComments = 'inline'
        }

        const defaultPort = parentServer.vitest.state._data.browserLastPort++

        const api = resolveApiServerConfig(
          viteConfig.test?.browser || {},
          defaultPort,
        )

        viteConfig.server = {
          ...viteConfig.server,
          port: defaultPort,
          ...api,
          middlewareMode: false,
          open: false,
        }
        viteConfig.server.fs ??= {}
        viteConfig.server.fs.allow = viteConfig.server.fs.allow || []
        viteConfig.server.fs.allow.push(
          ...resolveFsAllow(
            parentServer.vitest.config.root,
            parentServer.vitest.vite.config.configFile,
          ),
          distRoot,
        )

        return {
          resolve: {
            alias: viteConfig.test?.alias,
          },
        }
      },
    },
    {
      name: 'vitest:browser:in-source-tests',
      transform: {
        filter: {
          code: /import\.meta\.vitest/,
        },
        handler(code, id) {
          const filename = cleanUrl(id)

          if (!code.includes('import.meta.vitest')) {
            return
          }
          const s = new MagicString(code, { filename })
          s.prepend(
            `Object.defineProperty(import.meta, 'vitest', { get() { return typeof __vitest_worker__ !== 'undefined' && __vitest_worker__.filepath === "${filename.replace(/"/g, '\\"')}" ? __vitest_index__ : undefined } });\n`,
          )
          return {
            code: s.toString(),
            map: s.generateMap({ hires: true }),
          }
        },
      },
    },
    {
      name: 'vitest:browser:worker',
      transform(code, id, _options) {
        // https://github.com/vitejs/vite/blob/ba56cf43b5480f8519349f7d7fe60718e9af5f1a/packages/vite/src/node/plugins/worker.ts#L46
        if (/(?:\?|&)worker_file&type=\w+(?:&|$)/.test(id)) {
          const s = new MagicString(code)
          s.prepend('globalThis.__vitest_browser_runner__ = { wrapDynamicImport: f => f() };\n')
          return {
            code: s.toString(),
            map: s.generateMap({ hires: 'boundary' }),
          }
        }
      },
    },
    {
      name: 'vitest:browser:transform-tester-html',
      enforce: 'pre',
      async transformIndexHtml(html, ctx) {
        const projectBrowser = [...parentServer.children].find((server) => {
          return ctx.filename === server.testerFilepath
        })
        if (!projectBrowser) {
          return
        }

        const stateJs = typeof parentServer.stateJs === 'string'
          ? parentServer.stateJs
          : await parentServer.stateJs

        const testerTags: HtmlTagDescriptor[] = []

        const isDefaultTemplate = resolve(distRoot, 'client/tester/tester.html') === projectBrowser.testerFilepath
        if (!isDefaultTemplate) {
          const manifestContent = parentServer.manifest instanceof Promise
            ? await parentServer.manifest
            : parentServer.manifest
          const testerEntry = manifestContent['tester/tester.html']

          testerTags.push({
            tag: 'script',
            attrs: {
              type: 'module',
              crossorigin: '',
              src: `${parentServer.base}${testerEntry.file}`,
            },
            injectTo: 'head',
          })

          for (const importName of testerEntry.imports || []) {
            const entryManifest = manifestContent[importName]
            if (entryManifest) {
              testerTags.push(
                {
                  tag: 'link',
                  attrs: {
                    href: `${parentServer.base}${entryManifest.file}`,
                    rel: 'modulepreload',
                    crossorigin: '',
                  },
                  injectTo: 'head',
                },
              )
            }
          }
        }
        else {
          // inject the reset style only in the default template,
          // allowing users to customize the style in their own template
          testerTags.push({
            tag: 'style',
            children: `
html {
  padding: 0;
  margin: 0;
}
body {
  padding: 0;
  margin: 0;
  min-height: 100vh;
}`,
            injectTo: 'head',
          })
        }

        return [
          {
            tag: 'script',
            children: '{__VITEST_INJECTOR__}',
            injectTo: 'head-prepend' as const,
          },
          {
            tag: 'script',
            children: stateJs,
            injectTo: 'head-prepend',
          } as const,
          {
            tag: 'script',
            attrs: {
              type: 'module',
              src: parentServer.errorCatcherUrl,
            },
            injectTo: 'head' as const,
          },
          {
            tag: 'script',
            attrs: {
              type: 'module',
              src: parentServer.matchersUrl,
            },
            injectTo: 'head' as const,
          },
          ...parentServer.initScripts.map(script => ({
            tag: 'script',
            attrs: {
              type: 'module',
              src: join('/@fs/', script),
            },
            injectTo: 'head',
          } as const)),
          ...testerTags,
        ].filter(s => s != null)
      },
    },
    {
      name: 'vitest:browser:support-testing-library',
      enforce: 'pre',
      config() {
        const rolldownPlugin = {
          name: 'vue-test-utils-rewrite',
          resolveId: {
            // test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object
            // compiler-core: only CJS version allows slots as strings
            filter: { id: /^@vue\/(test-utils|compiler-core)$/ },
            handler(source: string, importer: string) {
              const resolved = getRequire().resolve(source, {
                paths: [importer!],
              })
              return resolved
            },
          },
        }
        const esbuildPlugin = {
          name: 'test-utils-rewrite',
          // "any" because vite doesn't expose any types for this
          setup(build: any) {
            // test-utils: resolve to CJS instead of the browser because the browser version expects a global Vue object
            // compiler-core: only CJS version allows slots as strings
            build.onResolve({ filter: /^@vue\/(test-utils|compiler-core)$/ }, (args: any) => {
              const resolved = getRequire().resolve(args.path, {
                paths: [args.importer],
              })
              return { path: resolved }
            })
          },
        }

        return {
          optimizeDeps: rolldownVersion
            ? { rolldownOptions: { plugins: [rolldownPlugin] } }
            : { esbuildOptions: { plugins: [esbuildPlugin] } },
        }
      },
    },
    {
      name: 'vitest:browser:__vitest_browser_import_meta_env_init__',
      transform: {
        handler(code) {
          // this transform runs after `vitest:meta-env-replacer` so that
          // `import.meta.env` will be handled by Vite import analysis to match behavior.
          if (code.includes('__vitest_browser_import_meta_env_init__')) {
            return code.replace('__vitest_browser_import_meta_env_init__', 'import.meta.env')
          }
        },
      },
    },
  ]
}

function tryResolve(path: string, paths: string[]) {
  try {
    const _require = getRequire()
    return _require.resolve(path, { paths })
  }
  catch {
    return undefined
  }
}

let _require: NodeRequire
function getRequire() {
  if (!_require) {
    _require = createRequire(import.meta.url)
  }
  return _require
}

const postfixRE = /[?#].*$/
function cleanUrl(url: string): string {
  return url.replace(postfixRE, '')
}
