// Copyright 2018-2026 the Deno authors. MIT license.

import {
  clearScriptNamesCache,
  debug,
  error,
  filterMapDiagnostic,
  fromTypeScriptDiagnostics,
  getCreateSourceFileOptions,
  host,
  IS_NODE_SOURCE_FILE_CACHE,
  LANGUAGE_SERVICE_ENTRIES,
  LAST_REQUEST_COMPILER_OPTIONS_KEY,
  LAST_REQUEST_METHOD,
  LAST_REQUEST_NOTEBOOK_URI,
  OperationCanceledError,
  PROJECT_VERSION_CACHE,
  SCRIPT_SNAPSHOT_CACHE,
  SCRIPT_VERSION_CACHE,
  setLogDebug,
  SOURCE_REF_COUNTS,
} from "./97_ts_host.js";

/** @type {DenoCore} */
const core = globalThis.Deno.core;
const ops = core.ops;

const ChangeKind = {
  Opened: 0,
  Modified: 1,
  Closed: 2,
};

/**
 * @param {ts.CompilerOptions | ts.MinimalResolutionCacheHost} settingsOrHost
 * @returns {ts.CompilerOptions}
 */
function getCompilationSettings(settingsOrHost) {
  if (typeof settingsOrHost.getCompilationSettings === "function") {
    return settingsOrHost.getCompilationSettings();
  }
  return /** @type {ts.CompilerOptions} */ (settingsOrHost);
}

/**
 * Assigns `moduleName` on a source file so that tsc displays the original
 * module specifier (e.g. in `typeof import("...")`) for non-file URL
 * specifiers like `https://...`. For `file://` specifiers we leave it
 * unset so tsc keeps its usual relative-path computation and so internal
 * details like the `/$node_modules/` rewrite don't leak into hovers.
 *
 * @param {ts.SourceFile} sourceFile
 * @param {string} fileName
 */
function setSourceFileModuleNameForDisplay(sourceFile, fileName) {
  if (!fileName.startsWith("file:")) {
    sourceFile.moduleName = fileName;
  }
}

// We need to use a custom document registry in order to provide source files
// with an impliedNodeFormat to the ts language service

