import { createRequire } from 'node:module'
import { dirname, relative, resolve } from 'node:path'

import { sortBy } from 'es-toolkit'
import type {
  CompilerHost,
  CompilerOptions,
  Diagnostic,
  EntityName,
  Identifier,
  NodeArray,
  SourceFile,
  Statement,
  SymbolFlags,
} from 'typescript'

import { readFileAsync } from './misc.js'

const require = createRequire(import.meta.url)
type TypeScriptModule = typeof import('typescript')
const TOP_LEVEL_NAMESPACE = '__TOP_LEVEL_MODULE__'
export const DEFAULT_TYPE_DEF_HEADER = `/* auto-generated by NAPI-RS */
/* eslint-disable */
`
const ITERATOR_OBJECT_COMPATIBILITY_DECLARATION = `  interface IteratorObject<T, TReturn = unknown, TNext = unknown>
    extends globalThis.Iterator<T, TReturn, TNext> {
    [globalThis.Symbol.iterator](): globalThis.IteratorObject<T, TReturn, TNext>
  }`

function asyncGeneratorDeclaration(name: string): string {
  // TOwner keeps downstream declaration emit importing the module that
  // installs this global augmentation, even though it is structurally unused.
  return `  interface ${name}<TOwner, T, TReturn, TNext> {
    next(...[value]: [] | [TNext]): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
    return(...[value]: [] | [TReturn]): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
    throw(exception?: unknown): globalThis.Promise<globalThis.IteratorResult<T, TReturn | undefined>>
    [globalThis.Symbol.asyncIterator](): this
  }`
}

export function createWasmModuleTypeDef() {
  return `${DEFAULT_TYPE_DEF_HEADER}
declare const wasmModule: WebAssembly.Module
export default wasmModule
`
}

enum TypeDefKind {
  Const = 'const',
  Enum = 'enum',
  StringEnum = 'string_enum',
  Interface = 'interface',
  Type = 'type',
  Fn = 'fn',
  Struct = 'struct',
  Extends = 'extends',
  Impl = 'impl',
}

interface TypeDefLine {
  kind: TypeDefKind
  name: string
  original_name?: string
  def: string
  def_with_type_import_markers?: string
  extends?: string
  asyncIterator?: [yieldType: string, returnType: string, nextType: string]
  js_doc?: string
  js_mod?: string
  type_imports?: TypeImport[]
}

export interface TypeImport {
  marker?: string
  name: string
  module: string
}

function parseTypeParameters(
  source: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
  const parameters: string[] = []
  let start = 0
  const depths = {
    angle: 0,
    brace: 0,
    bracket: 0,
    parenthesis: 0,
  }
  let quote: "'" | '"' | '`' | undefined
  let escaped = false

  for (let index = 0; index < source.length; index += 1) {
    const character = source[index]
    if (quote) {
      if (escaped) {
        escaped = false
      } else if (character === '\\') {
        escaped = true
      } else if (character === quote) {
        quote = undefined
      }
      continue
    }

    if (character === "'" || character === '"' || character === '`') {
      quote = character
      continue
    }

    switch (character) {
      case '<':
        depths.angle += 1
        break
      case '>':
        if (source[index - 1] !== '=') {
          depths.angle -= 1
        }
        break
      case '{':
        depths.brace += 1
        break
      case '}':
        depths.brace -= 1
        break
      case '[':
        depths.bracket += 1
        break
      case ']':
        depths.bracket -= 1
        break
      case '(':
        depths.parenthesis += 1
        break
      case ')':
        depths.parenthesis -= 1
        break
      case ',':
        if (Object.values(depths).every((depth) => depth === 0)) {
          parameters.push(source.slice(start, index).trim())
          start = index + 1
        }
        break
    }

    if (Object.values(depths).some((depth) => depth < 0)) {
      return
    }
  }

  if (quote || Object.values(depths).some((depth) => depth !== 0)) {
    return
  }

  parameters.push(source.slice(start).trim())
  if (parameters.length !== 3 || parameters.some((parameter) => !parameter)) {
    return
  }

  return parameters as [string, string, string]
}

function parseIteratorExtends(
  extendsDef: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
  const match = extendsDef.trim().match(/^Iterator\s*<([\s\S]*)>$/)
  return match ? parseTypeParameters(match[1]) : undefined
}

function parseAsyncGeneratorImpl(
  implDef: string,
): [yieldType: string, returnType: string, nextType: string] | undefined {
  const match = implDef
    .trim()
    .match(/^\[Symbol\.asyncIterator\]\(\):\s*AsyncGenerator\s*<([\s\S]*)>$/)
  return match ? parseTypeParameters(match[1]) : undefined
}

/**
 * Render a single intermediate type-def line as the TypeScript source it
 * should produce in `index.d.ts`.
 *
 * @param line - The intermediate type-def entry to render.
 * @param constEnum - When true, emit numeric and string `#[napi]` enums as
 *   `const enum`. When false (`--no-const-enum`), numeric enums become
 *   regular runtime enums and string enums fall back to a type-only union
 *   unless `runtimeStringEnum` is also true.
 * @param runtimeStringEnum - When true under `--no-const-enum`, emit
 *   `#[napi(string_enum)]` as a runtime enum (`export declare enum`)
 *   instead of a type-only union. No-op when `constEnum` is true.
 * @param ident - Indentation level applied to the rendered output.
 * @param ambient - When true, emit declarations in the ambient form used
 *   inside `declare namespace` blocks (e.g. drop the `declare` keyword).
 */
