import type { CreateMockInstanceProcedure } from '../automocker'
import type { MockedModule, MockedModuleType } from '../registry'
import type { ModuleMockContext, ModuleMockOptions, TestModuleMocker } from '../types'
import type { ModuleMockerInterceptor } from './interceptor'
import { extname, join } from 'pathe'
import { mockObject } from '../automocker'
import { AutomockedModule, MockerRegistry, RedirectedModule } from '../registry'

const { now } = Date

export class ModuleMocker implements TestModuleMocker {
  protected registry: MockerRegistry = new MockerRegistry()

  private queue = new Set<Promise<void>>()
  private mockedIds = new Set<string>()

  constructor(
    private interceptor: ModuleMockerInterceptor,
    private rpc: ModuleMockerRPC,
    private createMockInstance: CreateMockInstanceProcedure,
    private config: ModuleMockerConfig,
  ) {}

  public async prepare(): Promise<void> {
    if (!this.queue.size) {
      return
    }
    await Promise.all([...this.queue.values()])
  }

  public async resolveFactoryModule(id: string): Promise<Record<string | symbol, any>> {
    const mock = this.registry.get(id)
    if (!mock || mock.type !== 'manual') {
      throw new Error(`Mock ${id} wasn't registered. This is probably a Vitest error. Please, open a new issue with reproduction.`)
    }
    const result = await mock.resolve()
    return result
  }

  public getFactoryModule(id: string): any {
    const mock = this.registry.get(id)
    if (!mock || mock.type !== 'manual') {
      throw new Error(`Mock ${id} wasn't registered. This is probably a Vitest error. Please, open a new issue with reproduction.`)
    }
    if (!mock.cache) {
      throw new Error(`Mock ${id} wasn't resolved. This is probably a Vitest error. Please, open a new issue with reproduction.`)
    }
    return mock.cache
  }

  public async invalidate(): Promise<void> {
    const ids = Array.from(this.mockedIds)
    if (!ids.length) {
      return
    }
    await this.rpc.invalidate(ids)
    await this.interceptor.invalidate()
    this.registry.clear()
  }

  public async importActual<T>(id: string, importer: string): Promise<T> {
    const resolved = await this.rpc.resolveId(id, importer)
    if (resolved == null) {
      throw new Error(
        `[vitest] Cannot resolve "${id}" imported from "${importer}"`,
      )
    }
    const ext = extname(resolved.id)
    const url = new URL(resolved.url, this.getBaseUrl())
    const query = `_vitest_original&ext${ext}`
    const actualUrl = `${url.pathname}${
      url.search ? `${url.search}&${query}` : `?${query}`
    }${url.hash}`
    return this.wrapDynamicImport(() => import(/* @vite-ignore */ actualUrl)).then((mod) => {
      if (!resolved.optimized || typeof mod.default === 'undefined') {
        return mod
      }
      // vite injects this helper for optimized modules, so we try to follow the same behavior
      const m = mod.default
      return m?.__esModule ? m : { ...((typeof m === 'object' && !Array.isArray(m)) || typeof m === 'function' ? m : {}), default: m }
    })
  }

  protected getBaseUrl(): string {
    return location.href
  }