/** @type {Map<string, ts.SourceFile>} */
const documentRegistrySourceFileCache = new Map();
const { getKeyForCompilationSettings } = ts.createDocumentRegistry(); // reuse this code
/** @type {ts.DocumentRegistry} */
const documentRegistry = {
  acquireDocument(
    fileName,
    compilationSettingsOrHost,
    scriptSnapshot,
    version,
    scriptKind,
    sourceFileOptions,
  ) {
    const key = getKeyForCompilationSettings(
      getCompilationSettings(compilationSettingsOrHost),
    );
    return this.acquireDocumentWithKey(
      fileName,
      /** @type {ts.Path} */ (fileName),
      compilationSettingsOrHost,
      key,
      scriptSnapshot,
      version,
      scriptKind,
      sourceFileOptions,
    );
  },

  acquireDocumentWithKey(
    fileName,
    path,
    _compilationSettingsOrHost,
    key,
    scriptSnapshot,
    version,
    scriptKind,
    sourceFileOptions,
  ) {
    const mapKey = path + key;
    let sourceFile = documentRegistrySourceFileCache.get(mapKey);
    if (!sourceFile || sourceFile.version !== version) {
      const isCjs = /** @type {any} */ (scriptSnapshot).isCjs;
      sourceFile = ts.createLanguageServiceSourceFile(
        fileName,
        scriptSnapshot,
        {
          ...getCreateSourceFileOptions(sourceFileOptions),
          impliedNodeFormat: isCjs
            ? ts.ModuleKind.CommonJS
            : ts.ModuleKind.ESNext,
          // in the lsp we want to be able to show documentation
          jsDocParsingMode: ts.JSDocParsingMode.ParseAll,
        },
        version,
        true,
        scriptKind,
      );
      if (scriptSnapshot.isClassicScript) {
        sourceFile.externalModuleIndicator = undefined;
      }
      // Preserve the original module specifier so that `typeof import(...)`
      // displays the URL exactly as the user wrote it, including extensions.
      // Without this, tsc's `getSpecifierForModuleSymbol` recomputes the
      // specifier via `getModuleSpecifiers`, which strips `.ts` from URL
      // imports like `https://example.com/mod.ts`. See denoland/deno#16058.
      setSourceFileModuleNameForDisplay(sourceFile, fileName);
      documentRegistrySourceFileCache.set(mapKey, sourceFile);
    }
    const sourceRefCount = SOURCE_REF_COUNTS.get(fileName) ?? 0;
    SOURCE_REF_COUNTS.set(fileName, sourceRefCount + 1);
    return sourceFile;
  },

  updateDocument(
    fileName,
    compilationSettingsOrHost,
    scriptSnapshot,
    version,
    scriptKind,
    sourceFileOptions,
  ) {
    const key = getKeyForCompilationSettings(
      getCompilationSettings(compilationSettingsOrHost),
    );
    return this.updateDocumentWithKey(
      fileName,
      /** @type {ts.Path} */ (fileName),
      compilationSettingsOrHost,
      key,
      scriptSnapshot,
      version,
      scriptKind,
      sourceFileOptions,
    );
  },

  updateDocumentWithKey(
    fileName,
    path,
    compilationSettingsOrHost,
    key,
    scriptSnapshot,
    version,
    scriptKind,
    sourceFileOptions,
  ) {
    const mapKey = path + key;
    let sourceFile = documentRegistrySourceFileCache.get(mapKey) ??
      this.acquireDocumentWithKey(
        fileName,
        path,
        compilationSettingsOrHost,
        key,
        scriptSnapshot,
        version,
        scriptKind,
        sourceFileOptions,
      );

    if (sourceFile.version !== version) {
      sourceFile = ts.updateLanguageServiceSourceFile(
        sourceFile,
        scriptSnapshot,
        version,
        scriptSnapshot.getChangeRange(
          /** @type {ts.IScriptSnapshot} */ (sourceFile.scriptSnapShot),
        ),
      );
      if (scriptSnapshot.isClassicScript) {
        sourceFile.externalModuleIndicator = undefined;
      }
      setSourceFileModuleNameForDisplay(sourceFile, fileName);
      documentRegistrySourceFileCache.set(mapKey, sourceFile);
    }
    return sourceFile;
  },

  getKeyForCompilationSettings(settings) {
    return getKeyForCompilationSettings(settings);
  },

  releaseDocument(
    fileName,
    compilationSettings,
    scriptKind,
    impliedNodeFormat,
  ) {
    const key = getKeyForCompilationSettings(compilationSettings);
    return this.releaseDocumentWithKey(
      /** @type {ts.Path} */ (fileName),
      key,
      scriptKind,
      impliedNodeFormat,
    );
  },

  releaseDocumentWithKey(path, key, _scriptKind, _impliedNodeFormat) {
    const sourceRefCount = SOURCE_REF_COUNTS.get(path) ?? 1;
    if (sourceRefCount <= 1) {
      SOURCE_REF_COUNTS.delete(path);
      // We call `cleanupSemanticCache` for other purposes, don't bust the
      // source cache in this case.
      if (LAST_REQUEST_METHOD.get() != "$cleanupSemanticCache") {
        const mapKey = path + key;
        documentRegistrySourceFileCache.delete(mapKey);
        SCRIPT_SNAPSHOT_CACHE.delete(path);
        ops.op_release(path);
      }
    } else {
      SOURCE_REF_COUNTS.set(path, sourceRefCount - 1);
    }
  },

  reportStats() {
    return "[]";
  },
};

/** @param {Record<string, unknown>} config */
function normalizeCompilerOptions(config) {
  // the typescript compiler doesn't know about the precompile
  // transform at the moment, so just tell it we're using react-jsx
  if (config.jsx === "precompile") {
    config.jsx = "react-jsx";
  }
  if (config.jsxPrecompileSkipElements) {
    delete config.jsxPrecompileSkipElements;
  }
  return config;
}

