import type { CoverageMap, CoverageSummary } from 'istanbul-lib-coverage'
import type { TransformResult } from 'vite'
import type { Vitest } from '../node/core'
import type { CoverageModuleLoader, CoverageOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions } from '../node/types/coverage'
import type { SerializedCoverageConfig } from '../runtime/config'
import type { AfterSuiteRunMeta } from '../types/general'
import type { TestProject } from './project'
import { createHash } from 'node:crypto'
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
import module from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { cleanUrl, slash } from '@vitest/utils/helpers'
import { relative, resolve } from 'pathe'
import pm from 'picomatch'
import { glob } from 'tinyglobby'
import c from 'tinyrainbow'
import { coverageConfigDefaults } from '../defaults'
import { resolveCoverageReporters } from '../node/config/resolveConfig'
import { getCoverageFilesDirectory, resolveCoverageProviderModule } from '../utils/coverage'

type Threshold = 'lines' | 'functions' | 'statements' | 'branches'

interface ResolvedThreshold {
  coverageMap: CoverageMap
  name: string
  thresholds: Partial<Record<Threshold, number | undefined>>
  /** When `true`, check `thresholds` against each file instead of the aggregate. */
  perFile: boolean
  /** Additional per-file-only minimums (object form of `perFile`), or `null`. */
  perFileThresholds: Partial<Record<Threshold, number | undefined>> | null
}

/**
 * Holds info about raw coverage results that are stored on file system:
 *
 * ```json
 * "project-a": {
 *   "web": {
 *     "tests/math.test.ts": "coverage-1.json",
 *     "tests/utils.test.ts": "coverage-2.json",
 * //                          ^^^^^^^^^^^^^^^ Raw coverage on file system
 *   },
 *   "ssr": { ... },
 *   "browser": { ... },
 * },
 * "project-b": ...
 * ```
 */
type CoverageFiles = Map<
  NonNullable<AfterSuiteRunMeta['projectName']> | symbol,
  Record<
    AfterSuiteRunMeta['environment'],
    { [TestFilenames: string]: string }
  >
>
type Entries<T> = [keyof T, T[keyof T]][]

const THRESHOLD_KEYS: Readonly<Threshold[]> = [
  'lines',
  'functions',
  'statements',
  'branches',
]
const GLOBAL_THRESHOLDS_KEY = 'global'
const DEFAULT_PROJECT: unique symbol = Symbol.for('default-project')

export async function getCoverageProvider(
  options: SerializedCoverageConfig | undefined,
  loader: CoverageModuleLoader,
): Promise<CoverageProvider | null> {
  const coverageModule = await resolveCoverageProviderModule(options, loader)

  if (coverageModule) {
    return coverageModule.getProvider()
  }

  return null
}

export class BaseCoverageProvider {
  ctx!: Vitest
  readonly name!: 'v8' | 'istanbul'
  version!: string
  options!: ResolvedCoverageOptions
  globCache: Map<string, boolean> = new Map()
  autoUpdateMarker = '\n// __VITEST_COVERAGE_MARKER__'
  globMatchers?: { matchExclude: (file: string) => boolean; matchInclude: (file: string) => boolean }

  coverageFiles: CoverageFiles = new Map()
  coverageFilesDirectory!: string
  reportsDirectoryLock!: ReportsDirectoryLock
  roots: string[] = []
  changedFiles?: string[]