function prettyPrint(
  line: TypeDefLine,
  constEnum: boolean,
  runtimeStringEnum: boolean,
  ident: number,
  ambient = false,
  asyncGeneratorHelperName?: string,
): string {
  let s = line.js_doc ?? ''
  switch (line.kind) {
    case TypeDefKind.Interface:
      s += `export interface ${line.name} {\n${line.def}\n}`
      break

    case TypeDefKind.Type:
      s += `export type ${line.name} = \n${line.def}`
      break

    case TypeDefKind.Enum: {
      const enumName = constEnum ? 'const enum' : 'enum'
      s += `${exportDeclare(ambient)} ${enumName} ${line.name} {\n${line.def}\n}`
      break
    }

    case TypeDefKind.StringEnum: {
      if (constEnum) {
        s += `${exportDeclare(ambient)} const enum ${line.name} {\n${line.def}\n}`
      } else if (runtimeStringEnum) {
        s += `${exportDeclare(ambient)} enum ${line.name} {\n${line.def}\n}`
      } else {
        s += `export type ${line.name} = ${line.def.replaceAll(/.*=/g, '').replaceAll(',', '|')};`
      }
      break
    }

    case TypeDefKind.Struct: {
      let classDef = line.def
      let extendsDef = line.extends ? ` extends ${line.extends}` : ''
      let iteratorInterface = ''
      const iteratorTypes = line.extends
        ? parseIteratorExtends(line.extends)
        : undefined
      if (iteratorTypes) {
        // Runtime instances inherit from Iterator.prototype when it exists,
        // but the generated constructor does not extend the global Iterator.
        const [T, TResult, TNext] = iteratorTypes
        const resultType = `(${TResult}) | undefined`
        classDef +=
          `\n[globalThis.Symbol.iterator](): this` +
          `\nnext(...[value]: [] | [${TNext}]): globalThis.IteratorResult<${T}, ${resultType}>` +
          `\nreturn(...[value]: [] | [${TResult}]): globalThis.IteratorResult<${T}, ${resultType}>` +
          `\nthrow(exception?: unknown): globalThis.IteratorResult<${T}, ${resultType}>`
        extendsDef = ''
        iteratorInterface =
          `\n\nexport interface ${line.name} ` +
          `extends globalThis.Omit<globalThis.IteratorObject<${T}, ${resultType}, ${TNext}>, 'next' | 'return' | 'throw'> {}`
      }
      if (line.asyncIterator) {
        if (!asyncGeneratorHelperName) {
          throw new Error('Async generator helper name was not initialized')
        }
        const [T, TResult, TNext] = line.asyncIterator
        classDef += `\n[globalThis.Symbol.asyncIterator](): globalThis.${asyncGeneratorHelperName}<${line.name}, ${T}, ${TResult}, ${TNext}>`
      }
      s += `${exportDeclare(ambient)} class ${line.name}${extendsDef} {\n${classDef}\n}`
      s += iteratorInterface
      if (line.original_name && line.original_name !== line.name) {
        s += `\nexport type ${line.original_name} = ${line.name}`
      }
      break
    }

    case TypeDefKind.Fn:
      s += `${exportDeclare(ambient)} ${line.def}`
      break

    default:
      s += line.def
  }

  return correctStringIdent(s, ident)
}

function exportDeclare(ambient: boolean): string {
  if (ambient) {
    return 'export'
  }

  return 'export declare'
}

/**
 * Read the napi-derive-emitted intermediate type-def file and render its
 * entries into the `index.d.ts` source string plus the list of names to
 * re-export from `index.js`.
 *
 * @param intermediateTypeFile - Path to the JSONL type-def file produced
 *   by napi-derive (one entry per `#[napi]` item).
 * @param constEnum - See {@link prettyPrint}.
 * @param runtimeStringEnum - See {@link prettyPrint}. Defaults to `false`.
 */
export async function processTypeDef(
  intermediateTypeFile: string,
  constEnum: boolean,
  runtimeStringEnum: boolean = false,
) {
  return processTypeDefs([intermediateTypeFile], constEnum, runtimeStringEnum)
}

export async function processTypeDefs(
  intermediateTypeFiles: string[],
  constEnum: boolean,
  runtimeStringEnum: boolean = false,
  reservedDeclarationText: string = '',
) {
  const exports: string[] = []
  const typeDefs = await Promise.all(
    intermediateTypeFiles.map((file) => readIntermediateTypeFile(file)),
  )
  const typeDefsWithUniqueMarkers = makeTypeImportMarkersUnique(
    typeDefs,
    reservedDeclarationText,
  )
  const typeImports = collectTypeImports(typeDefsWithUniqueMarkers.flat())
  const dtsWithTypeImportMarkers = renderTypeDefs(
    typeDefsWithUniqueMarkers.map((defs) =>
      preprocessTypeDef(preserveTypeImportMarkers(defs)),
    ),
    constEnum,
    runtimeStringEnum,
    exports,
    reservedDeclarationText,
  )
  const dts = rewriteTypeImportReferences(
    dtsWithTypeImportMarkers,
    typeImports,
    false,
  )

  return {
    dts,
    dtsWithTypeImportMarkers,
    exports,
    typeImports,
  }
}

