import type { File, FileSpecification, Task, TaskResultPack } from '../runtime/runner/types'
import type { AsyncLeak, UserConsoleLog } from '../types/general'
import type { TransformClock } from './environments/fetchModule'
import type { TestProject } from './project'
import type { MergedBlobs } from './reporters/blob'
import type { OnUnhandledErrorCallback } from './types/config'
import { relative } from 'pathe'
import { createFileTask, generateFileHash } from '../utils/tasks'
import { TestCase, TestModule, TestSuite } from './reporters/reported-tasks'

function isAggregateError(err: unknown): err is AggregateError {
  if (typeof AggregateError !== 'undefined' && err instanceof AggregateError) {
    return true
  }

  return err instanceof Error && 'errors' in err
}

export class StateManager implements TransformClock {
  filesMap: Map<string, File[]> = new Map()
  pathsSet: Set<string> = new Set()
  idMap: Map<string, Task> = new Map()
  taskFileMap: WeakMap<Task, File> = new WeakMap()
  errorsSet: Set<unknown> = new Set()
  leakSet: Set<AsyncLeak> = new Set()
  reportedTasksMap: WeakMap<Task, TestModule | TestCase | TestSuite> = new WeakMap()
  blobs?: MergedBlobs
  /**
   * Wall time during which the server's module transform pipeline was busy,
   * measured as the union of in-flight fetch intervals. Individual fetch
   * durations cannot be summed instead: concurrent fetches (parallel workers,
   * the vm pool graph prewarm) all wait on the same deduplicated in-flight
   * transforms, so per-caller wall times overcount the actual work by orders
   * of magnitude.
   */
  transformTime = 0
  private _transformsInflight = 0
  private _transformsBusyStart = 0

  transformStarted(): void {
    if (this._transformsInflight++ === 0) {
      this._transformsBusyStart = performance.now()
    }
  }

  transformFinished(): void {
    if (--this._transformsInflight === 0) {
      this.transformTime += performance.now() - this._transformsBusyStart
    }
  }

  metadata: Record<string, {
    externalized: Record<string, string>
    duration: Record<string, number[]>
    tmps: Record<string, string>
    dumpDir?: string
    outline?: {
      externalized: number
      inlined: number
    }
  }> = {}

  onUnhandledError?: OnUnhandledErrorCallback

  /** @internal */
  _data = {
    timeoutIncreased: false,
  }

  constructor(
    options: {
      onUnhandledError?: OnUnhandledErrorCallback
    },
  ) {
    this.onUnhandledError = options.onUnhandledError
  }

  catchError(error: unknown, type: string): void {
    if (isAggregateError(error)) {
      return error.errors.forEach(error => this.catchError(error, type))
    }

    if (typeof error === 'object' && error !== null) {
      (error as Record<string, unknown>).type = type
    }
    else {
      error = { type, message: error }
    }

    const _error = error as Record<string, any>
    if (_error && typeof _error === 'object' && _error.code === 'VITEST_PENDING') {
      const task = this.idMap.get(_error.taskId)
      if (task) {
        task.mode = 'skip'
        task.result ??= { state: 'skip' }
        task.result.state = 'skip'
        task.result.note = _error.note
      }
      return
    }

    if (!this.onUnhandledError || this.onUnhandledError(error as any) !== false) {
      this.errorsSet.add(error)
    }
  }

  catchLeaks(leaks: AsyncLeak[]): void {
    leaks.forEach(leak => this.leakSet.add(leak))
  }

  clearErrors(): void {
    this.errorsSet.clear()
    this.leakSet.clear()
  }

  getUnhandledErrors(): unknown[] {
    return Array.from(this.errorsSet)
  }

  getPaths(): string[] {
    return Array.from(this.pathsSet)
  }

  /**
   * Return files that were running or collected.
   */
  getFiles(keys?: string[]): File[] {
    if (keys) {
      return keys
        .map(key => this.filesMap.get(key)!)
        .flat()
        .filter(file => file && !file.local)
    }
    return Array.from(this.filesMap.values()).flat().filter(file => !file.local).sort((f1, f2) => {
      // print typecheck files first
      if (f1.meta?.typecheck && f2.meta?.typecheck) {
        return 0
      }
      if (f1.meta?.typecheck) {
        return -1
      }
      return 1
    })
  }