  _initialize(ctx: Vitest): void {
    this.ctx = ctx

    if (ctx.version !== this.version) {
      ctx.logger.warn(
        c.yellow(
          `Loaded ${c.inverse(c.yellow(` vitest@${ctx.version} `))} and ${c.inverse(c.yellow(` @vitest/coverage-${this.name}@${this.version} `))}.`
          + '\nRunning mixed versions is not supported and may lead into bugs'
          + '\nUpdate your dependencies and make sure the versions match.',
        ),
      )
    }

    const config = ctx._coverageOptions

    this.globMatchers = undefined

    this.options = {
      ...coverageConfigDefaults,

      // User's options
      ...config,

      // Resolved fields
      provider: this.name,
      reportsDirectory: resolve(
        ctx.config.root,
        config.reportsDirectory || coverageConfigDefaults.reportsDirectory,
      ),
      reporter: resolveCoverageReporters(
        config.reporter || coverageConfigDefaults.reporter,
      ),
      thresholds: config.thresholds && {
        ...config.thresholds,
        lines: config.thresholds['100'] ? 100 : config.thresholds.lines,
        branches: config.thresholds['100'] ? 100 : config.thresholds.branches,
        functions: config.thresholds['100'] ? 100 : config.thresholds.functions,
        statements: config.thresholds['100'] ? 100 : config.thresholds.statements,
      },
    }

    this.coverageFilesDirectory = getCoverageFilesDirectory(
      this.options.reportsDirectory,
      this.ctx.config.shard,
    )
    this.reportsDirectoryLock = new ReportsDirectoryLock(resolve(this.options.reportsDirectory))

    // If --project filter is set pick only roots of resolved projects
    this.roots = ctx.config.project?.length
      ? [...new Set(ctx.projects.map(project => project.config.root))]
      : [ctx.config.root]
  }

  /**
   * Check if file matches `coverage.include` but not `coverage.exclude`
   */
  isIncluded(_filename: string, root?: string): boolean {
    const roots = root ? [root] : this.roots

    const filename = slash(cleanUrl(_filename))
    const cacheHit = this.globCache.get(filename)

    if (cacheHit !== undefined) {
      return cacheHit
    }

    const matchingRoot = roots.find(root => filename.startsWith(`${slash(root)}/`) || filename === slash(root))

    // File outside project root with default allowExternal
    if (this.options.allowExternal === false && !matchingRoot) {
      this.globCache.set(filename, false)

      return false
    }

    const relativeFilename = matchingRoot ? relative(matchingRoot, filename) : filename

    const { matchExclude, matchInclude } = this.getGlobMatchers()

    if (matchExclude(relativeFilename)) {
      this.globCache.set(filename, false)
      return false
    }

    // By default `coverage.include` matches all files, except "coverage.exclude"
    let included = matchInclude(relativeFilename)

    if (included && this.changedFiles) {
      included = this.changedFiles.includes(filename)
    }

    this.globCache.set(filename, included)

    return included
  }

  /**
   * Compile `coverage.include`/`coverage.exclude` into reusable matchers once.
   * `picomatch.isMatch(file, patterns, options)` recompiles the patterns on
   * every call, which dominates the filtering step on large test suites.
   */
  private getGlobMatchers(): { matchExclude: (file: string) => boolean; matchInclude: (file: string) => boolean } {
    if (!this.globMatchers) {
      const exclude = this.options.exclude
      const include = this.options.include

      this.globMatchers = {
        matchExclude: exclude.length ? pm(exclude, { dot: true }) : () => false,
        matchInclude: include ? pm(include, { dot: true, ignore: exclude }) : () => true,
      }
    }

    return this.globMatchers
  }

  private async getUntestedFilesByRoot(
    testedFiles: string[],
    include: string[],
    root: string,
  ): Promise<string[]> {
    let includedFiles = await glob(include, {
      cwd: root,
      ignore: [...this.options.exclude, ...testedFiles.map(file => slash(file))],
      absolute: true,
      dot: true,
      onlyFiles: true,
    })

    // Run again through picomatch as tinyglobby's exclude pattern is different ({ "exclude": ["math"] } should ignore "src/math.ts")
    includedFiles = includedFiles.filter(file => this.isIncluded(file, root))

    if (this.changedFiles) {
      includedFiles = this.changedFiles.filter(file => includedFiles.includes(file))
    }

    return includedFiles.map(file => slash(path.resolve(root, file)))
  }

  async getUntestedFiles(testedFiles: string[]): Promise<string[]> {
    if (this.options.include == null) {
      return []
    }

    const rootMapper = this.getUntestedFilesByRoot.bind(this, testedFiles, this.options.include)

    const matrix = await Promise.all(this.roots.map(rootMapper))

    return matrix.flatMap(files => files)
  }