function renderTypeDefs(
  groupedTypeDefs: Map<string, TypeDefLine[]>[],
  constEnum: boolean,
  runtimeStringEnum: boolean,
  exports: string[],
  reservedDeclarationText: string,
): string {
  const topLevelExportNames = new Set<string>()
  for (const groupedDefs of groupedTypeDefs) {
    for (const [namespace, namespaceDefs] of groupedDefs) {
      if (namespace !== TOP_LEVEL_NAMESPACE) {
        if (namespace === 'globalThis') {
          throw new Error(
            'The export name `globalThis` is reserved by NAPI-RS type generation',
          )
        }
        topLevelExportNames.add(namespace)
      }
      for (const def of namespaceDefs) {
        if (def.name === 'globalThis' || def.original_name === 'globalThis') {
          throw new Error(
            'The export name `globalThis` is reserved by NAPI-RS type generation',
          )
        }
        if (namespace === TOP_LEVEL_NAMESPACE) {
          topLevelExportNames.add(def.name)
          if (def.original_name) {
            topLevelExportNames.add(def.original_name)
          }
        }
      }
    }
  }
  const hasIteratorClass = groupedTypeDefs.some((groupedDefs) =>
    Array.from(groupedDefs.values()).some((defs) =>
      defs.some(
        (def) =>
          def.kind === TypeDefKind.Struct &&
          def.extends !== undefined &&
          parseIteratorExtends(def.extends) !== undefined,
      ),
    ),
  )
  const hasAsyncIteratorClass = groupedTypeDefs.some((groupedDefs) =>
    Array.from(groupedDefs.values()).some((defs) =>
      defs.some(
        (def) =>
          def.kind === TypeDefKind.Struct && def.asyncIterator !== undefined,
      ),
    ),
  )
  let asyncGeneratorHelperName = '__NapiRsAsyncGenerator'
  for (
    let suffix = 1;
    topLevelExportNames.has(asyncGeneratorHelperName) ||
    reservedDeclarationText.includes(asyncGeneratorHelperName);
    suffix += 1
  ) {
    asyncGeneratorHelperName = `__NapiRsAsyncGenerator_${suffix}`
  }
  const renderedDefs = groupedTypeDefs
    .map((groupedDefs) =>
      sortBy(Array.from(groupedDefs), [([namespace]) => namespace])
        .map(([namespace, defs]) => {
          if (namespace === TOP_LEVEL_NAMESPACE) {
            return defs
              .map((def) => {
                switch (def.kind) {
                  case TypeDefKind.Const:
                  case TypeDefKind.Enum:
                  case TypeDefKind.StringEnum:
                  case TypeDefKind.Fn:
                  case TypeDefKind.Struct: {
                    exports.push(def.name)
                    if (def.original_name && def.original_name !== def.name) {
                      exports.push(def.original_name)
                    }
                    break
                  }
                  default:
                    break
                }
                return prettyPrint(
                  def,
                  constEnum,
                  runtimeStringEnum,
                  0,
                  false,
                  asyncGeneratorHelperName,
                )
              })
              .join('\n\n')
          } else {
            exports.push(namespace)
            let declaration = ''
            declaration += `export declare namespace ${namespace} {\n`
            for (const def of defs) {
              declaration +=
                prettyPrint(
                  def,
                  constEnum,
                  runtimeStringEnum,
                  2,
                  true,
                  asyncGeneratorHelperName,
                ) + '\n'
            }
            declaration += '}'
            return declaration
          }
        })
        .join('\n\n'),
    )
    .join('\n')
  const globalDeclarations = []
  if (hasIteratorClass) {
    globalDeclarations.push(ITERATOR_OBJECT_COMPATIBILITY_DECLARATION)
  }
  if (hasAsyncIteratorClass) {
    globalDeclarations.push(asyncGeneratorDeclaration(asyncGeneratorHelperName))
  }
  const dts =
    (globalDeclarations.length
      ? `declare global {\n${globalDeclarations.join('\n\n')}\n}\n\n`
      : '') +
    renderedDefs +
    '\n'
  return dts
}

function makeTypeImportMarkersUnique(
  typeDefGroups: TypeDefLine[][],
  reservedDeclarationText: string,
): TypeDefLine[][] {
  const reservedText = [
    reservedDeclarationText,
    ...typeDefGroups
      .flat()
      .flatMap((def) => [def.def, def.js_doc ?? '', def.name]),
  ].join('\n')
  const allocated = new Set<string>()

  return typeDefGroups.map((defs) =>
    defs.map((def) => {
      const markedDefinition = def.def_with_type_import_markers
      const reallocations: Array<{
        importedName: string
        nextMarker: string
        previousMarker: string
      }> = []
      const typeImports = def.type_imports?.map((typeImport) => {
        if (!typeImport.marker || markedDefinition === undefined) {
          return { ...typeImport }
        }
        const base = typeImport.marker
        let marker = base
        for (
          let suffix = 1;
          allocated.has(marker) || reservedText.includes(marker);
          suffix += 1
        ) {
          marker = `${base}_${suffix}`
        }
        allocated.add(marker)
        reallocations.push({
          importedName: typeImport.name,
          nextMarker: marker,
          previousMarker: typeImport.marker,
        })
        return { ...typeImport, marker }
      })
      return {
        ...def,
        def_with_type_import_markers:
          markedDefinition === undefined
            ? undefined
            : reallocateTypeImportMarkers(
                def.def,
                markedDefinition,
                reallocations,
              ),
        type_imports: typeImports,
      }
    }),
  )
}

