import type { SerializedError } from '@vitest/utils'
import type { DevEnvironment, EnvironmentModuleNode } from 'vite'
import type { File } from '../../runtime/runner/types'
import type { Vitest } from '../core'
import type { TestProject } from '../project'
import type { Reporter } from '../types/reporter'
import type { TestModule } from './reported-tasks'
import { existsSync } from 'node:fs'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { sanitizeFilePath } from '@vitest/utils/helpers'
import { parse, stringify } from 'flatted'
import { dirname, resolve } from 'pathe'
import { getOutputFile } from '../../utils/config-helpers'

export interface BlobOptions {
  outputFile?: string
  label?: string
}

export class BlobReporter implements Reporter {
  start = 0
  ctx!: Vitest
  options: BlobOptions
  coverage: unknown | undefined

  constructor(options: BlobOptions) {
    this.options = options
  }

  onInit(ctx: Vitest): void {
    if (ctx.config.watch) {
      throw new Error('Blob reporter is not supported in watch mode')
    }

    this.ctx = ctx
    this.start = performance.now()
    this.coverage = undefined
  }

  onCoverage(coverage: unknown): void {
    this.coverage = coverage
  }

  async onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>): Promise<void> {
    const executionTime = performance.now() - this.start

    const files = testModules.map(testModule => testModule.task)
    const errors = [...unhandledErrors]
    const coverage = this.coverage

    const environmentModules: MergeReportEnvironmentModules = {}
    this.ctx.projects.forEach((project) => {
      const serializedProject: MergeReportEnvironmentModules[string] = {
        environments: {},
        external: [],
      }
      Object.entries(project.vite.environments).forEach(([environmentName, environment]) => {
        serializedProject.environments[environmentName] = serializeEnvironmentModuleGraph(
          environment,
        )
      })

      for (const [id, value] of project._resolver.externalizeCache.entries()) {
        if (typeof value === 'string') {
          serializedProject.external.push([id, value])
        }
      }

      environmentModules[project.name] = serializedProject
    })

    const content = stringify([
      this.ctx.version,
      files,
      errors,
      coverage,
      executionTime,
      environmentModules,
      this.ctx.state.transformTime,
    ] satisfies MergeReport)

    let outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'blob')

    if (outputFile) {
      outputFile = resolve(this.ctx.config.root, outputFile)

      const dir = dirname(outputFile)
      if (!existsSync(dir)) {
        await mkdir(dir, { recursive: true })
      }

      await writeFile(outputFile, content, 'utf-8')
    }
    else {
      const report = this.ctx.createReport('blob')

      const shard = this.ctx.config.shard
      outputFile = [
        'blob',
        this.ctx.config.mergeReportsLabel,
        shard ? `${shard.index}-${shard.count}` : '',
      ].filter(Boolean).join('-')

      outputFile = `${sanitizeFilePath(outputFile)}.json`

      await report.writeFile(outputFile, content, 'utf-8')
      outputFile = resolve(report.root, outputFile)
    }

    this.ctx.logger.log('blob report written to', outputFile)
  }
}

export async function readBlobs(
  currentVersion: string,
  blobsDirectory: string,
  projectsArray: TestProject[],
): Promise<MergedBlobs> {
  // using process.cwd() because --merge-reports can only be used in CLI
  const resolvedDir = resolve(process.cwd(), blobsDirectory)
  const blobsFiles = await readdir(resolvedDir)
  const promises = blobsFiles.map(async (filename) => {
    const fullPath = resolve(resolvedDir, filename)
    const stats = await stat(fullPath)
    if (!stats.isFile()) {
      throw new TypeError(
        `vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a file`,
      )
    }
    const content = await readFile(fullPath, 'utf-8')
    const [version, files, errors, coverage, executionTime, environmentModules, transformTime] = parse(
      content,
    ) as MergeReport
    if (!version) {
      throw new TypeError(
        `vitest.mergeReports() expects all paths in "${blobsDirectory}" to be files generated by the blob reporter, but "${filename}" is not a valid blob file`,
      )
    }
    return { version, files, errors, coverage, file: filename, executionTime, environmentModules, transformTime }
  })
  const blobs = await Promise.all(promises)

  if (!blobs.length) {
    throw new Error(
      `vitest.mergeReports() requires at least one blob file in "${blobsDirectory}" directory, but none were found`,
    )
  }

  const versions = new Set(blobs.map(blob => blob.version))
  if (versions.size > 1) {
    throw new Error(
      `vitest.mergeReports() requires all blob files to be generated by the same Vitest version, received\n\n${blobs.map(b => `- "${b.file}" uses v${b.version}`).join('\n')}`,
    )
  }

  if (!versions.has(currentVersion)) {
    throw new Error(
      `the blobs in "${blobsDirectory}" were generated by a different version of Vitest. Expected v${currentVersion}, but received v${blobs[0].version}`,
    )
  }

  // Restore module graph
  const projects = Object.fromEntries(
    projectsArray.map(p => [p.name, p]),
  )

  blobs.forEach((blob) => {
    Object.entries(blob.environmentModules).forEach(([projectName, modulesByProject]) => {
      const project = projects[projectName]
      if (!project) {
        return
      }

      modulesByProject.external.forEach(([id, externalized]) => {
        project._resolver.externalizeCache.set(id, externalized)
      })

      Object.entries(modulesByProject.environments).forEach(([environmentName, moduleGraph]) => {
        const environment = project.vite.environments[environmentName]
        deserializeEnvironmentModuleGraph(environment, moduleGraph)
      })
    })
  })

  const files = blobs
    .flatMap(blob => blob.files)
    .sort((f1, f2) => {
      const time1 = f1.result?.startTime || 0
      const time2 = f2.result?.startTime || 0
      return time1 - time2
    })
  const errors = blobs.flatMap(blob => blob.errors)
  const coverages = blobs.map(blob => blob.coverage)
  const executionTimes = blobs.map(blob => blob.executionTime)
  const transformTimes = blobs.map(blob => blob.transformTime)

  return {
    files,
    errors,
    coverages,
    executionTimes,
    transformTimes,
  }
}