  createCoverageMap(): CoverageMap {
    throw new Error('BaseReporter\'s createCoverageMap was not overwritten')
  }

  async generateReports(_: CoverageMap, __: boolean | undefined): Promise<void> {
    throw new Error('BaseReporter\'s generateReports was not overwritten')
  }

  async parseConfigModule(_: string): Promise<{ generate: () => { code: string } }> {
    throw new Error('BaseReporter\'s parseConfigModule was not overwritten')
  }

  resolveOptions(): ResolvedCoverageOptions {
    return this.options
  }

  async clean(clean = true): Promise<void> {
    await this.reportsDirectoryLock.acquire()

    if (clean && existsSync(this.options.reportsDirectory)) {
      await fs.rm(this.options.reportsDirectory, {
        recursive: true,
        force: true,
        maxRetries: 10,
      })
    }

    if (existsSync(this.coverageFilesDirectory)) {
      await fs.rm(this.coverageFilesDirectory, {
        recursive: true,
        force: true,
        maxRetries: 10,
      })
    }

    await fs.mkdir(this.coverageFilesDirectory, { recursive: true })

    this.coverageFiles = new Map()
  }

  onAfterSuiteRun({ coverage, environment, projectName, testFiles }: AfterSuiteRunMeta): void {
    if (!coverage) {
      return
    }

    if (typeof coverage !== 'string') {
      throw new TypeError(`Expected string coverage payload, received ${typeof coverage}, ${JSON.stringify(coverage)}`)
    }
    const filename = coverage

    let entry = this.coverageFiles.get(projectName || DEFAULT_PROJECT)

    if (!entry) {
      entry = {}
      this.coverageFiles.set(projectName || DEFAULT_PROJECT, entry)
    }

    const testFilenames = testFiles.join()
    entry[environment] ??= {}
    // If there's a result from previous run, overwrite it
    entry[environment][testFilenames] = filename
  }

  async readCoverageFiles<CoverageType>({ onFileRead, onFinished, onDebug }: {
    /** Callback invoked with a single coverage result */
    onFileRead: (data: CoverageType) => void
    /** Callback invoked once all results of a project for specific transform mode are read */
    onFinished: (project: Vitest['projects'][number], environment: string) => Promise<void>
    onDebug: ((...logs: any[]) => void) & { enabled: boolean }
  }): Promise<void> {
    let index = 0
    const total = this.coverageFiles.size

    for (const [projectName, coveragePerProject] of this.coverageFiles.entries()) {
      for (const [environment, coverageByTestfiles] of Object.entries(coveragePerProject) as Entries<typeof coveragePerProject>) {
        const filenames = Object.values(coverageByTestfiles)
        const project = this.ctx.getProjectByName(projectName as string)

        for (const chunk of this.toSlices(filenames, this.options.processingConcurrency)) {
          if (onDebug.enabled) {
            index += chunk.length
            onDebug(`Reading coverage results ${index}/${total}`)
          }

          await Promise.all(chunk.map(async (filename) => {
            const contents = await fs.readFile(filename, 'utf-8')
            const coverage = JSON.parse(contents)

            onFileRead(coverage)
          }),
          )
        }

        await onFinished(project, environment)
      }
    }
  }

  async cleanAfterRun(): Promise<void> {
    try {
      this.coverageFiles = new Map()
      await fs.rm(this.coverageFilesDirectory, { recursive: true })

      // Remove empty reports directory, e.g. when only text-reporter is used
      if (readdirSync(this.options.reportsDirectory).length === 0) {
        await fs.rm(this.options.reportsDirectory, { recursive: true })
      }
    }
    finally {
      await this.reportsDirectoryLock.release()
    }
  }

  async onTestRunStart(): Promise<void> {
    if (this.options.changed) {
      try {
        const changedFiles = await this.ctx.vcs.findChangedFiles({
          root: this.ctx.config.root,
          changedSince: this.options.changed,
        })

        this.changedFiles = changedFiles
      }
      catch {
        this.changedFiles = undefined
      }
    }
    else if (this.ctx.config.changed) {
      this.changedFiles = this.ctx.config.related
    }

    if (this.changedFiles) {
      this.globCache.clear()
    }
  }