function reallocateTypeImportMarkers(
  definition: string,
  markedDefinition: string,
  reallocations: Array<{
    importedName: string
    nextMarker: string
    previousMarker: string
  }>,
) {
  const changedReallocations = reallocations
    .filter(({ nextMarker, previousMarker }) => previousMarker !== nextMarker)
    .sort(
      (left, right) => right.previousMarker.length - left.previousMarker.length,
    )
  if (changedReallocations.length === 0) {
    return markedDefinition
  }

  let definitionOffset = 0
  let markedOffset = 0
  let rewritten = ''
  while (markedOffset < markedDefinition.length) {
    let markerOffset = -1
    let reallocation: (typeof changedReallocations)[number] | undefined
    for (const candidate of changedReallocations) {
      const candidateOffset = markedDefinition.indexOf(
        candidate.previousMarker,
        markedOffset,
      )
      if (
        candidateOffset !== -1 &&
        (markerOffset === -1 || candidateOffset < markerOffset)
      ) {
        markerOffset = candidateOffset
        reallocation = candidate
      }
    }
    if (markerOffset === -1 || reallocation === undefined) {
      break
    }
    const prefix = markedDefinition.slice(markedOffset, markerOffset)
    if (!definition.startsWith(prefix, definitionOffset)) {
      throw new Error(
        `Imported type marker ${reallocation.previousMarker} does not match its declaration`,
      )
    }
    rewritten += prefix
    definitionOffset += prefix.length
    markedOffset = markerOffset + reallocation.previousMarker.length

    if (definition.startsWith(reallocation.previousMarker, definitionOffset)) {
      rewritten += reallocation.previousMarker
      definitionOffset += reallocation.previousMarker.length
    } else if (
      definition.startsWith(reallocation.importedName, definitionOffset)
    ) {
      rewritten += reallocation.nextMarker
      definitionOffset += reallocation.importedName.length
    } else {
      throw new Error(
        `Imported type marker ${reallocation.previousMarker} does not match ${reallocation.importedName} in its declaration`,
      )
    }
  }

  const markedRemainder = markedDefinition.slice(markedOffset)
  const definitionRemainder = definition.slice(definitionOffset)
  if (markedRemainder !== definitionRemainder) {
    throw new Error('Imported type markers leave mismatched declaration text')
  }
  return rewritten + markedRemainder
}

function preserveTypeImportMarkers(defs: TypeDefLine[]): TypeDefLine[] {
  return defs.map((def) => ({
    ...def,
    def: def.def_with_type_import_markers ?? def.def,
  }))
}

function collectTypeImports(defs: TypeDefLine[]): TypeImport[] {
  const imports = new Map<string, TypeImport>()
  for (const typeImport of defs.flatMap((def) => def.type_imports ?? [])) {
    imports.set(
      `${typeImport.marker ?? ''}\0${typeImport.module}\0${typeImport.name}`,
      typeImport,
    )
  }
  return [...imports.values()].sort(
    (left, right) =>
      left.module.localeCompare(right.module) ||
      left.name.localeCompare(right.name) ||
      (left.marker ?? '').localeCompare(right.marker ?? ''),
  )
}

const BUFFER_TYPE_REFERENCE = 'import("buffer").Buffer'
const BUFFER_HERITAGE_ALIAS = '__NapiRsBuffer'
const IN_MEMORY_DECLARATION_FILE = '/__napi_rs_typegen__.d.ts'

export function rewriteTypeImportReferences(
  source: string,
  typeImports: TypeImport[],
  inlineImports: boolean,
): string {
  const markerImports = new Map<string, TypeImport>()
  for (const typeImport of typeImports) {
    const { marker } = typeImport
    if (marker) {
      markerImports.set(marker, typeImport)
    }
  }
  const rewriteUnboundBuffer = inlineImports && source.includes('Buffer')
  if (markerImports.size === 0 && !rewriteUnboundBuffer) {
    return source
  }

  const typeScript = loadTypeScript()
  const { program, sourceFile } = createDeclarationProgram(source)
  const checker = rewriteUnboundBuffer ? program.getTypeChecker() : undefined
  let bufferHeritageAlias: string | undefined
  const getBufferHeritageAlias = () => {
    bufferHeritageAlias ??= createCollisionSafeIdentifier(
      typeScript,
      sourceFile,
      BUFFER_HERITAGE_ALIAS,
    )
    return bufferHeritageAlias
  }
  const replacements: Array<{
    end: number
    replacement: string
    start: number
  }> = []

  const visit = (node: import('typescript').Node) => {
    if (typeScript.isIdentifier(node)) {
      const markerImport = markerImports.get(node.text)
      if (
        markerImport !== undefined &&
        typeImportReferenceMeaning(typeScript, node) !== undefined
      ) {
        const useBufferHeritageAlias =
          inlineImports &&
          markerImport.module === 'buffer' &&
          markerImport.name === 'Buffer' &&
          isHeritageReference(typeScript, node)
        replacements.push({
          start: node.getStart(sourceFile),
          end: node.end,
          replacement: inlineImports
            ? useBufferHeritageAlias
              ? getBufferHeritageAlias()
              : `import(${JSON.stringify(markerImport.module)}).${markerImport.name}`
            : markerImport.name,
        })
      } else if (
        checker !== undefined &&
        node.text === 'Buffer' &&
        isUnboundBufferReference(typeScript, checker, node)
      ) {
        replacements.push({
          start: node.getStart(sourceFile),
          end: node.end,
          replacement: isHeritageReference(typeScript, node)
            ? getBufferHeritageAlias()
            : BUFFER_TYPE_REFERENCE,
        })
      }
    }
    typeScript.forEachChild(node, visit)
  }
  visit(sourceFile)

  let rewritten = source
  for (const replacement of replacements.reverse()) {
    rewritten =
      rewritten.slice(0, replacement.start) +
      replacement.replacement +
      rewritten.slice(replacement.end)
  }
  if (bufferHeritageAlias) {
    rewritten = appendAliasedImport(
      rewritten,
      'buffer',
      'Buffer',
      bufferHeritageAlias,
    )
  }
  return rewritten
}

