import globRex from 'globrex'
import * as fs from 'node:fs'
import { readdir } from 'node:fs/promises'
import { isAbsolute, join, relative } from 'node:path'
import { inspect } from 'node:util'
import * as tsconfck from 'tsconfck'
import * as vite from 'vite'
import { debug } from './debug'
import { LogFileWriter } from './logFile'
import { resolvePathMappings } from './mappings'
import type { NormalizedPath } from './path'
import * as path from './path'
import {
  Directory,
  PluginOptions,
  Project,
  Resolver,
  ViteResolve,
} from './types'

const notApplicable = [undefined, false] as const
const notFound = [undefined, true] as const

/** A directory with no projects. */
const emptyDirectory: Directory = {
  projects: Object.freeze([]) as any,
  lazyDiscovery: false,
}

type Logger = Pick<vite.Logger, 'error' | 'hasErrorLogged'>

export type TsconfigResolvers = ReturnType<typeof createTsconfigResolvers>

export function createTsconfigResolvers({
  projectRoot,
  workspaceRoot,
  skip = () => false,
  logFile,
  logger,
  ...opts
}: Omit<PluginOptions, 'root' | 'logFile'> & {
  projectRoot: string
  workspaceRoot: string
  logFile?: LogFileWriter | null
  logger: Logger
}) {
  let initializing: Promise<void> | undefined
  let directoryCache: Map<string, Directory>
  let resolversByProject: WeakMap<Project, Resolver>
  let isFirstParseError = true
  let hasTypeScriptDep = false

  if (opts.parseNative) {
    try {
      const pkgJson = fs.readFileSync(
        join(workspaceRoot, 'package.json'),
        'utf8'
      )
      const pkg = JSON.parse(pkgJson)
      const deps = { ...pkg.dependencies, ...pkg.devDependencies }
      hasTypeScriptDep = 'typescript' in deps
    } catch (e: any) {
      if (e.code != 'ENOENT') {
        throw e
      }
    }
  }

  const configNames = opts.configNames || ['tsconfig.json', 'jsconfig.json']
  debug(
    'Only tsconfig files with a name in this list are discoverable:',
    configNames
  )

  const parseProject = async (
    tsconfigFile: string
  ): Promise<Project | null> => {
    tsconfigFile = path.normalize(tsconfigFile)

    try {
      return (
        hasTypeScriptDep
          ? await tsconfck.parseNative(tsconfigFile)
          : await tsconfck.parse(tsconfigFile)
      ) as Project
    } catch (error: any) {
      if (opts.ignoreConfigErrors) {
        debug('[!] Failed to parse tsconfig file at %s', tsconfigFile)
        if (isFirstParseError) {
          debug('Remove the `ignoreConfigErrors` option to see the error.')
        }
      } else {
        logger.error(
          '[tsconfig-paths] An error occurred while parsing "' +
            tsconfigFile +
            '". See below for details.' +
            (isFirstParseError
              ? ' To disable this message, set the `ignoreConfigErrors` option to true.'
              : ''),
          { error }
        )
        if (!logger.hasErrorLogged(error)) {
          console.error(error)
        }
      }
      isFirstParseError = false
      return null
    }
  }

  // File watcher hooks
  let onBeforeAddProject: ((project: Project) => void) | undefined
  let onParseError: ((tsconfigFile: string) => void) | undefined

  const addProject = (project: Project, data?: Directory) => {
    const tsconfigFile = project.tsconfigFile
    const dir = path.normalize(path.dirname(tsconfigFile))
    data ??= directoryCache.get(dir)

    // Sanity check
    if (data?.projects.some((p) => p.tsconfigFile === tsconfigFile)) {
      return
    }

    onBeforeAddProject?.(project)

    // Referenced projects must be added first, so they can override
    // the parent project's paths if both are in the same directory.
    if (project.referenced) {
      project.referenced.forEach((projectRef) => {
        addProject(projectRef)
      })
      // Ensure the latest directory data is used. One of the project
      // references may have updated it.
      data = directoryCache.get(dir)
    }

    const resolver = createResolver(project, opts, logFile)
    if (resolver) {
      resolversByProject.set(project, resolver)
    }

    if (!data || data === emptyDirectory) {
      directoryCache.set(
        dir,
        (data = {
          projects: [],
          lazyDiscovery: null,
        })
      )
    }

    data.projects.push(project)
  }

  const loadProject = async (tsconfigFile: string, data?: Directory) => {
    const project = await parseProject(tsconfigFile)
    if (project) {
      addProject(project, data)
    } else {
      onParseError?.(tsconfigFile)
    }
  }

  // Ensure a deterministic order.
  const sortProjects = (projects: Project[]) => {
    projects.sort((left, right) =>
      left.tsconfigFile.localeCompare(right.tsconfigFile)
    )
  }

  const processConfigFile = async (
    dir: NormalizedPath,
    name: string,
    data = directoryCache.get(dir)
  ): Promise<void> => {
    if (!data) {
      return // Wait to be loaded on-demand.
    }
    const file = path.join(dir, name as NormalizedPath)
    if (data.projects.some((p) => p.tsconfigFile === file)) {
      return
    }
    await loadProject(file, data)
  }

  const loadEagerProjects = async () => {
    let projectPaths: string[]
    if (opts.projects) {
      projectPaths = opts.projects.map((file) => {
        if (!file.endsWith('.json')) {
          file = join(file, 'tsconfig.json')
        }
        return path.resolve(projectRoot, file)
      })
    } else {
      if (opts.projectDiscovery === 'lazy') {
        return
      }
      projectPaths = await tsconfck.findAll(workspaceRoot, {
        configNames,
        skip,
      })
    }

    debug('Eagerly parsing these projects:', projectPaths)

    await Promise.all(Array.from(new Set(projectPaths), (p) => loadProject(p)))
    for (const data of directoryCache.values()) {
      sortProjects(data.projects)
    }
  }

  const resetResolvers = () => {
    directoryCache = new Map()
    resolversByProject = new WeakMap()
    initializing = loadEagerProjects()
  }

  // Only used when projectDiscovery is 'lazy'.
  const discoverProjects = async (dir: NormalizedPath, data: Directory) => {
    debug('Searching directory for tsconfig files:', dir)
    const names = await readdir(dir).catch(() => [])

    await Promise.all(
      names
        .filter((name) => configNames.includes(name))
        .map((name) => {
          return processConfigFile(dir, name, data)
        })
    )

    if (data.projects.length) {
      sortProjects(data.projects)
      if (debug.enabled) {
        debug(
          `Directory "${dir}" contains the following tsconfig files:`,
          data.projects.map((p) => path.basename(p.tsconfigFile))
        )
      }
    } else {
      // No projects found. Reduce memory usage with a stand-in.
      directoryCache.set(dir, emptyDirectory)
      debug('No tsconfig files found in directory:', dir)
    }
  }

  const getResolvers = async function* (
    importer: string
  ): AsyncIterable<Resolver> {
    await initializing

    let dir = path.normalize(importer)
    const { root } = path.parse(dir)
    while (dir !== (dir = path.dirname(dir)) && dir !== root) {
      let data = directoryCache.get(dir)

      if (opts.projectDiscovery === 'lazy') {
        if (!data) {
          if (skip(path.basename(dir))) {
            directoryCache.set(dir, emptyDirectory)
            continue
          }
          directoryCache.set(
            dir,
            (data = {
              projects: [],
              lazyDiscovery: null,
            })
          )
        }
        await (data.lazyDiscovery ??= discoverProjects(dir, data))
      } else if (!data) {
        continue
      }

      for (const project of data.projects) {
        const resolver = resolversByProject.get(project)
        if (resolver) {
          yield resolver
        }
      }
    }
  }

  const watchProjects = (watcher: vite.FSWatcher) => {
    onBeforeAddProject = (project) => {
      watcher.add(project.tsconfigFile)
      project.extended?.forEach((parent) => {
        watcher.add(parent.tsconfigFile)
      })
    }
    onParseError = (tsconfigFile) => {
      // Try again if the file changes.
      watcher.add(tsconfigFile)
    }
    watcher.on('all', (event, file) => {
      const normalizedFile = path.normalize(file)
      if (
        !normalizedFile.endsWith('.json') ||
        !path.isAbsolute(normalizedFile)
      ) {
        return
      }
      if (event === 'add') {
        if (configNames.includes(path.basename(normalizedFile))) {
          processConfigFile(
            path.dirname(normalizedFile),
            path.basename(normalizedFile)
          ).catch(console.error)
        }
      } else if (event === 'change' || event === 'unlink') {
        invalidateConfigFile(
          path.dirname(normalizedFile),
          path.basename(normalizedFile),
          event
        )
      }
    })

    function invalidateConfigFile(
      dir: NormalizedPath,
      name: string,
      event: 'change' | 'unlink'
    ): void {
      const data = directoryCache.get(dir)
      if (!data) {
        return
      }
      const file = path.join(dir, name as NormalizedPath)
      const index = data.projects.findIndex(
        (project) => project.tsconfigFile === file
      )
      if (index !== -1) {
        const project = data.projects[index]
        debug(
          `Unloading project because of ${event} event:`,
          project.tsconfigFile
        )

        resolversByProject.delete(project)
        data.projects.splice(index, 1)

        if (event === 'change') {
          if (opts.projectDiscovery === 'lazy') {
            data.lazyDiscovery = null
          } else {
            loadProject(project.tsconfigFile, data)
              .then(() => {
                sortProjects(data.projects)
              })
              .catch(console.error)
          }
        }
      }
    }
  }

  return {
    reset: resetResolvers,
    get: getResolvers,
    watch: watchProjects,
  }
}