  async onTestFailure(): Promise<void> {
    if (!this.options.reportOnFailure) {
      await this.cleanAfterRun()
    }
  }

  async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext): Promise<void> {
    await this.generateReports(
      (coverageMap as CoverageMap) || this.createCoverageMap(),
      allTestsRun,
    )

    // In watch mode we need to preserve the previous results if cleanOnRerun is disabled
    const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch

    if (!keepResults) {
      await this.cleanAfterRun()
    }
  }

  async reportThresholds(coverageMap: CoverageMap, allTestsRun: boolean | undefined): Promise<void> {
    const resolvedThresholds = this.resolveThresholds(coverageMap)
    this.checkThresholds(resolvedThresholds)

    if (this.options.thresholds?.autoUpdate && allTestsRun) {
      if (!this.ctx.vite.config.configFile) {
        throw new Error(
          'Missing configurationFile. The "coverage.thresholds.autoUpdate" can only be enabled when configuration file is used.',
        )
      }

      const configFilePath = this.ctx.vite.config.configFile
      const configModule = await this.parseConfigModule(configFilePath)

      await this.updateThresholds({
        thresholds: resolvedThresholds,
        configurationFile: configModule,
        onUpdate: () =>
          writeFileSync(
            configFilePath,
            configModule.generate().code.replace(this.autoUpdateMarker, ''),
            'utf-8',
          ),

      })
    }
  }

  /**
   * Constructs collected coverage and users' threshold options into separate sets
   * where each threshold set holds their own coverage maps. Threshold set is either
   * for specific files defined by glob pattern or global for all other files.
   */
  private resolveThresholds(coverageMap: CoverageMap): ResolvedThreshold[] {
    const resolvedThresholds: ResolvedThreshold[] = []
    const files = coverageMap.files()
    const globalCoverageMap = this.createCoverageMap()

    for (const key of Object.keys(this.options.thresholds!) as `${keyof NonNullable<typeof this.options.thresholds>}`[]) {
      if (
        key === 'perFile'
        || key === 'autoUpdate'
        || key === '100'
        || THRESHOLD_KEYS.includes(key)
      ) {
        continue
      }

      const glob = key
      const globEntry = this.options.thresholds![glob]
      const globThresholds = resolveGlobThresholds(globEntry)
      const globCoverageMap = this.createCoverageMap()

      const matcher = pm(glob)
      const matchingFiles = files.filter(file =>
        matcher(relative(this.ctx.config.root, file)),
      )

      for (const file of matchingFiles) {
        const fileCoverage = coverageMap.fileCoverageFor(file)
        globCoverageMap.addFileCoverage(fileCoverage)
      }

      resolvedThresholds.push({
        name: glob,
        coverageMap: globCoverageMap,
        thresholds: globThresholds,
        ...resolvePerFile(globEntry),
      })
    }

    // Global threshold is for all files, even if they are included by glob patterns
    for (const file of files) {
      const fileCoverage = coverageMap.fileCoverageFor(file)
      globalCoverageMap.addFileCoverage(fileCoverage)
    }

    resolvedThresholds.unshift({
      name: GLOBAL_THRESHOLDS_KEY,
      coverageMap: globalCoverageMap,
      thresholds: {
        branches: this.options.thresholds?.branches,
        functions: this.options.thresholds?.functions,
        lines: this.options.thresholds?.lines,
        statements: this.options.thresholds?.statements,
      },
      ...resolvePerFile(this.options.thresholds),
    })

    return resolvedThresholds
  }

  /**
   * Check collected coverage against configured thresholds. Sets exit code to 1 when thresholds not reached.
   */
  private checkThresholds(allThresholds: ResolvedThreshold[]) {
    for (const { coverageMap, thresholds, perFile, perFileThresholds, name } of allThresholds) {
      const groups: {
        file: string | null
        thresholds: ResolvedThreshold['thresholds']
        summary: CoverageSummary
        name: string
      }[] = []

      if (!perFile) {
        groups.push({
          file: null,
          thresholds,
          summary: coverageMap.getCoverageSummary(),
          name: name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`,
        })
      }

      if (perFile) {
        for (const file of coverageMap.files().sort()) {
          groups.push({
            file,
            thresholds,
            summary: coverageMap.fileCoverageFor(file).toSummary(),
            name: name === GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`,
          })
        }
      }

      if (perFileThresholds) {
        for (const file of coverageMap.files().sort()) {
          groups.push({
            file,
            thresholds: perFileThresholds,
            summary: coverageMap.fileCoverageFor(file).toSummary(),
            name: 'per-file',
          })
        }
      }

      for (const group of groups) {
        if (
          group.thresholds.branches === undefined
          && group.thresholds.functions === undefined
          && group.thresholds.lines === undefined
          && group.thresholds.statements === undefined
        ) {
          continue
        }

        this.reportThresholdViolations(group.thresholds, group.summary, group.file, group.name)
      }
    }
  }

  private reportThresholdViolations(
    thresholds: ResolvedThreshold['thresholds'],
    summary: CoverageSummary,
    file: string | null,
    label: string,
  ) {
    for (const thresholdKey of THRESHOLD_KEYS) {
      const threshold = thresholds[thresholdKey]

      if (threshold === undefined) {
        continue
      }

      /**
       * Positive thresholds are treated as minimum coverage percentages (X means: X% of lines must be covered),
       * while negative thresholds are treated as maximum uncovered counts (-X means: X lines may be uncovered).
       */
      if (threshold >= 0) {
        const coverage = summary.data[thresholdKey].pct

        if (coverage < threshold) {
          process.exitCode = 1

          let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet ${label} threshold (${threshold}%)`

          if (file) {
            errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}`
          }

          this.ctx.logger.error(errorMessage)
        }
      }
      else {
        const uncovered = summary.data[thresholdKey].total - summary.data[thresholdKey].covered
        const absoluteThreshold = threshold * -1

        if (uncovered > absoluteThreshold) {
          process.exitCode = 1

          let errorMessage = `ERROR: Uncovered ${thresholdKey} (${uncovered}) exceed ${label} threshold (${absoluteThreshold})`

          if (file) {
            errorMessage += ` for ${relative('./', file).replace(/\\/g, '/')}`
          }

          this.ctx.logger.error(errorMessage)
        }
      }
    }
  }

  /**
   * Check if current coverage is above configured thresholds and bump the thresholds if needed
   */
  async updateThresholds({ thresholds: allThresholds, onUpdate, configurationFile }: {
    thresholds: ResolvedThreshold[]
    configurationFile: unknown // ProxifiedModule from magicast
    onUpdate: () => void
  }): Promise<void> {
    let updatedThresholds = false

    const config = resolveConfig(configurationFile)
    assertConfigurationModule(config)

    for (const { coverageMap, thresholds, name, perFile } of allThresholds) {
      const summaries = perFile
        ? coverageMap
            .files()
            .map((file: string) =>
              coverageMap.fileCoverageFor(file).toSummary(),
            )
        : [coverageMap.getCoverageSummary()]

      // A `perFile` glob may match no files; skip it instead of writing
      // Infinity thresholds from `Math.min(...[])`.
      if (summaries.length === 0) {
        continue
      }

      const thresholdsToUpdate: [Threshold, number, number][] = []

      for (const key of THRESHOLD_KEYS) {
        const threshold = thresholds[key] ?? 100
        /**
         * Positive thresholds are treated as minimum coverage percentages (X means: X% of lines must be covered),
         * while negative thresholds are treated as maximum uncovered counts (-X means: X lines may be uncovered).
         */
        if (threshold >= 0) {
          const actual = Math.min(
            ...summaries.map(summary => summary[key].pct),
          )

          if (actual > threshold) {
            thresholdsToUpdate.push([key, actual, threshold])
          }
        }
        else {
          const absoluteThreshold = threshold * -1
          const actual = Math.max(
            ...summaries.map(summary => summary[key].total - summary[key].covered),
          )

          if (actual < absoluteThreshold) {
            // If everything was covered, set new threshold to 100% (since a threshold of 0 would be considered as 0%)
            const updatedThreshold = actual === 0 ? 100 : actual * -1
            thresholdsToUpdate.push([key, updatedThreshold, threshold])
          }
        }
      }

      if (thresholdsToUpdate.length === 0) {
        continue
      }

      updatedThresholds = true

      const thresholdFormatter = typeof this.options.thresholds?.autoUpdate === 'function' ? this.options.thresholds?.autoUpdate : (value: number) => value

      for (const [threshold, newValue, previousValue] of thresholdsToUpdate) {
        const formattedValue = thresholdFormatter(newValue, previousValue)
        if (name === GLOBAL_THRESHOLDS_KEY) {
          config.test.coverage.thresholds[threshold] = formattedValue
        }
        else {
          const glob = config.test.coverage.thresholds[name as Threshold] as ResolvedThreshold['thresholds']
          glob[threshold] = formattedValue
        }
      }
    }

    if (updatedThresholds) {
      this.ctx.logger.log('Updating thresholds to configuration file. You may want to push with updated coverage thresholds.')
      onUpdate()
    }
  }

  async mergeReports(coverageMaps: unknown[]): Promise<void> {
    const coverageMap = this.createCoverageMap()

    for (const coverage of coverageMaps) {
      coverageMap.merge(coverage as CoverageMap)
    }

    await this.generateReports(coverageMap, true)
  }

  hasTerminalReporter(reporters: ResolvedCoverageOptions['reporter']): boolean {
    return reporters.some(
      ([reporter]) =>
        reporter === 'text'
        || reporter === 'text-summary'
        || reporter === 'text-lcov'
        || reporter === 'teamcity',
    )
  }

  toSlices<T>(array: T[], size: number): T[][] {
    return array.reduce<T[][]>((chunks, item) => {
      const index = Math.max(0, chunks.length - 1)
      const lastChunk = chunks[index] || []
      chunks[index] = lastChunk

      if (lastChunk.length >= size) {
        chunks.push([item])
      }
      else {
        lastChunk.push(item)
      }

      return chunks
    }, [])
  }

  // TODO: should this be abstracted in `project`/`vitest` instead?
  // if we decide to keep `viteModuleRunner: false`, we will need to abstract transformation in both main thread and tests
  // custom --import=module.registerHooks need to be transformed as well somehow
  async transformFile(url: string, project: TestProject, viteEnvironment: string, isTransformedByVite = true): Promise<TransformResult | null | undefined> {
    const config = project.config

    // vite is disabled, should transform manually if possible
    if (config.experimental.viteModuleRunner === false || !isTransformedByVite) {
      const pathname = url.split('?')[0]
      const filename = pathname.startsWith('file://') ? fileURLToPath(pathname) : pathname
      const extension = path.extname(filename)
      const isTypeScript = extension === '.ts' || extension === '.mts' || extension === '.cts'
      if (!isTypeScript) {
        const code = await fs.readFile(filename, 'utf-8')
        return { code, map: null }
      }
      if (!module.stripTypeScriptTypes) {
        throw new Error(`Cannot parse '${url}' because "module.stripTypeScriptTypes" is not supported. TypeScript coverage requires Node.js 22.15 or higher. This is NOT a bug of Vitest.`)
      }
      const isTransform = process.execArgv.includes('--experimental-transform-types')
        || config.execArgv.includes('--experimental-transform-types')
        || process.env.NODE_OPTIONS?.includes('--experimental-transform-types')
        || config.env?.NODE_OPTIONS?.includes('--experimental-transform-types')
      const code = await fs.readFile(filename, 'utf-8')
      return {
        // `transform` mode will inject source maps comment at the end
        code: module.stripTypeScriptTypes(code, { mode: isTransform ? 'transform' : 'strip' }),
        map: null,
      }
    }

    return project.vite.environments[viteEnvironment].transformRequest(url)
  }

  createUncoveredFileTransformer(ctx: Vitest) {
    const projects = new Set([
      ...ctx.projects,
      // Check core last as it will match all files anyway
      ctx.getRootProject(),
    ])

    return async (filename: string): Promise<TransformResult | null | undefined> => {
      let lastError

      for (const project of projects) {
        const root = project.config.root

        // On Windows root doesn't start with "/" while filenames do
        if (!filename.startsWith(root) && !filename.startsWith(`/${root}`)) {
          continue
        }

        try {
          const environment = project.config.environment
          const viteEnvironment = environment === 'jsdom' || environment === 'happy-dom' || project.isBrowserEnabled() ? 'client' : 'ssr'
          return await this.transformFile(filename, project, viteEnvironment)
        }
        catch (err) {
          lastError = err
        }
      }

      // All vite servers failed to transform the file
      throw lastError
    }
  }
}