export function rebaseDeclarationSpecifiers(
  source: string,
  sourcePath: string,
  destinationPath: string,
): string {
  const references = collectRelativeDeclarationSpecifierReferences(source)
  const replacements: Array<{
    end: number
    replacement: string
    start: number
  }> = []

  for (const reference of references) {
    const absoluteTarget = resolve(dirname(sourcePath), reference.specifier)
    let rebased = relative(dirname(destinationPath), absoluteTarget).replaceAll(
      '\\',
      '/',
    )
    if (!rebased.startsWith('.')) {
      rebased = `./${rebased}`
    }
    replacements.push({
      start: reference.start,
      end: reference.end,
      replacement: rebased,
    })
  }

  let rebasedSource = source
  for (const replacement of replacements
    .filter(
      (replacement, index, all) =>
        all.findIndex(
          (candidate) =>
            candidate.start === replacement.start &&
            candidate.end === replacement.end,
        ) === index,
    )
    .sort((left, right) => right.start - left.start)) {
    rebasedSource =
      rebasedSource.slice(0, replacement.start) +
      replacement.replacement +
      rebasedSource.slice(replacement.end)
  }
  return rebasedSource
}

interface DeclarationSpecifierReference {
  end: number
  specifier: string
  start: number
}

export function collectRelativeDeclarationSpecifiers(source: string): string[] {
  return [
    ...new Set(
      collectRelativeDeclarationSpecifierReferences(source).map(
        ({ specifier }) => specifier,
      ),
    ),
  ]
}

function collectRelativeDeclarationSpecifierReferences(
  source: string,
): DeclarationSpecifierReference[] {
  const typeScript = loadTypeScript()
  const sourceFile = typeScript.createSourceFile(
    IN_MEMORY_DECLARATION_FILE,
    source,
    typeScript.ScriptTarget.Latest,
    true,
    typeScript.ScriptKind.TS,
  )
  const references: DeclarationSpecifierReference[] = []
  const addStringLiteral = (node: import('typescript').StringLiteralLike) => {
    if (!node.text.startsWith('.')) {
      return
    }
    references.push({
      start: node.getStart(sourceFile) + 1,
      end: node.end - 1,
      specifier: node.text,
    })
  }
  const visit = (node: import('typescript').Node) => {
    if (
      (typeScript.isImportDeclaration(node) ||
        typeScript.isExportDeclaration(node)) &&
      node.moduleSpecifier &&
      typeScript.isStringLiteralLike(node.moduleSpecifier)
    ) {
      addStringLiteral(node.moduleSpecifier)
    } else if (
      typeScript.isImportTypeNode(node) &&
      typeScript.isLiteralTypeNode(node.argument) &&
      typeScript.isStringLiteralLike(node.argument.literal)
    ) {
      addStringLiteral(node.argument.literal)
    } else if (
      typeScript.isExternalModuleReference(node) &&
      node.expression &&
      typeScript.isStringLiteralLike(node.expression)
    ) {
      addStringLiteral(node.expression)
    } else if (
      typeScript.isCallExpression(node) &&
      node.arguments.length === 1 &&
      typeScript.isStringLiteralLike(node.arguments[0]) &&
      (node.expression.kind === typeScript.SyntaxKind.ImportKeyword ||
        (typeScript.isIdentifier(node.expression) &&
          node.expression.text === 'require'))
    ) {
      addStringLiteral(node.arguments[0])
    }
    typeScript.forEachChild(node, visit)
  }
  visit(sourceFile)

  const preprocessed = typeScript.preProcessFile(source, true, true)
  for (const reference of [
    ...preprocessed.referencedFiles,
    ...preprocessed.typeReferenceDirectives,
  ]) {
    if (reference.fileName.startsWith('.')) {
      references.push({
        start: reference.pos,
        end: reference.end,
        specifier: reference.fileName,
      })
    }
  }

  return references
    .filter(
      (reference, index, all) =>
        all.findIndex(
          (candidate) =>
            candidate.start === reference.start &&
            candidate.end === reference.end,
        ) === index,
    )
    .sort((left, right) => left.start - right.start)
}

function createCollisionSafeIdentifier(
  typeScript: TypeScriptModule,
  sourceFile: SourceFile,
  baseName: string,
): string {
  const identifiers = new Set<string>()
  const visit = (node: import('typescript').Node) => {
    if (typeScript.isIdentifier(node)) {
      identifiers.add(node.text)
    }
    typeScript.forEachChild(node, visit)
  }
  visit(sourceFile)

  let identifier = baseName
  let suffix = 1
  while (identifiers.has(identifier)) {
    identifier = `${baseName}_${suffix}`
    suffix += 1
  }
  return identifier
}

function isHeritageReference(
  typeScript: TypeScriptModule,
  identifier: Identifier,
): boolean {
  const parent = identifier.parent
  return (
    typeScript.isExpressionWithTypeArguments(parent) &&
    parent.expression === identifier &&
    typeScript.isHeritageClause(parent.parent)
  )
}

function isUnboundBufferReference(
  typeScript: TypeScriptModule,
  checker: import('typescript').TypeChecker,
  identifier: Identifier,
): boolean {
  const meaning = typeImportReferenceMeaning(typeScript, identifier)
  return (
    meaning !== undefined &&
    checker.resolveName(identifier.text, identifier, meaning, false) ===
      undefined
  )
}