/** @param {Record<string, unknown>} config */
function lspToTsCompilerOptions(config) {
  const normalizedConfig = normalizeCompilerOptions(config);
  const { options, errors } = ts
    .convertCompilerOptionsFromJson(normalizedConfig, "");
  Object.assign(options, {
    allowNonTsExtensions: true,
    allowImportingTsExtensions: true,
    module: options.module === ts.ModuleKind.ESNext ||
        options.module === ts.ModuleKind.Preserve
      ? options.module
      : ts.ModuleKind.NodeNext,
    moduleResolution:
      options.moduleResolution === ts.ModuleResolutionKind.Bundler
        ? ts.ModuleResolutionKind.Bundler
        : ts.ModuleResolutionKind.NodeNext,
  });
  if (errors.length > 0) {
    debug(ts.formatDiagnostics(errors, host));
  }
  return options;
}

/**
 * @param {any} e
 * @returns {e is (OperationCanceledError | ts.OperationCanceledException)}
 */
function isCancellationError(e) {
  return e instanceof OperationCanceledError ||
    e instanceof ts.OperationCanceledException;
}

/**
 * @param {number} _id
 * @param {any} data
 * @param {string | null} error
 */
// TODO(bartlomieju): this feels needlessly generic, both type checking
// and language server use it with inefficient serialization. Id is not used
// anyway...
function respond(_id, data = null, error = null) {
  if (error) {
    ops.op_respond(
      "error",
      error,
    );
  } else {
    ops.op_respond(JSON.stringify(data), "");
  }
}

/** @typedef {[[string, number][], number, [string, any][], [string, string][]] } PendingChange */
/**
 * @template T
 * @typedef {T | null} Option<T> */

/** @returns {Promise<[number, string, any[], string | null, Option<PendingChange>] | null>} */
async function pollRequests() {
  return await ops.op_poll_requests();
}

let hasStarted = false;

function createLs() {
  let exportInfoMap = undefined;
  const newHost = {
    ...host,
    getCachedExportInfoMap: () => {
      // this export info map is specific to
      // the language service instance
      return exportInfoMap;
    },
  };
  const ls = ts.createLanguageService(
    newHost,
    documentRegistry,
  );
  exportInfoMap = ts.createCacheableExportInfoMap({
    getCurrentProgram() {
      return ls.getProgram();
    },
    getGlobalTypingsCacheLocation() {
      return undefined;
    },
    getPackageJsonAutoImportProvider() {
      return undefined;
    },
  });
  return ls;
}

/**
 * Prompt V8 to collect garbage and hand memory back to the OS. Triggered by
 * `op_poll_requests` after the language server has been idle, so a long-running
 * editor session doesn't keep sitting on the (often large) transient heap left
 * over from the last batch of type-checking until something else forces a
 * collection. See denoland/deno#23577.
 *
 * Note we deliberately don't drop the language services' semantic caches here:
 * `cleanupSemanticCache()` discards the current program without bumping the
 * project version, so the language service would consider itself up to date and
 * never rebuild it, breaking the next request. It's only safe to call paired
 * with a project change (see `$cleanupSemanticCache`). A full GC reclaims the
 * dead allocations without touching the still-valid live program.
 */
function releaseMemory() {
  ops.op_lsp_release_memory();
}

/** @param {boolean} enableDebugLogging */
export async function serverMainLoop(enableDebugLogging) {
  if (hasStarted) {
    throw new Error("The language server has already been initialized.");
  }
  hasStarted = true;
  setLogDebug(enableDebugLogging, "TSLS");
  debug("serverInit()");

  while (true) {
    const request = await pollRequests();
    if (request === null) {
      break;
    }
    // Synthesized by `op_poll_requests` when the server has gone idle. Handled
    // here rather than through `serverRequest()` because there's no client
    // waiting on a response.
    if (request[1] === "$releaseMemory") {
      try {
        releaseMemory();
      } catch (err) {
        error(`Internal error occurred during idle memory release: ${err}`);
      }
      continue;
    }
    try {
      serverRequest(
        request[0],
        request[1],
        request[2],
        request[3],
        request[4],
        request[5],
      );
    } catch (err) {
      error(`Internal error occurred processing request: ${err}`);
    }
  }
}