function resolvePerFile(thresholds: unknown): {
  perFile: boolean
  perFileThresholds: ResolvedThreshold['thresholds'] | null
} {
  if (!thresholds || typeof thresholds !== 'object' || !('perFile' in thresholds)) {
    return { perFile: false, perFileThresholds: null }
  }

  const { perFile } = thresholds

  if (perFile === true) {
    return { perFile: true, perFileThresholds: null }
  }

  if (perFile && typeof perFile === 'object') {
    return { perFile: false, perFileThresholds: resolveGlobThresholds(perFile) }
  }

  return { perFile: false, perFileThresholds: null }
}

/**
 * Narrow down `unknown` glob thresholds to resolved ones
 */
function resolveGlobThresholds(
  thresholds: unknown,
): ResolvedThreshold['thresholds'] {
  if (!thresholds || typeof thresholds !== 'object') {
    return {}
  }

  if (100 in thresholds && thresholds[100] === true) {
    return {
      lines: 100,
      branches: 100,
      functions: 100,
      statements: 100,
    }
  }

  return {
    lines:
      'lines' in thresholds && typeof thresholds.lines === 'number'
        ? thresholds.lines
        : undefined,
    branches:
      'branches' in thresholds && typeof thresholds.branches === 'number'
        ? thresholds.branches
        : undefined,
    functions:
      'functions' in thresholds && typeof thresholds.functions === 'number'
        ? thresholds.functions
        : undefined,
    statements:
      'statements' in thresholds && typeof thresholds.statements === 'number'
        ? thresholds.statements
        : undefined,
  }
}