function typeImportReferenceMeaning(
  typeScript: TypeScriptModule,
  identifier: Identifier,
): SymbolFlags | undefined {
  let entityName: EntityName = identifier
  while (
    typeScript.isQualifiedName(entityName.parent) &&
    entityName.parent.left === entityName
  ) {
    entityName = entityName.parent
  }

  const parent = entityName.parent
  if (
    typeScript.isTypeReferenceNode(parent) &&
    parent.typeName === entityName
  ) {
    return typeScript.SymbolFlags.Type
  }
  if (typeScript.isTypeQueryNode(parent) && parent.exprName === entityName) {
    return typeScript.SymbolFlags.Value
  }
  if (
    typeScript.isExpressionWithTypeArguments(parent) &&
    parent.expression === entityName &&
    typeScript.isHeritageClause(parent.parent)
  ) {
    const heritageClause = parent.parent
    const declaration = heritageClause.parent
    return heritageClause.token === typeScript.SyntaxKind.ExtendsKeyword &&
      (typeScript.isClassDeclaration(declaration) ||
        typeScript.isClassExpression(declaration))
      ? typeScript.SymbolFlags.Value
      : typeScript.SymbolFlags.Type
  }
}

function createDeclarationProgram(source: string): {
  program: import('typescript').Program
  sourceFile: SourceFile
} {
  const typeScript = loadTypeScript()
  const options: CompilerOptions = {
    module: typeScript.ModuleKind.ESNext,
    noLib: true,
    noResolve: true,
    skipLibCheck: true,
    target: typeScript.ScriptTarget.Latest,
    types: [],
  }
  const sourceFile = typeScript.createSourceFile(
    IN_MEMORY_DECLARATION_FILE,
    source,
    options.target!,
    true,
    typeScript.ScriptKind.TS,
  )
  const host: CompilerHost = {
    fileExists: (fileName) => fileName === IN_MEMORY_DECLARATION_FILE,
    getCanonicalFileName: (fileName) => fileName,
    getCurrentDirectory: () => '/',
    getDefaultLibFileName: () => '',
    getDirectories: () => [],
    getNewLine: () => '\n',
    getSourceFile: (fileName) =>
      fileName === IN_MEMORY_DECLARATION_FILE ? sourceFile : undefined,
    readFile: (fileName) =>
      fileName === IN_MEMORY_DECLARATION_FILE ? source : undefined,
    useCaseSensitiveFileNames: () => true,
    writeFile: () => {},
  }
  const program = typeScript.createProgram({
    rootNames: [IN_MEMORY_DECLARATION_FILE],
    options,
    host,
  })
  const diagnostics = program.getSyntacticDiagnostics(sourceFile)
  if (diagnostics.length > 0) {
    throwDeclarationDiagnostics(typeScript, sourceFile, diagnostics)
  }
  return { program, sourceFile }
}

function throwDeclarationDiagnostics(
  typeScript: TypeScriptModule,
  sourceFile: SourceFile,
  diagnostics: readonly Diagnostic[],
): never {
  const messages = diagnostics.slice(0, 3).map((diagnostic) => {
    const message = typeScript.flattenDiagnosticMessageText(
      diagnostic.messageText,
      '\n',
    )
    if (diagnostic.start === undefined) {
      return message
    }
    const { character, line } = sourceFile.getLineAndCharacterOfPosition(
      diagnostic.start,
    )
    return `${line + 1}:${character + 1} ${message}`
  })
  throw new Error(`Failed to parse declaration source:\n${messages.join('\n')}`)
}

function decodeStructuralNewlines(source: string): string {
  let output = ''
  let index = 0

  while (index < source.length) {
    const character = source[index]
    if (character === "'" || character === '"' || character === '`') {
      const quote = character
      output += character
      index += 1
      while (index < source.length) {
        const current = source[index]
        output += current
        index += 1
        if (current === '\\' && index < source.length) {
          output += source[index]
          index += 1
        } else if (current === quote) {
          break
        }
      }
      continue
    }
    if (character === '/' && source[index + 1] === '/') {
      const end = source.indexOf('\n', index + 2)
      if (end === -1) {
        output += source.slice(index)
        break
      }
      output += source.slice(index, end)
      index = end
      continue
    }
    if (character === '/' && source[index + 1] === '*') {
      const end = source.indexOf('*/', index + 2)
      if (end === -1) {
        output += source.slice(index)
        break
      }
      output += source.slice(index, end + 2)
      index = end + 2
      continue
    }
    if (character === '\\' && source[index + 1] === 'n') {
      output += '\n'
      index += 2
      continue
    }
    output += character
    index += 1
  }
  return output
}

export function appendTypeImports(
  source: string,
  typeImports: TypeImport[],
): string {
  const typeScript = loadTypeScript()
  const parsed = parseDeclarationSource(source)
  const missingImports = typeImports.filter(
    (typeImport) => !hasNamedImport(parsed.statements, typeImport),
  )
  if (missingImports.length === 0) {
    return source
  }

  const importsByModule = new Map<string, string[]>()
  for (const { module, name } of missingImports) {
    const names = importsByModule.get(module) ?? []
    names.push(name)
    importsByModule.set(module, names)
  }
  const newline = source.includes('\r\n') ? '\r\n' : '\n'
  const importSource = [...importsByModule]
    .sort(([left], [right]) => left.localeCompare(right))
    .map(
      ([module, names]) =>
        `import type { ${[...new Set(names)].sort().join(', ')} } from ${JSON.stringify(module)}`,
    )
    .join(newline)
  const importDeclarations = parsed.statements.filter(
    typeScript.isImportDeclaration,
  )
  const insertionOffset = importDeclarations.length
    ? endOfLine(source, importDeclarations.at(-1)!.end)
    : leadingDeclarationPreambleEnd(source, parsed)

  return insertDeclarationSource(source, insertionOffset, importSource, newline)
}