export interface MergedBlobs {
  files: File[]
  errors: unknown[]
  coverages: unknown[]
  executionTimes: number[]
  transformTimes: number[]
}

export type MergeReport = [
  vitestVersion: string,
  files: File[],
  errors: unknown[],
  coverage: unknown,
  executionTime: number,
  environmentModules: MergeReportEnvironmentModules,
  transformTime: number,
]

interface MergeReportEnvironmentModules {
  [projectName: string]: {
    environments: {
      [environmentName: string]: SerializedEnvironmentModuleGraph
    }
    external: [id: string, externalized: string][]
  }
}

type SerializedEnvironmentModuleNode = [
  id: number,
  file: number,
  url: number,
  importedIds: number[],
]

interface SerializedEnvironmentModuleGraph {
  idTable: string[]
  modules: SerializedEnvironmentModuleNode[]
}

function serializeEnvironmentModuleGraph(
  environment: DevEnvironment,
): SerializedEnvironmentModuleGraph {
  const idTable: string[] = []
  const idMap = new Map<string, number>()

  const getIdIndex = (id: string) => {
    const existing = idMap.get(id)
    if (existing != null) {
      return existing
    }
    const next = idTable.length
    idMap.set(id, next)
    idTable.push(id)
    return next
  }

  const modules: SerializedEnvironmentModuleNode[] = []
  for (const [id, mod] of environment.moduleGraph.idToModuleMap.entries()) {
    // Vite can generate module with `file = ""` for module id "#..."
    // when the actual module doesn't exist (e.g. resolve failure or mocked module)
    if (mod.file == null) {
      continue
    }

    const importedIds: number[] = []
    for (const importedNode of mod.importedModules) {
      if (importedNode.id !== null) {
        importedIds.push(getIdIndex(importedNode.id))
      }
    }

    modules.push([
      getIdIndex(id),
      getIdIndex(mod.file),
      getIdIndex(mod.url),
      importedIds,
    ])
  }

  return {
    idTable,
    modules,
  }
}

function deserializeEnvironmentModuleGraph(
  environment: DevEnvironment,
  serialized: SerializedEnvironmentModuleGraph,
): void {
  const nodesById = new Map<string, EnvironmentModuleNode>()

  serialized.modules.forEach(([id, file, url]) => {
    const moduleId = serialized.idTable[id]
    const filePath = serialized.idTable[file]
    const urlPath = serialized.idTable[url]
    // `createFileOnlyEntry('')` normalizes the file to ".". This keeps
    // the graph usable, but doesn't perfectly round-trip Vite's `file = ""`
    // nodes for ids like "#...".
    // We may just do moduleNode.file = filePath in the future.
    const moduleNode = environment.moduleGraph.createFileOnlyEntry(filePath)
    moduleNode.url = urlPath
    moduleNode.id = moduleId
    moduleNode.transformResult = {
      // print error checks that transformResult is set
      code: ' ',
      map: null,
    }
    environment.moduleGraph.idToModuleMap.set(moduleId, moduleNode)
    nodesById.set(moduleId, moduleNode)
  })

  serialized.modules.forEach(([id, _file, _url, importedIds]) => {
    const moduleId = serialized.idTable[id]
    const moduleNode = nodesById.get(moduleId)!
    importedIds.forEach((importedIdIndex) => {
      const importedId = serialized.idTable[importedIdIndex]
      const importedNode = nodesById.get(importedId)!
      moduleNode.importedModules.add(importedNode)
      importedNode.importers.add(moduleNode)
    })
  })
}