function assertConfigurationModule(config: unknown): asserts config is {
  test: {
    coverage: { thresholds: NonNullable<CoverageOptions['thresholds']> }
  }
} {
  try {
    // @ts-expect-error -- Intentional unsafe null pointer check as wrapped in try-catch
    if (typeof config.test.coverage.thresholds !== 'object') {
      throw new TypeError(
        'Expected config.test.coverage.thresholds to be an object',
      )
    }
  }
  catch (error) {
    const message = error instanceof Error ? error.message : String(error)
    throw new Error(
      `Unable to parse thresholds from configuration file: ${message}`,
    )
  }
}

function resolveConfig(configModule: any) {
  const mod = configModule.exports.default

  try {
    // Check for "export default { test: {...} }"
    if (mod.$type === 'object') {
      return mod
    }

    // "export default defineConfig(...)"
    let config = resolveDefineConfig(mod)
    if (config) {
      return config
    }

    // "export default mergeConfig(..., defineConfig(...))"
    if (mod.$type === 'function-call' && mod.$callee === 'mergeConfig') {
      config = resolveMergeConfig(mod)
      if (config) {
        return config
      }
    }
  }
  catch (error) {
    // Reduce magicast's verbose errors to readable ones
    throw new Error(error instanceof Error ? error.message : String(error))
  }

  throw new Error(
    'Failed to update coverage thresholds. Configuration file is too complex.',
  )
}