/**
 * @param {any} error
 * @param {any[] | null} args
 */
function formatErrorWithArgs(error, args) {
  let errorString = "stack" in error
    ? error.stack.toString()
    : error.toString();
  if (args) {
    errorString += `\nFor request: [${
      args.map((v) => JSON.stringify(v)).join(", ")
    }]`;
  }
  return errorString;
}

/**
 * @param {number} id
 * @param {string} method
 * @param {any[]} args
 * @param {string} compilerOptionsKey
 * @param {string | null} notebookUri
 * @param {PendingChange | null} maybeChange
 */
function serverRequestInner(
  id,
  method,
  args,
  compilerOptionsKey,
  notebookUri,
  maybeChange,
) {
  debug(
    `serverRequest()`,
    id,
    method,
    args,
    compilerOptionsKey,
    notebookUri,
    maybeChange,
  );
  if (maybeChange !== null) {
    const changedScripts = maybeChange[0];
    const newProjectVersion = maybeChange[1];
    const newCompilerOptionsByKey = maybeChange[2];
    const newNotebookKeys = maybeChange[3];
    if (newCompilerOptionsByKey) {
      IS_NODE_SOURCE_FILE_CACHE.clear();
      /** @type { typeof LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey } */
      const newByCompilerOptionsKey = new Map();
      for (const [compilerOptionsKey, options] of newCompilerOptionsByKey) {
        LAST_REQUEST_COMPILER_OPTIONS_KEY.set(compilerOptionsKey);
        LAST_REQUEST_NOTEBOOK_URI.set(null);
        const oldEntry = LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey.get(
          compilerOptionsKey,
        );
        const ls = oldEntry ? oldEntry.ls : createLs();
        const compilerOptions = lspToTsCompilerOptions(options);
        newByCompilerOptionsKey.set(compilerOptionsKey, {
          ls,
          compilerOptions,
        });
        LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey.delete(
          compilerOptionsKey,
        );
      }
      for (
        const oldEntry of LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey.values()
      ) {
        oldEntry.ls.dispose();
      }
      LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey = newByCompilerOptionsKey;
    }
    if (newNotebookKeys) {
      /** @type { typeof LANGUAGE_SERVICE_ENTRIES.byNotebookUri } */
      const newByNotebookUri = new Map();
      for (const [notebookUri, compilerOptionsKey] of newNotebookKeys) {
        LAST_REQUEST_COMPILER_OPTIONS_KEY.set(compilerOptionsKey);
        LAST_REQUEST_NOTEBOOK_URI.set(notebookUri);
        const oldEntry = LANGUAGE_SERVICE_ENTRIES.byNotebookUri.get(
          notebookUri,
        );
        const ls = oldEntry ? oldEntry.ls : createLs();
        const compilerOptions = LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey
          .get(compilerOptionsKey)?.compilerOptions;
        if (!compilerOptions) {
          throw new Error(
            `Couldn't find language service entry for key: ${compilerOptionsKey}`,
          );
        }
        newByNotebookUri.set(notebookUri, { ls, compilerOptions });
        LANGUAGE_SERVICE_ENTRIES.byNotebookUri.delete(notebookUri);
      }
      for (const oldEntry of LANGUAGE_SERVICE_ENTRIES.byNotebookUri.values()) {
        oldEntry.ls.dispose();
      }
      LANGUAGE_SERVICE_ENTRIES.byNotebookUri = newByNotebookUri;
    }

    PROJECT_VERSION_CACHE.set(newProjectVersion);

    let opened = false;
    let closed = false;
    for (const { 0: script, 1: changeKind } of changedScripts) {
      if (changeKind === ChangeKind.Opened) {
        opened = true;
      } else if (changeKind === ChangeKind.Closed) {
        closed = true;
      }
      SCRIPT_VERSION_CACHE.delete(script);
      SCRIPT_SNAPSHOT_CACHE.delete(script);
    }

    if (newCompilerOptionsByKey || newNotebookKeys || opened || closed) {
      clearScriptNamesCache();
    }
  }

  LAST_REQUEST_METHOD.set(method);
  LAST_REQUEST_COMPILER_OPTIONS_KEY.set(compilerOptionsKey);
  LAST_REQUEST_NOTEBOOK_URI.set(notebookUri);
  const ls =
    (notebookUri
      ? LANGUAGE_SERVICE_ENTRIES.byNotebookUri.get(notebookUri)?.ls
      : null) ??
      LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey.get(compilerOptionsKey)?.ls;
  if (!ls) {
    throw new Error(
      `Couldn't find language service entry for key: ${compilerOptionsKey}`,
    );
  }
  switch (method) {
    case "$cleanupSemanticCache": {
      for (
        const ls of [
          ...[...LANGUAGE_SERVICE_ENTRIES.byCompilerOptionsKey.values()].map((
            e,
          ) => e.ls),
          ...[...LANGUAGE_SERVICE_ENTRIES.byNotebookUri.values()].map((e) =>
            e.ls
          ),
        ]
      ) {
        ls.cleanupSemanticCache();
      }
      return respond(id, null);
    }
    case "$getSupportedCodeFixes": {
      return respond(
        id,
        ts.getSupportedCodeFixes(),
      );
    }
    case "$getDiagnostics": {
      const projectVersion = args[1];
      // there's a possibility that we receive a change notification
      // but the diagnostic server queues a `$getDiagnostics` request
      // with a stale project version. in that case, treat it as cancelled
      // (it's about to be invalidated anyway).
      const cachedProjectVersion = PROJECT_VERSION_CACHE.get();
      if (cachedProjectVersion && projectVersion !== cachedProjectVersion) {
        return respond(id, [[], null]);
      }
      try {
        /** @type {string} */
        const specifier = args[0];
        const diagnostics = fromTypeScriptDiagnostics([
          ...ls.getSemanticDiagnostics(specifier),
          ...ls.getSuggestionDiagnostics(specifier),
          ...ls.getSyntacticDiagnostics(specifier),
        ].filter(filterMapDiagnostic));
        return respond(id, diagnostics);
      } catch (e) {
        if (
          !isCancellationError(e)
        ) {
          return respond(
            id,
            [[], null],
            formatErrorWithArgs(e, [
              id,
              method,
              args,
              compilerOptionsKey,
              notebookUri,
              maybeChange,
            ]),
          );
        }
        return respond(id, [[], null]);
      }
    }
    case "$getAmbientModules": {
      return respond(
        id,
        ls.getProgram()?.getTypeChecker().getAmbientModules().map((symbol) =>
          symbol.getName()
        ) ?? [],
      );
    }
    default:
      if (typeof ls[method] === "function") {
        // The `getCompletionEntryDetails()` method returns null if the
        // `source` is `null` for whatever reason. It must be `undefined`.
        if (method == "getCompletionEntryDetails") {
          args[4] ??= undefined;
        }
        try {
          return respond(id, ls[method](...args));
        } catch (e) {
          if (!isCancellationError(e)) {
            return respond(
              id,
              null,
              formatErrorWithArgs(e, [
                id,
                method,
                args,
                compilerOptionsKey,
                notebookUri,
                maybeChange,
              ]),
            );
          }
          return respond(id);
        }
      }
      throw new TypeError(
        // @ts-ignore exhausted case statement sets type to never
        `Invalid request method for request: "${method}" (${id})`,
      );
  }
}

/**
 * @param {number} id
 * @param {string} method
 * @param {any[]} args
 * @param {string} compilerOptionsKey
 * @param {string | null} notebookUri
 * @param {PendingChange | null} maybeChange
 */
function serverRequest(
  id,
  method,
  args,
  compilerOptionsKey,
  notebookUri,
  maybeChange,
) {
  const span = ops.op_make_span(`serverRequest(${method})`, true);
  try {
    serverRequestInner(
      id,
      method,
      args,
      compilerOptionsKey,
      notebookUri,
      maybeChange,
    );
  } finally {
    ops.op_exit_span(span, true);
  }
}