  getTestModules(keys?: string[]): TestModule[] {
    return this.getFiles(keys).map(file => this.getReportedEntity(file) as TestModule)
  }

  getFilepaths(): string[] {
    return Array.from(this.filesMap.keys())
  }

  getFailedFilepaths(): string[] {
    return this.getFiles()
      .filter(i => i.result?.state === 'fail')
      .map(i => i.filepath)
  }

  collectPaths(paths: string[] = []): void {
    paths.forEach((path) => {
      this.pathsSet.add(path)
    })
  }

  collectFiles(project: TestProject, files: File[] = []): void {
    files.forEach((file) => {
      const existing = this.filesMap.get(file.filepath) || []
      const currentFile = existing.find(
        i => i.projectName === file.projectName
          && i.meta.typecheck === file.meta.typecheck
          && i.meta.__vitest_label__ === file.meta.__vitest_label__,
      )
      // keep logs for the previous file because it should always be initiated before the collections phase
      // which means that all logs are collected during the collection and not inside tests
      if (currentFile) {
        file.logs = currentFile.logs
      }
      const otherFiles = existing.filter(i => i !== currentFile)
      otherFiles.push(file)
      this.filesMap.set(file.filepath, otherFiles)
      this.updateId(file, project)
    })
  }

  clearFiles(
    project: TestProject,
    paths: string[] = [],
  ): void {
    paths.forEach((path) => {
      const files = this.filesMap.get(path)
      const fileTask = createFileTask(
        path,
        project.config.root,
        project.config.name,
      )
      fileTask.local = true
      TestModule.register(fileTask, project)
      this.idMap.set(fileTask.id, fileTask)
      if (!files) {
        this.filesMap.set(path, [fileTask])
        return
      }
      const filtered = files.filter(
        file => file.projectName !== project.config.name,
      )
      // always keep a File task, so we can associate logs with it
      if (!filtered.length) {
        this.filesMap.set(path, [fileTask])
      }
      else {
        this.filesMap.set(path, [...filtered, fileTask])
      }
    })
  }

  updateId(task: Task, project: TestProject): void {
    if (this.idMap.get(task.id) === task) {
      return
    }

    if (task.type === 'suite' && 'filepath' in task) {
      TestModule.register(task, project)
    }
    else if (task.type === 'suite') {
      TestSuite.register(task, project)
    }
    else {
      TestCase.register(task, project)
    }

    this.idMap.set(task.id, task)
    if (task.type === 'suite') {
      task.tasks.forEach((task) => {
        this.updateId(task, project)
      })
    }
  }

  getReportedEntity(task: Task): TestModule | TestCase | TestSuite | undefined {
    return this.reportedTasksMap.get(task)
  }

  getReportedEntityById(taskId: string): TestModule | TestCase | TestSuite | undefined {
    const task = this.idMap.get(taskId)
    return task ? this.reportedTasksMap.get(task) : undefined
  }

  updateTasks(packs: TaskResultPack[]): void {
    for (const [id, result, meta] of packs) {
      const task = this.idMap.get(id)
      if (task) {
        task.result = result
        task.meta = meta
        // skipped with new PendingError
        if (result?.state === 'skip') {
          task.mode = 'skip'
        }
      }
    }
  }

  updateUserLog(log: UserConsoleLog): void {
    const task = log.taskId && this.idMap.get(log.taskId)
    if (task) {
      if (!task.logs) {
        task.logs = []
      }
      task.logs.push(log)
    }
  }

  getCountOfFailedTests(): number {
    return Array.from(this.idMap.values()).filter(
      t => t.result?.state === 'fail',
    ).length
  }

  cancelFiles(files: FileSpecification[], project: TestProject): void {
    // if we don't filter existing modules, they will be overridden by `collectFiles`
    const nonRegisteredFiles = files.filter(({ filepath }) => {
      const relativePath = relative(project.config.root, filepath)
      const id = generateFileHash(relativePath, project.name)
      return !this.idMap.has(id)
    })

    this.collectFiles(
      project,
      nonRegisteredFiles.map(file =>
        createFileTask(file.filepath, project.config.root, project.config.name),
      ),
    )
  }
}