function resolveDefineConfig(mod: any) {
  if (mod.$type === 'function-call' && mod.$callee === 'defineConfig') {
    // "export default defineConfig({ test: {...} })"
    if (mod.$args[0].$type === 'object') {
      return mod.$args[0]
    }

    if (mod.$args[0].$type === 'arrow-function-expression') {
      if (mod.$args[0].$body.$type === 'object') {
        // "export default defineConfig(() => ({ test: {...} }))"
        return mod.$args[0].$body
      }

      // "export default defineConfig(() => mergeConfig({...}, ...))"
      const config = resolveMergeConfig(mod.$args[0].$body)
      if (config) {
        return config
      }
    }
  }
}

function resolveMergeConfig(mod: any): any {
  if (mod.$type === 'function-call' && mod.$callee === 'mergeConfig') {
    for (const arg of mod.$args) {
      const config = resolveDefineConfig(arg)
      if (config) {
        return config
      }
    }
  }
}

/**
 * Cross-process lock on a coverage `reportsDirectory`.
 *
 * Two `vitest run --coverage` runs pointed at the same reports directory delete
 * each other's reports, so the second one to start fails fast instead. The lock
 * file lives in the OS temp directory so it survives the cleanup it guards, and a
 * lock left behind by a process that no longer exists is reclaimed on the next run.
 */