function appendAliasedImport(
  source: string,
  module: string,
  importedName: string,
  localName: string,
): string {
  const typeScript = loadTypeScript()
  const parsed = parseDeclarationSource(source)
  const newline = source.includes('\r\n') ? '\r\n' : '\n'
  const importDeclarations = parsed.statements.filter(
    typeScript.isImportDeclaration,
  )
  const insertionOffset = importDeclarations.length
    ? endOfLine(source, importDeclarations.at(-1)!.end)
    : leadingDeclarationPreambleEnd(source, parsed)
  return insertDeclarationSource(
    source,
    insertionOffset,
    `import { ${importedName} as ${localName} } from ${JSON.stringify(module)}`,
    newline,
  )
}

export function removeNodeStreamWebTypeImports(source: string): string {
  const typeScript = loadTypeScript()
  const parsed = parseDeclarationSource(source)
  const removals: Array<{ start: number; end: number }> = []

  for (const statement of parsed.statements) {
    if (
      !typeScript.isImportDeclaration(statement) ||
      !typeScript.isStringLiteral(statement.moduleSpecifier) ||
      statement.moduleSpecifier.text !== 'node:stream/web'
    ) {
      continue
    }
    const importClause = statement.importClause
    const namedBindings = importClause?.namedBindings
    const unaliasedNamedTypeImports =
      importClause !== undefined &&
      namedBindings !== undefined &&
      typeScript.isNamedImports(namedBindings) &&
      namedBindings.elements.length > 0 &&
      namedBindings.elements.every(
        (specifier) =>
          (importClause.isTypeOnly || specifier.isTypeOnly) &&
          specifier.propertyName === undefined,
      )
    if (!unaliasedNamedTypeImports) {
      throw new Error(
        'Threadless declaration headers may only import unaliased types from node:stream/web so DOM globals can replace them',
      )
    }
    removals.push(
      expandToDeclarationLine(
        source,
        statement.getStart(parsed),
        statement.end,
      ),
    )
  }

  let result = source
  for (const removal of removals.reverse()) {
    result = result.slice(0, removal.start) + result.slice(removal.end)
  }
  return result
}

export function rewriteUnboundNodeGlobalTypeQueries(source: string): string {
  if (!source.includes('global')) {
    return source
  }
  const typeScript = loadTypeScript()
  const { program, sourceFile } = createDeclarationProgram(source)
  const checker = program.getTypeChecker()
  const replacements: Array<{ start: number; end: number }> = []
  const visit = (node: import('typescript').Node) => {
    if (
      typeScript.isIdentifier(node) &&
      node.text === 'global' &&
      typeScript.isTypeQueryNode(node.parent) &&
      node.parent.exprName === node &&
      checker.resolveName(
        node.text,
        node,
        typeScript.SymbolFlags.Value,
        false,
      ) === undefined
    ) {
      replacements.push({
        start: node.getStart(sourceFile),
        end: node.end,
      })
    }
    typeScript.forEachChild(node, visit)
  }
  visit(sourceFile)

  let rewritten = source
  for (const replacement of replacements.reverse()) {
    rewritten =
      rewritten.slice(0, replacement.start) +
      'globalThis' +
      rewritten.slice(replacement.end)
  }
  return rewritten
}

function parseDeclarationSource(source: string) {
  const typeScript = loadTypeScript()
  const parsed = typeScript.createSourceFile(
    'index.d.ts',
    source,
    typeScript.ScriptTarget.Latest,
    true,
    typeScript.ScriptKind.TS,
  )
  const diagnostics = (
    parsed as typeof parsed & {
      parseDiagnostics: readonly Diagnostic[]
    }
  ).parseDiagnostics
  if (diagnostics.length > 0) {
    throw new Error(
      `Failed to parse declaration source:\n${diagnostics
        .slice(0, 3)
        .map((diagnostic) =>
          typeScript.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
        )
        .join('\n')}`,
    )
  }
  return parsed
}

function hasNamedImport(
  statements: NodeArray<Statement>,
  typeImport: TypeImport,
): boolean {
  const typeScript = loadTypeScript()
  return statements.some(
    (statement) =>
      typeScript.isImportDeclaration(statement) &&
      typeScript.isStringLiteral(statement.moduleSpecifier) &&
      statement.moduleSpecifier.text === typeImport.module &&
      statement.importClause?.namedBindings !== undefined &&
      typeScript.isNamedImports(statement.importClause.namedBindings) &&
      statement.importClause.namedBindings.elements.some(
        (specifier) => specifier.name.text === typeImport.name,
      ),
  )
}

function leadingDeclarationPreambleEnd(
  source: string,
  parsed: ReturnType<typeof parseDeclarationSource>,
): number {
  const typeScript = loadTypeScript()
  const firstStatementStart =
    parsed.statements[0]?.getStart(parsed) ?? source.length
  const leadingComments =
    typeScript.getLeadingCommentRanges(
      source,
      parsed.statements[0]?.pos ?? 0,
    ) ?? []
  let offset = 0

  for (const comment of leadingComments) {
    if (
      comment.end > firstStatementStart ||
      source.slice(offset, comment.pos).trim()
    ) {
      break
    }
    if (source.startsWith('/**', comment.pos)) {
      break
    }
    offset = endOfLine(source, comment.end)
  }

  return offset
}