  public async importMock<T>(rawId: string, importer: string): Promise<T> {
    await this.prepare()
    const { resolvedId, resolvedUrl, redirectUrl } = await this.rpc.resolveMock(
      rawId,
      importer,
      { mock: 'auto' },
    )

    const mockUrl = this.resolveMockPath(cleanVersion(resolvedUrl))
    let mock = this.registry.get(mockUrl)

    if (!mock) {
      if (redirectUrl) {
        const resolvedRedirect = new URL(this.resolveMockPath(cleanVersion(redirectUrl)), this.getBaseUrl()).toString()
        mock = new RedirectedModule(rawId, resolvedId, mockUrl, resolvedRedirect)
      }
      else {
        mock = new AutomockedModule(rawId, resolvedId, mockUrl)
      }
    }

    if (mock.type === 'manual') {
      return await mock.resolve() as T
    }

    if (mock.type === 'automock' || mock.type === 'autospy') {
      const url = new URL(`/@id/${resolvedId}`, this.getBaseUrl())
      const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}`
      const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}&mock=${mock.type}${url.hash}`)
      return this.mockObject(moduleObject, mock.type) as T
    }

    return import(/* @vite-ignore */ mock.redirect)
  }

  public mockObject(
    object: Record<string | symbol, any>,
    moduleType?: 'automock' | 'autospy',
  ): Record<string | symbol, any>
  public mockObject(
    object: Record<string | symbol, any>,
    mockExports: Record<string | symbol, any> | undefined,
    moduleType?: 'automock' | 'autospy',
  ): Record<string | symbol, any>
  public mockObject(
    object: Record<string | symbol, any>,
    mockExportsOrModuleType?: Record<string | symbol, any> | 'automock' | 'autospy',
    moduleType?: 'automock' | 'autospy',
  ): Record<string | symbol, any> {
    let mockExports: Record<string | symbol, any> | undefined
    if (mockExportsOrModuleType === 'automock' || mockExportsOrModuleType === 'autospy') {
      moduleType = mockExportsOrModuleType
      mockExports = undefined
    }
    else {
      mockExports = mockExportsOrModuleType
    }
    moduleType ??= 'automock'
    const result = mockObject(
      {
        globalConstructors: {
          Object,
          Function,
          Array,
          Map,
          RegExp,
        },
        createMockInstance: this.createMockInstance,
        type: moduleType,
      },
      object,
      mockExports,
    )
    return result
  }

  public getMockContext(): ModuleMockContext {
    return {
      callstack: null,
    }
  }

  public queueMock(rawId: string, importer: string, factoryOrOptions?: ModuleMockOptions | (() => any)): void {
    const promise = this.rpc
      .resolveMock(rawId, importer, {
        mock: typeof factoryOrOptions === 'function'
          ? 'factory'
          : factoryOrOptions?.spy ? 'spy' : 'auto',
      })
      .then(async ({ redirectUrl, resolvedId, resolvedUrl, needsInterop, mockType }) => {
        const mockUrl = this.resolveMockPath(cleanVersion(resolvedUrl))
        this.mockedIds.add(resolvedId)
        const factory = typeof factoryOrOptions === 'function'
          ? async () => {
            const data = await factoryOrOptions()
            // vite wraps all external modules that have "needsInterop" in a function that
            // merges all exports from default into the module object
            return needsInterop ? { default: data } : data
          }
          : undefined

        const mockRedirect = typeof redirectUrl === 'string'
          ? new URL(this.resolveMockPath(cleanVersion(redirectUrl)), this.getBaseUrl()).toString()
          : null

        let module: MockedModule
        if (mockType === 'manual') {
          module = this.registry.register('manual', rawId, resolvedId, mockUrl, factory!)
        }
        // autospy takes higher priority over redirect, so it needs to be checked first
        else if (mockType === 'autospy') {
          module = this.registry.register('autospy', rawId, resolvedId, mockUrl)
        }
        else if (mockType === 'redirect') {
          module = this.registry.register('redirect', rawId, resolvedId, mockUrl, mockRedirect!)
        }
        else {
          module = this.registry.register('automock', rawId, resolvedId, mockUrl)
        }

        await this.interceptor.register(module)
      })
      .finally(() => {
        this.queue.delete(promise)
      })
    this.queue.add(promise)
  }

  public queueUnmock(id: string, importer: string): void {
    const promise = this.rpc
      .resolveId(id, importer)
      .then(async (resolved) => {
        if (!resolved) {
          return
        }
        const mockUrl = this.resolveMockPath(cleanVersion(resolved.url))
        this.mockedIds.add(resolved.id)
        this.registry.delete(mockUrl)
        await this.interceptor.delete(mockUrl)
      })
      .finally(() => {
        this.queue.delete(promise)
      })
    this.queue.add(promise)
  }

  // We need to await mock registration before importing the actual module
  // In case there is a mocked module in the import chain
  public wrapDynamicImport<T>(moduleFactory: () => Promise<T>): Promise<T> {
    if (typeof moduleFactory === 'function') {
      const promise = new Promise<T>((resolve, reject) => {
        this.prepare().finally(() => {
          moduleFactory().then(resolve, reject)
        })
      })
      return promise
    }
    return moduleFactory
  }

  public getMockedModuleById(id: string): MockedModule | undefined {
    return this.registry.getById(id)
  }

  public reset(): void {
    this.registry.clear()
    this.mockedIds.clear()
    this.queue.clear()
  }

  private resolveMockPath(path: string) {
    const config = this.config
    const fsRoot = join('/@fs/', config.root)

    // URL can be /file/path.js, but path is resolved to /file/path
    if (path.startsWith(config.root)) {
      return path.slice(config.root.length)
    }

    if (path.startsWith(fsRoot)) {
      return path.slice(fsRoot.length)
    }

    return path
  }
}

const versionRegexp = /(\?|&)v=\w{8}/
function cleanVersion(url: string) {
  return url.replace(versionRegexp, '')
}

export interface ResolveIdResult {
  id: string
  url: string
  optimized: boolean
}

export interface ResolveMockResult {
  mockType: MockedModuleType
  resolvedId: string
  resolvedUrl: string
  redirectUrl?: string | null
  needsInterop?: boolean
}

export interface ModuleMockerRPC {
  invalidate: (ids: string[]) => Promise<void>
  resolveId: (id: string, importer: string) => Promise<ResolveIdResult | null>
  resolveMock: (
    id: string,
    importer: string,
    options: { mock: 'spy' | 'factory' | 'auto' },
  ) => Promise<ResolveMockResult>
}

export interface ModuleMockerConfig {
  root: string
}