interface LockOwner {
  pid: number
  reportsDirectory: string
}

class ReportsDirectoryLock {
  readonly lockFile: string

  constructor(private readonly reportsDirectory: string) {
    const hash = createHash('sha256')
      .update(reportsDirectory)
      .digest('hex')
      .slice(0, 16)

    this.lockFile = resolve(tmpdir(), `vitest-coverage-${hash}.lock`)
  }

  async acquire(): Promise<void> {
    if (await this.tryWrite()) {
      return
    }

    const owner = await this.readOwner()

    // We already hold the lock for this directory (e.g. watch-mode reruns).
    if (owner?.pid === process.pid) {
      return
    }

    // Another running Vitest owns this directory.
    if (owner && isProcessAlive(owner.pid)) {
      throw this.inUseError(owner)
    }

    // The lock was left behind by a process that no longer exists. Reclaim it.
    await fs.rm(this.lockFile, { force: true })

    if (!(await this.tryWrite())) {
      throw this.inUseError(await this.readOwner())
    }
  }

  async release(): Promise<void> {
    const owner = await this.readOwner()

    if (owner?.pid === process.pid) {
      await fs.rm(this.lockFile, { force: true })
    }
  }

  private async tryWrite(): Promise<boolean> {
    const payload = JSON.stringify({ pid: process.pid, reportsDirectory: this.reportsDirectory })

    try {
      // `wx` fails with EEXIST if the file already exists, so only one process wins.
      await fs.writeFile(this.lockFile, payload, { flag: 'wx' })
      return true
    }
    catch (error) {
      if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
        return false
      }
      throw error
    }
  }

  private async readOwner(): Promise<LockOwner | null> {
    try {
      const owner = JSON.parse(await fs.readFile(this.lockFile, 'utf-8'))
      return typeof owner?.pid === 'number' ? owner : null
    }
    catch {
      return null
    }
  }

  private inUseError(owner: LockOwner | null): Error {
    return new Error(
      `The coverage report directory "${this.reportsDirectory}" is already in use by `
      + `another Vitest process${owner ? ` (pid ${owner.pid})` : ''}. Running coverage for multiple `
      + `Vitest processes in the same directory at the same time is not supported, because they would `
      + `delete each other's reports.\nGive each run its own "coverage.reportsDirectory" `
      + `(e.g. --coverage.reportsDirectory=coverage-${process.pid}) or run them sequentially.`,
    )
  }
}

function isProcessAlive(pid: number): boolean {
  try {
    // Sending signal 0 checks if the process exists without actually killing it:
    // https://nodejs.org/api/process.html#processkillpid-signal
    process.kill(pid, 0)
    return true
  }
  catch (error) {
    // ESRCH means the process is gone. Treat anything else (e.g. EPERM) as alive
    // so we never reclaim a lock from a process that is still running.
    return (error as NodeJS.ErrnoException).code !== 'ESRCH'
  }
}