let loadedTypeScript: TypeScriptModule | undefined

function loadTypeScript(): TypeScriptModule {
  loadedTypeScript ??= require('typescript') as TypeScriptModule
  return loadedTypeScript
}

function endOfLine(source: string, offset: number): number {
  const newline = source.indexOf('\n', offset)
  return newline === -1 ? source.length : newline + 1
}

function insertDeclarationSource(
  source: string,
  offset: number,
  inserted: string,
  newline: string,
): string {
  const before = source.slice(0, offset)
  const after = source.slice(offset)
  const beforeSeparator =
    before.length > 0 && !before.endsWith('\n') ? newline : ''
  const afterSeparator =
    after.length > 0 && !after.startsWith('\n') ? newline : ''
  const finalNewline = after.length === 0 ? newline : ''
  return `${before}${beforeSeparator}${inserted}${afterSeparator}${after}${finalNewline}`
}

function expandToDeclarationLine(
  source: string,
  start: number,
  end: number,
): { start: number; end: number } {
  const lineStart = source.lastIndexOf('\n', start - 1) + 1
  const newline = source.indexOf('\n', end)
  const lineEnd = newline === -1 ? source.length : newline + 1
  const prefix = source.slice(lineStart, start)
  const suffix = source.slice(end, newline === -1 ? source.length : newline)
  return prefix.trim() || suffix.trim()
    ? { start, end }
    : { start: lineStart, end: lineEnd }
}

async function readIntermediateTypeFile(file: string) {
  const content = await readFileAsync(file, 'utf8')

  const defs = content
    .split('\n')
    .filter(Boolean)
    .map((line) => {
      line = line.trim()
      const parsed = JSON.parse(line) as TypeDefLine
      if (parsed.js_doc) {
        parsed.js_doc = parsed.js_doc.replace(/\\n/g, '\n')
      }
      if (parsed.def) {
        parsed.def = decodeStructuralNewlines(parsed.def)
      }
      if (parsed.def_with_type_import_markers) {
        parsed.def_with_type_import_markers = decodeStructuralNewlines(
          parsed.def_with_type_import_markers,
        )
      }
      return parsed
    })

  // move all `struct` def to the very top
  // and order the rest alphabetically.
  return defs.sort((a, b) => {
    if (a.kind === TypeDefKind.Struct) {
      if (b.kind === TypeDefKind.Struct) {
        return a.name.localeCompare(b.name)
      }
      return -1
    } else if (b.kind === TypeDefKind.Struct) {
      return 1
    } else {
      return a.name.localeCompare(b.name)
    }
  })
}

function preprocessTypeDef(defs: TypeDefLine[]): Map<string, TypeDefLine[]> {
  const namespaceGrouped = new Map<string, TypeDefLine[]>()
  const classDefs = new Map<string, TypeDefLine>()

  for (const def of defs) {
    const namespace = def.js_mod ?? TOP_LEVEL_NAMESPACE
    if (!namespaceGrouped.has(namespace)) {
      namespaceGrouped.set(namespace, [])
    }

    const group = namespaceGrouped.get(namespace)!
    const classKey = `${namespace}\0${def.name}`

    if (def.kind === TypeDefKind.Struct) {
      group.push(def)
      classDefs.set(classKey, def)
    } else if (def.kind === TypeDefKind.Extends) {
      const classDef = classDefs.get(classKey)
      if (classDef) {
        classDef.extends = def.def
      }
    } else if (def.kind === TypeDefKind.Impl) {
      // merge `impl` into class definition
      const classDef = classDefs.get(classKey)
      if (classDef) {
        const asyncIterator = parseAsyncGeneratorImpl(def.def)
        if (asyncIterator) {
          classDef.asyncIterator = asyncIterator
        } else {
          if (classDef.def) {
            classDef.def += '\n'
          }

          classDef.def += def.def
          // Convert any remaining \n sequences in the merged def to actual newlines
          if (classDef.def) {
            classDef.def = classDef.def.replace(/\\n/g, '\n')
          }
        }
      }
    } else {
      group.push(def)
    }
  }

  return namespaceGrouped
}

export function correctStringIdent(src: string, ident: number): string {
  let bracketDepth = 0
  const result = src
    .split('\n')
    .map((line) => {
      line = line.trim()
      if (line === '') {
        return ''
      }

      const isInMultilineComment = line.startsWith('*')
      const isClosingBracket = line.endsWith('}')
      const isOpeningBracket = line.endsWith('{')
      const isTypeDeclaration = line.endsWith('=')
      const isTypeVariant = line.startsWith('|')

      let rightIndent = ident
      if ((isOpeningBracket || isTypeDeclaration) && !isInMultilineComment) {
        bracketDepth += 1
        rightIndent += (bracketDepth - 1) * 2
      } else {
        if (
          isClosingBracket &&
          bracketDepth > 0 &&
          !isInMultilineComment &&
          !isTypeVariant
        ) {
          bracketDepth -= 1
        }
        rightIndent += bracketDepth * 2
      }

      if (isInMultilineComment) {
        rightIndent += 1
      }

      const s = `${' '.repeat(rightIndent)}${line}`

      return s
    })
    .join('\n')

  return result
}