function createResolver(
  project: Project,
  opts: { loose?: boolean },
  logFile?: LogFileWriter | null
): Resolver | null {
  const configPath = project.tsconfigFile
  const config = project.tsconfig

  debug('Config loaded:', inspect({ configPath, config }, false, 10, true))

  // Sometimes a tsconfig is not meant to be used for path resolution,
  // but rather for pointing to other tsconfig files and possibly being
  // extended by them. This is represented by an explicitly empty "files"
  // array and a missing/empty "include" array.
  if (config.files?.length == 0 && !config.include?.length) {
    debug(
      `[!] Skipping "${configPath}" as no files can be matched since "files" is empty and "include" is missing or empty.`
    )
    return null
  }

  const compilerOptions = config.compilerOptions || {}
  const { baseUrl, paths } = compilerOptions

  type InternalResolver = (
    viteResolve: ViteResolve,
    id: string,
    importer: string
  ) => Promise<string | undefined>

  const resolveWithBaseUrl: InternalResolver | undefined = baseUrl
    ? async (viteResolve, id, importer) => {
        if (id[0] === '/') {
          return
        }
        const absoluteId = join(baseUrl, id)
        const resolvedId = await viteResolve(absoluteId, importer)
        if (resolvedId) {
          logFile?.write('resolvedWithBaseUrl', {
            importer,
            id,
            resolvedId,
            configPath,
          })
          return resolvedId
        }
      }
    : undefined

  let resolveId: InternalResolver
  if (paths) {
    const pathsRootDir = resolvePathsRootDir(project)
    const pathMappings = resolvePathMappings(paths, pathsRootDir)

    const resolveWithPaths: InternalResolver = async (
      viteResolve,
      id,
      importer
    ) => {
      const candidates = logFile ? ([] as string[]) : null
      for (const mapping of pathMappings) {
        const match = id.match(mapping.pattern)
        if (!match) {
          continue
        }
        for (let pathTemplate of mapping.paths) {
          let starCount = 0
          const mappedId = pathTemplate.replace(/\*/g, () => {
            // There may exist more globs in the path template than in
            // the match pattern. In that case, we reuse the final glob
            // match.
            const matchIndex = Math.min(++starCount, match.length - 1)
            return match[matchIndex]
          })
          candidates?.push(mappedId)
          const resolvedId = await viteResolve(mappedId, importer)
          if (resolvedId) {
            logFile?.write('resolvedWithPaths', {
              importer,
              id,
              resolvedId,
              configPath,
            })
            return resolvedId
          }
        }
      }
      logFile?.write('notFound', {
        importer,
        id,
        candidates,
        configPath,
      })
    }

    if (resolveWithBaseUrl) {
      resolveId = async (viteResolve, id, importer) =>
        (await resolveWithPaths(viteResolve, id, importer)) ??
        (await resolveWithBaseUrl(viteResolve, id, importer))
    } else {
      resolveId = resolveWithPaths
    }
  } else if (resolveWithBaseUrl) {
    resolveId = resolveWithBaseUrl
  } else {
    debug(`[!] Skipping "${configPath}" as no paths or baseUrl are defined.`)
    return null
  }

  const configDir = path.normalize(path.dirname(configPath))

  let outDir = compilerOptions.outDir && path.normalize(compilerOptions.outDir)

  // When `tsconfck.parseNative` is used, the outDir is absolute, which
  // is not what `getIncluder` expects.
  if (outDir && path.isAbsolute(outDir)) {
    outDir = path.relative(configDir, outDir)
  }

  const isIncludedRelative = getIncluder(
    config.include?.map((p) => ensureRelative(configDir, p)),
    config.exclude?.map((p) => ensureRelative(configDir, p)),
    outDir
  )

  const importerExtRE = opts.loose
    ? /$/
    : compilerOptions.allowJs ||
      path.basename(configPath).startsWith('jsconfig.')
    ? /\.(astro|mdx|svelte|vue|[mc]?[jt]sx?)$/
    : /\.[mc]?tsx?$/

  const resolutionCache = new Map<string, string>()

  return async (viteResolve, id, importer) => {
    // Remove query and hash parameters from the importer path.
    const importerFile = path.normalize(importer.replace(/[#?].+$/, ''))

    // Ignore importers with unsupported extensions.
    if (!importerExtRE.test(importerFile)) {
      logFile?.write('unsupportedExtension', { importer, id })
      return notApplicable
    }

    // Respect the include/exclude properties.
    const relativeImporterFile = path.relative(configDir, importerFile)
    if (!isIncludedRelative(relativeImporterFile)) {
      logFile?.write('configMismatch', { importer, id, configPath })
      return notApplicable
    }

    // Find and remove Vite's suffix (e.g. "?url") if present.
    // If the path is resolved, the suffix will be added back.
    const suffix = /\?.+$/.exec(id)?.[0]
    if (suffix) {
      id = id.slice(0, -suffix.length)
    }

    let resolvedId = resolutionCache.get(id)
    if (resolvedId) {
      logFile?.write('resolvedFromCache', {
        importer,
        id,
        resolvedId,
        configPath,
      })
    } else {
      resolvedId = await resolveId(viteResolve, id, importer)
      if (!resolvedId) {
        return notFound
      }
      resolutionCache.set(id, resolvedId)
    }

    // Restore the suffix if one was removed earlier.
    if (suffix) {
      resolvedId += suffix
    }

    return [resolvedId, true]
  }
}

function resolvePathsRootDir(project: Project): string {
  if (project.result) {
    const { options } = project.result
    if (options && typeof options.pathsBasePath === 'string') {
      return options.pathsBasePath
    }
    return path.dirname(project.tsconfigFile)
  }
  const baseUrl = project.tsconfig.compilerOptions?.baseUrl
  if (baseUrl) {
    return baseUrl
  }
  const projectWithPaths = project.extended?.find(
    (project) => project.tsconfig.compilerOptions?.paths
  )
  return path.dirname((projectWithPaths ?? project).tsconfigFile)
}

const defaultInclude = ['**/*']
const defaultExclude = [
  '**/node_modules',
  '**/bower_components',
  '**/jspm_packages',
]

/**
 * The returned function does not support absolute paths.
 * Be sure to call `path.relative` on your path first.
 */
function getIncluder(
  includePaths = defaultInclude,
  excludePaths = defaultExclude,
  outDir?: string
) {
  if (outDir) {
    excludePaths = excludePaths.concat(outDir)
  }
  if (includePaths.length || excludePaths.length) {
    const includers: RegExp[] = []
    const excluders: RegExp[] = []

    includePaths.forEach(addCompiledGlob, includers)
    excludePaths.forEach(addCompiledGlob, excluders)

    if (debug.enabled) {
      debug(`Compiled tsconfig globs:`, {
        include: {
          globs: includePaths,
          regexes: includers,
        },
        exclude: {
          globs: excludePaths,
          regexes: excluders,
        },
      })
    }

    return (id: string) => {
      id = id.replace(/\?.+$/, '')
      if (!path.relativeImportRE.test(id)) {
        id = './' + id
      }
      const test = (glob: RegExp) => glob.test(id)
      return includers.some(test) && !excluders.some(test)
    }
  }
  return () => true
}

function addCompiledGlob(this: RegExp[], glob: string) {
  const endsWithGlob = glob.split('/').pop()!.includes('*')
  const relativeGlob = path.relativeImportRE.test(glob) ? glob : './' + glob
  if (endsWithGlob) {
    this.push(compileGlob(relativeGlob))
  } else {
    // Append a globstar to possible directories.
    this.push(compileGlob(relativeGlob + '/**'))

    // Try to match specific files (must have file extension).
    if (/\.\w+$/.test(glob)) {
      this.push(compileGlob(relativeGlob))
    }
  }
}

function compileGlob(glob: string) {
  return globRex(glob, {
    extended: true,
    globstar: true,
  }).regex
}

function ensureRelative(dir: string, path: string) {
  return isAbsolute(path) ? relative(dir, path) : path
}
