/**
 *  Copyright (c) 2021 GraphQL Contributors
 *  All rights reserved.
 *
 *  This source code is licensed under the license found in the
 *  LICENSE file in the root directory of this source tree.
 *
 */
import { existsSync, mkdirSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { URI } from 'vscode-uri';
import {
  CachedContent,
  Uri,
  FileChangeTypeKind,
  Range,
  Position,
  IPosition,
} from 'graphql-language-service';
import type { GraphQLConfig, GraphQLProjectConfig } from 'graphql-config';

import { GraphQLLanguageService } from './GraphQLLanguageService';

import type {
  CompletionParams,
  FileEvent,
  VersionedTextDocumentIdentifier,
  DidSaveTextDocumentParams,
  DidOpenTextDocumentParams,
  DidChangeConfigurationParams,
  Diagnostic,
  CompletionList,
  CancellationToken,
  Hover,
  InitializeResult,
  Location,
  PublishDiagnosticsParams,
  DidChangeTextDocumentParams,
  DidCloseTextDocumentParams,
  DidChangeWatchedFilesParams,
  InitializeParams,
  Range as RangeType,
  Position as VscodePosition,
  TextDocumentPositionParams,
  DocumentSymbolParams,
  SymbolInformation,
  WorkspaceSymbolParams,
  Connection,
  DidChangeConfigurationRegistrationOptions,
} from 'vscode-languageserver/node';

import type { UnnormalizedTypeDefPointer } from '@graphql-tools/load';

import { getGraphQLCache, GraphQLCache } from './GraphQLCache';
import { parseDocument } from './parseDocument';

import { printSchema, visit, parse, FragmentDefinitionNode } from 'graphql';
import { tmpdir } from 'node:os';
import {
  ConfigEmptyError,
  ConfigInvalidError,
  ConfigNotFoundError,
  LoaderNoResultError,
  ProjectNotFoundError,
} from 'graphql-config';
import type { LoadConfigOptions, LocateCommand } from './types';
import {
  DEFAULT_SUPPORTED_EXTENSIONS,
  SupportedExtensionsEnum,
} from './constants';
import { NoopLogger, Logger } from './Logger';
import { glob } from 'fast-glob';
import { isProjectSDLOnly, unwrapProjectSchema } from './common';
import { DefinitionQueryResponse } from 'graphql-language-service/src/interface';
import { default as debounce } from 'debounce-promise';

const configDocLink =
  'https://www.npmjs.com/package/graphql-language-service-server#user-content-graphql-configuration-file';

type CachedDocumentType = {
  version: number;
  contents: CachedContent[];
};

function toPosition(position: VscodePosition): IPosition {
  return new Position(position.line, position.character);
}

export class MessageProcessor {
  private _connection: Connection;
  private _graphQLCache!: GraphQLCache;
  private _languageService!: GraphQLLanguageService;
  private _textDocumentCache = new Map<string, CachedDocumentType>();
  private _isInitialized = false;
  private _isGraphQLConfigMissing: boolean | null = null;
  private _willShutdown = false;
  private _logger: Logger | NoopLogger;
  private _parser: (text: string, uri: string) => Promise<CachedContent[]>;
  private _tmpDir: string;
  private _tmpDirBase: string;
  private _loadConfigOptions: LoadConfigOptions;
  private _rootPath: string = process.cwd();
  private _settings: any;
  private _providedConfig?: GraphQLConfig;

  constructor({
    logger,
    fileExtensions,
    graphqlFileExtensions,
    loadConfigOptions,
    config,
    parser,
    tmpDir,
    connection,
  }: {
    logger: Logger | NoopLogger;
    fileExtensions: ReadonlyArray<SupportedExtensionsEnum>;
    graphqlFileExtensions: string[];
    loadConfigOptions: LoadConfigOptions;
    config?: GraphQLConfig;
    parser?: typeof parseDocument;
    tmpDir?: string;
    connection: Connection;
  }) {
    if (config) {
      this._providedConfig = config;
    }
    this._connection = connection;
    this._logger = logger;
    this._parser = async (text, uri) => {
      const p = parser ?? parseDocument;
      return p(text, uri, fileExtensions, graphqlFileExtensions, this._logger);
    };
    this._tmpDir = tmpDir || tmpdir();
    this._tmpDirBase = path.join(this._tmpDir, 'graphql-language-service');
    // use legacy mode by default for backwards compatibility
    this._loadConfigOptions = { legacy: true, ...loadConfigOptions };
    /**
     * existsSync(this._tmpDirBase) with mkdirSync(this._tmpDirBase) provoke race condition, we use
     * `{ recursive: true }` that way, if the directory already exists, it does not throw.
     */
    // if (!existsSync(this._tmpDirBase)) {
    mkdirSync(this._tmpDirBase, { recursive: true });
    // }
  }
  get connection(): Connection {
    return this._connection;
  }
  set connection(connection: Connection) {
    this._connection = connection;
  }

  public async handleInitializeRequest(
    params: InitializeParams,
    _token?: CancellationToken,
    configDir?: string,
  ): Promise<InitializeResult> {
    if (!params) {
      throw new Error('`params` argument is required to initialize.');
    }

    const serverCapabilities: InitializeResult = {
      capabilities: {
        workspaceSymbolProvider: true,
        documentSymbolProvider: true,
        completionProvider: {
          resolveProvider: true,
          triggerCharacters: [' ', ':', '$', '(', '@', '\n'],
        },
        definitionProvider: true,
        textDocumentSync: 1,
        hoverProvider: true,
        workspace: {
          workspaceFolders: {
            supported: true,
            changeNotifications: true,
          },
        },
      },
    };

    this._rootPath = configDir
      ? configDir.trim()
      : params.rootUri || this._rootPath;
    if (!this._rootPath) {
      this._logger.warn(
        'no rootPath configured in extension or server, defaulting to cwd',
      );
    }

    this._logger.info(
      JSON.stringify({
        type: 'usage',
        messageType: 'initialize',
      }),
    );

    return serverCapabilities;
  }
  // TODO next: refactor (most of) this into the `GraphQLCache` class
  async _initializeGraphQLCaches() {
    const settings = await this._connection.workspace.getConfiguration({
      section: 'graphql-config',
    });

    const vscodeSettings = await this._connection.workspace.getConfiguration({
      section: 'vscode-graphql',
    });

    // TODO: eventually we will instantiate an instance of this per workspace,
    // so rootDir should become that workspace's rootDir
    this._settings = { ...settings, ...vscodeSettings };
    const rootDir = this._settings?.load?.rootDir.length
      ? this._settings?.load?.rootDir
      : this._rootPath;
    if (settings?.dotEnvPath) {
      require('dotenv').config({
        path: path.resolve(rootDir, settings.dotEnvPath),
      });
    }
    this._rootPath = rootDir;
    this._loadConfigOptions = {
      ...Object.keys(this._settings?.load ?? {}).reduce((agg, key) => {
        const value = this._settings?.load[key];
        if (value === undefined || value === null) {
          delete agg[key];
        }
        return agg;
      }, this._settings.load ?? {}),
      rootDir,
    };

    const onSchemaChange = debounce(async (project: GraphQLProjectConfig) => {
      const { cacheSchemaFileForLookup } =
        this.getCachedSchemaSettings(project);
      if (!cacheSchemaFileForLookup) {
        return;
      }
      const unwrappedSchema = unwrapProjectSchema(project);
      const sdlOnly = isProjectSDLOnly(unwrappedSchema);
      if (sdlOnly) {
        return;
      }
      return this.cacheConfigSchemaFile(project);
    }, 400);

    try {
      // now we have the settings so we can re-build the logger
      this._logger.level = this._settings?.debug === true ? 1 : 0;
      // createServer() can be called with a custom config object, and
      // this is a public interface that may be used by customized versions of the server
      if (this._providedConfig) {
        this._graphQLCache = new GraphQLCache({
          config: this._providedConfig,
          logger: this._logger,
          parser: this._parser,
          configDir: rootDir,
          onSchemaChange,
          schemaCacheTTL: this._settings?.schemaCacheTTL,
        });
        this._languageService = new GraphQLLanguageService(
          this._graphQLCache,
          this._logger,
        );
      } else {
        // reload the graphql cache
        this._graphQLCache = await getGraphQLCache({
          parser: this._parser,
          loadConfigOptions: this._loadConfigOptions,
          logger: this._logger,
          onSchemaChange,
          schemaCacheTTL: this._settings?.schemaCacheTTL,
        });
        this._languageService = new GraphQLLanguageService(
          this._graphQLCache,
          this._logger,
        );
      }
      const config = this._graphQLCache.getGraphQLConfig();
      if (config) {
        await this._cacheAllProjectFiles(config);
        // TODO: per project lazy instantiation.
        // we had it working before, but it seemed like it caused bugs
        // which were caused by something else.
        // thus. _isInitialized should be replaced with something like
        // projectHasInitialized: (projectName: string) => boolean
        this._isInitialized = true;
        this._isGraphQLConfigMissing = false;
        this._logger.info('GraphQL Language Server caches initialized');
      }
    } catch (err) {
      this._handleConfigError({ err });
    }
  }
  private _handleConfigError({ err }: { err: unknown; uri?: string }) {
    if (err instanceof ConfigNotFoundError || err instanceof ConfigEmptyError) {
      // TODO: obviously this needs to become a map by workspace from uri
      // for workspaces support
      this._isGraphQLConfigMissing = true;
      this._logConfigError(err.message);
    } else if (err instanceof ProjectNotFoundError) {
      // this is the only case where we don't invalidate config;
      // TODO: per-project schema initialization status (PR is almost ready)
      this._logConfigError(
        'Project not found for this file - make sure that a schema is present in the config file or for the project',
      );
    } else if (err instanceof ConfigInvalidError) {
      this._isGraphQLConfigMissing = true;
      this._logConfigError(`Invalid configuration\n${err.message}`);
    } else if (err instanceof LoaderNoResultError) {
      this._isGraphQLConfigMissing = true;
      this._logConfigError(err.message);
      return;
    } else {
      // if it's another kind of error,
      // lets just assume the config is missing and
      // disable language features
      this._isGraphQLConfigMissing = true;
      this._logConfigError(
        // @ts-expect-error
        err?.message ?? err?.toString(),
      );
    }
  }

  private _logConfigError(errorMessage: string) {
    this._logger.error(
      'WARNING: graphql-config error, only highlighting is enabled:\n' +
        errorMessage +
        `\nfor more information on using 'graphql-config' with 'graphql-language-service-server', \nsee the documentation at ${configDocLink}`,
    );
  }
  private async _isGraphQLConfigFile(uri: string) {
    const configMatchers = ['graphql.config', 'graphqlrc', 'graphqlconfig'];
    if (this._settings?.load?.fileName?.length) {
      configMatchers.push(this._settings.load.fileName);
    }

    const fileMatch = configMatchers
      .filter(Boolean)
      .some(v => uri.match(v)?.length);
    if (fileMatch) {
      return fileMatch;
    }
    if (uri.match('package.json')?.length) {
      try {
        const pkgConfig = await readFile(URI.parse(uri).fsPath, 'utf-8');
        return Boolean(JSON.parse(pkgConfig)?.graphql);
      } catch {}
    }
    return false;
  }
  private async _loadConfigOrSkip(uri: string) {
    try {
      const isGraphQLConfigFile = await this._isGraphQLConfigFile(uri);

      if (!this._isInitialized) {
        if (this._isGraphQLConfigMissing === true && !isGraphQLConfigFile) {
          return true;
        }
        // don't try to initialize again if we've already tried
        // and the graphql config file or package.json entry isn't even there
        await this._initializeGraphQLCaches();
        return isGraphQLConfigFile;
      }
      // if it has initialized, but this is another config file change, then let's handle it
      if (isGraphQLConfigFile) {
        await this._initializeGraphQLCaches();
      }
      return isGraphQLConfigFile;
    } catch (err) {
      this._logger.error(String(err));
      // return true if it's a graphql config file so we don't treat
      // this as a non-config file if it is one
      return true;
    }
  }

  public async handleDidOpenOrSaveNotification(
    params: DidSaveTextDocumentParams | DidOpenTextDocumentParams,
  ): Promise<PublishDiagnosticsParams> {
    const { textDocument } = params;
    const { uri } = textDocument;

    /**
     * Initialize the LSP server when the first file is opened or saved,
     * so that we can access the user settings for config rootDir, etc
     */
    const shouldSkip = await this._loadConfigOrSkip(uri);
    // if we're loading config or the config is missing or there's an error
    // don't do anything else
    if (shouldSkip) {
      return { uri, diagnostics: [] };
    }

    // Here, we set the workspace settings in memory,
    // and re-initialize the language service when a different
    // root path is detected.
    // We aren't able to use initialization event for this
    // and the config change event is after the fact.

    if (!textDocument) {
      throw new Error('`textDocument` argument is required.');
    }

    const diagnostics: Diagnostic[] = [];

    if (!this._isInitialized) {
      return { uri, diagnostics };
    }
    try {
      const project = this._graphQLCache.getProjectForFile(uri);

      if (project) {
        const text = 'text' in textDocument && textDocument.text;
        // for some reason if i try to tell to not parse empty files, it breaks :shrug:
        // i think this is because if the file change is empty, it doesn't get parsed
        // TODO: this could be related to a bug in how we are calling didOpenOrSave in our tests
        // that doesn't reflect the real runtime behavior

        const { contents } = await this._parseAndCacheFile(
          uri,
          project,
          text as string,
        );
        if (project?.extensions?.languageService?.enableValidation !== false) {
          await Promise.all(
            contents.map(async ({ query, range }) => {
              const results = await this._languageService.getDiagnostics(
                query,
                uri,
                this._isRelayCompatMode(query),
              );
              if (results && results.length > 0) {
                diagnostics.push(
                  ...processDiagnosticsMessage(results, query, range),
                );
              }
            }),
          );
        }
      }

      this._logger.log(
        JSON.stringify({
          type: 'usage',
          messageType: 'textDocument/didOpenOrSave',
          projectName: project?.name,
          fileName: uri,
        }),
      );
      return { uri, diagnostics };
    } catch (err) {
      this._handleConfigError({ err, uri });
      return { uri, diagnostics };
    }
  }

  public async handleDidChangeNotification(
    params: DidChangeTextDocumentParams,
  ): Promise<PublishDiagnosticsParams | null> {
    if (
      this._isGraphQLConfigMissing ||
      !this._isInitialized ||
      !this._graphQLCache
    ) {
      return null;
    }
    // For every `textDocument/didChange` event, keep a cache of textDocuments
    // with version information up-to-date, so that the textDocument contents
    // may be used during performing language service features,
    // e.g. auto-completions.
    if (!params?.textDocument?.uri || !params.contentChanges) {
      throw new Error(
        '`textDocument.uri` and `contentChanges` arguments are required.',
      );
    }
    const { textDocument, contentChanges } = params;
    const { uri } = textDocument;

    try {
      const project = this._graphQLCache.getProjectForFile(uri);
      if (!project) {
        return { uri, diagnostics: [] };
      }

      // As `contentChanges` is an array, and we just want the
      // latest update to the text, grab the last entry from the array.

      // If it's a .js file, try parsing the contents to see if GraphQL queries
      // exist. If not found, delete from the cache.
      const { contents } = await this._parseAndCacheFile(
        uri,
        project,
        contentChanges.at(-1)!.text,
      );
      // // If it's a .graphql file, proceed normally and invalidate the cache.
      // await this._invalidateCache(textDocument, uri, contents);

      const diagnostics: Diagnostic[] = [];

      if (project?.extensions?.languageService?.enableValidation !== false) {
        // Send the diagnostics onChange as well
        try {
          await Promise.all(
            contents.map(async ({ query, range }) => {
              const results = await this._languageService.getDiagnostics(
                query,
                uri,
                this._isRelayCompatMode(query),
              );
              if (results && results.length > 0) {
                diagnostics.push(
                  ...processDiagnosticsMessage(results, query, range),
                );
              }
              // skip diagnostic errors, usually related to parsing incomplete fragments
            }),
          );
        } catch {}
      }

      this._logger.log(
        JSON.stringify({
          type: 'usage',
          messageType: 'textDocument/didChange',
          projectName: project?.name,
          fileName: uri,
        }),
      );

      return { uri, diagnostics };
    } catch (err) {
      this._handleConfigError({ err, uri });
      return { uri, diagnostics: [] };
    }
  }
  async handleDidChangeConfiguration(
    _params: DidChangeConfigurationParams,
  ): Promise<DidChangeConfigurationRegistrationOptions> {
    await this._initializeGraphQLCaches();
    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'workspace/didChangeConfiguration',
      }),
    );
    return {};
  }

  public handleDidCloseNotification(params: DidCloseTextDocumentParams): void {
    if (!this._isInitialized) {
      return;
    }
    // For every `textDocument/didClose` event, delete the cached entry.
    // This is to keep a low memory usage && switch the source of truth to
    // the file on disk.
    if (!params?.textDocument) {
      throw new Error('`textDocument` is required.');
    }
    const { textDocument } = params;
    const { uri } = textDocument;

    if (this._textDocumentCache.has(uri)) {
      this._textDocumentCache.delete(uri);
    }
    const project = this._graphQLCache.getProjectForFile(uri);

    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'textDocument/didClose',
        projectName: project?.name,
        fileName: uri,
      }),
    );
  }

  public handleShutdownRequest(): void {
    this._willShutdown = true;
  }

  public handleExitNotification(): void {
    process.exit(this._willShutdown ? 0 : 1);
  }

  private validateDocumentAndPosition(params: CompletionParams): void {
    if (!params?.textDocument?.uri || !params.position) {
      throw new Error(
        '`textDocument.uri` and `position` arguments are required.',
      );
    }
  }

  public async handleCompletionRequest(
    params: CompletionParams,
  ): Promise<CompletionList> {
    if (!this._isInitialized) {
      return { items: [], isIncomplete: false };
    }

    this.validateDocumentAndPosition(params);

    const { textDocument, position } = params;

    // `textDocument/completion` event takes advantage of the fact that
    // `textDocument/didChange` event always fires before, which would have
    // updated the cache with the query text from the editor.
    // Treat the computed list always complete.

    const cachedDocument = this._getCachedDocument(textDocument.uri);
    if (!cachedDocument) {
      return { items: [], isIncomplete: false };
    }

    const found = cachedDocument.contents.find(content => {
      const currentRange = content.range;
      if (currentRange?.containsPosition(toPosition(position))) {
        return true;
      }
    });

    // If there is no GraphQL query in this file, return an empty result.
    if (!found) {
      return { items: [], isIncomplete: false };
    }

    const { query, range } = found;

    if (range) {
      position.line -= range.start.line;
    }

    const result = await this._languageService.getAutocompleteSuggestions(
      query,
      toPosition(position),
      textDocument.uri,
    );

    const project = this._graphQLCache.getProjectForFile(textDocument.uri);

    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'textDocument/completion',
        projectName: project?.name,
        fileName: textDocument.uri,
      }),
    );

    return { items: result, isIncomplete: false };
  }

  public async handleHoverRequest(
    params: TextDocumentPositionParams,
  ): Promise<Hover | null> {
    if (!this._isInitialized) {
      return null;
    }

    this.validateDocumentAndPosition(params);

    const { textDocument, position } = params;

    const cachedDocument = this._getCachedDocument(textDocument.uri);
    if (!cachedDocument) {
      return null;
    }

    const found = cachedDocument.contents.find(content => {
      const currentRange = content.range;
      if (currentRange?.containsPosition(toPosition(position))) {
        return true;
      }
    });

    // If there is no GraphQL query in this file, return an empty result.
    if (!found) {
      return null;
    }

    const { query, range } = found;

    if (range) {
      position.line -= range.start.line;
    }
    const result = await this._languageService.getHoverInformation(
      query,
      toPosition(position),
      textDocument.uri,
      { useMarkdown: true },
    );

    return {
      contents: result,
    };
  }

  private async _parseAndCacheFile(
    uri: string,
    project: GraphQLProjectConfig,
    text?: string,
  ) {
    try {
      const fileText = text || (await readFile(URI.parse(uri).fsPath, 'utf-8'));
      const contents = await this._parser(fileText, uri);
      const cachedDocument = this._textDocumentCache.get(uri);
      const version = cachedDocument ? cachedDocument.version++ : 0;
      await this._invalidateCache({ uri, version }, uri, contents);
      await this._updateFragmentDefinition(uri, contents);
      await this._updateObjectTypeDefinition(uri, contents, project);
      await this._updateSchemaIfChanged(project, uri);
      return { contents, version };
    } catch {
      return { contents: [], version: 0 };
    }
  }

  public async handleWatchedFilesChangedNotification(
    params: DidChangeWatchedFilesParams,
  ): Promise<Array<PublishDiagnosticsParams | undefined> | null> {
    const resultsForChanges = Promise.all(
      params.changes.map(async (change: FileEvent) => {
        const shouldSkip = await this._loadConfigOrSkip(change.uri);
        if (shouldSkip) {
          return { uri: change.uri, diagnostics: [] };
        }
        if (
          change.type === FileChangeTypeKind.Created ||
          change.type === FileChangeTypeKind.Changed
        ) {
          const { uri } = change;

          try {
            let diagnostics: Diagnostic[] = [];
            const project = this._graphQLCache.getProjectForFile(uri);
            if (project) {
              // Important! Use system file uri not file path here!!!!
              const { contents } = await this._parseAndCacheFile(uri, project);
              if (
                project?.extensions?.languageService?.enableValidation !== false
              ) {
                diagnostics = (
                  await Promise.all(
                    contents.map(async ({ query, range }) => {
                      const results =
                        await this._languageService.getDiagnostics(
                          query,
                          uri,
                          this._isRelayCompatMode(query),
                        );
                      if (results && results.length > 0) {
                        return processDiagnosticsMessage(results, query, range);
                      }
                      return [];
                    }),
                  )
                ).reduce((left, right) => left.concat(right), diagnostics);
              }

              return { uri, diagnostics };
            }
            // skip diagnostics errors usually from incomplete files
          } catch {}
          return { uri, diagnostics: [] };
        }
        if (change.type === FileChangeTypeKind.Deleted) {
          await this._updateFragmentDefinition(change.uri, []);
          await this._updateObjectTypeDefinition(change.uri, []);
        }
      }),
    );
    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'workspace/didChangeWatchedFiles',
        files: params.changes.map(change => change.uri),
      }),
    );
    return resultsForChanges;
  }

  public async handleDefinitionRequest(
    params: TextDocumentPositionParams,
    _token?: CancellationToken,
  ): Promise<Array<Location>> {
    if (!this._isInitialized) {
      return [];
    }

    if (!params?.textDocument || !params.position) {
      throw new Error('`textDocument` and `position` arguments are required.');
    }
    const { textDocument, position } = params;
    const project = this._graphQLCache.getProjectForFile(textDocument.uri);
    const cachedDocument = this._getCachedDocument(textDocument.uri);
    if (!cachedDocument || !project) {
      return [];
    }

    const found = cachedDocument.contents.find(content => {
      const currentRange = content.range;
      if (currentRange?.containsPosition(toPosition(position))) {
        return true;
      }
    });

    // If there is no GraphQL query in this file, return an empty result.
    if (!found) {
      return [];
    }

    const { query, range: parentRange } = found;
    if (parentRange) {
      position.line -= parentRange.start.line;
    }

    let result: DefinitionQueryResponse | null = null;

    try {
      result = await this._languageService.getDefinition(
        query,
        toPosition(position),
        textDocument.uri,
      );
    } catch {
      // these thrown errors end up getting fired before the service is initialized, so lets cool down on that
    }

    const inlineFragments: string[] = [];
    try {
      visit(parse(query), {
        FragmentDefinition(node: FragmentDefinitionNode) {
          inlineFragments.push(node.name.value);
        },
      });
    } catch {}

    const locateCommand = project?.extensions?.languageService
      ?.locateCommand as LocateCommand | undefined;

    const formatted = result
      ? result.definitions.map(res => {
          const defRange = res.range as Range;
          if (parentRange && res.name) {
            const isInline = inlineFragments.includes(res.name);
            const isEmbedded = DEFAULT_SUPPORTED_EXTENSIONS.includes(
              path.extname(res.path) as SupportedExtensionsEnum,
            );

            if (isEmbedded || isInline) {
              const cachedDoc = this._getCachedDocument(
                URI.parse(res.path).toString(),
              );
              const vOffset = isEmbedded
                ? (cachedDoc?.contents[0].range?.start.line ?? 0)
                : parentRange.start.line;

              defRange.setStart(
                (defRange.start.line += vOffset),
                defRange.start.character,
              );
              defRange.setEnd(
                (defRange.end.line += vOffset),
                defRange.end.character,
              );
            }
          }

          if (locateCommand && result && result?.printedName) {
            const locateResult = this._getCustomLocateResult(
              project,
              result,
              locateCommand,
            );

            if (locateResult) {
              return locateResult;
            }
          }
          return {
            uri: res.path,
            range: defRange,
          };
        })
      : [];

    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'textDocument/definition',
        projectName: project?.name,
        fileName: textDocument.uri,
      }),
    );
    return formatted;
  }
  _getCustomLocateResult(
    project: GraphQLProjectConfig,
    result: DefinitionQueryResponse,
    locateCommand: LocateCommand,
  ) {
    if (!result.printedName) {
      return null;
    }
    try {
      const locateResult = locateCommand(project.name, result.printedName, {
        node: result.node,
        type: result.type,
        project,
      });
      if (typeof locateResult === 'string') {
        const [uri, line = '1', character = '1'] = locateResult.split(':');
        const startLine = Math.max(parseInt(line, 10) - 1, 0);
        const startCharacter = Math.max(parseInt(character, 10) - 1, 0);
        return {
          uri,
          range: new Range(
            new Position(startLine, startCharacter),
            new Position(startLine, startCharacter),
          ),
        };
      }
      return locateResult;
    } catch (error) {
      this._logger.error(
        'There was an error executing user defined locateCommand\n\n' +
          (error as Error).toString(),
      );
      return null;
    }
  }

  public async handleDocumentSymbolRequest(
    params: DocumentSymbolParams,
  ): Promise<Array<SymbolInformation>> {
    if (!this._isInitialized) {
      return [];
    }

    if (!params?.textDocument) {
      throw new Error('`textDocument` argument is required.');
    }

    const { textDocument } = params;
    const cachedDocument = this._getCachedDocument(textDocument.uri);
    if (!cachedDocument?.contents[0]) {
      return [];
    }

    if (
      this._settings.largeFileThreshold !== undefined &&
      this._settings.largeFileThreshold <
        cachedDocument.contents[0].query.length
    ) {
      return [];
    }

    this._logger.log(
      JSON.stringify({
        type: 'usage',
        messageType: 'textDocument/documentSymbol',
        fileName: textDocument.uri,
      }),
    );

    return this._languageService.getDocumentSymbols(
      cachedDocument.contents[0].query,
      textDocument.uri,
    );
  }

  // async handleReferencesRequest(params: ReferenceParams): Promise<Location[]> {
  //    if (!this._isInitialized) {
  //      return [];
  //    }

  //    if (!params?.textDocument) {
  //      throw new Error('`textDocument` argument is required.');
  //    }

  //    const textDocument = params.textDocument;
  //    const cachedDocument = this._getCachedDocument(textDocument.uri);
  //    if (!cachedDocument) {
  //      throw new Error('A cached document cannot be found.');
  //    }
  //    return this._languageService.getReferences(
  //      cachedDocument.contents[0].query,
  //      params.position,
  //      textDocument.uri,
  //    );
  // }

  public async handleWorkspaceSymbolRequest(
    params: WorkspaceSymbolParams,
  ): Promise<Array<SymbolInformation>> {
    if (!this._isInitialized) {
      return [];
    }

    if (params.query !== '') {
      const documents = this._getTextDocuments();
      const symbols: SymbolInformation[] = [];
      await Promise.all(
        documents.map(async ([uri]) => {
          const cachedDocument = this._getCachedDocument(uri);

          if (!cachedDocument) {
            return [];
          }
          const docSymbols = await this._languageService.getDocumentSymbols(
            cachedDocument.contents[0].query,
            uri,
          );
          symbols.push(...docSymbols);
        }),
      );
      return symbols.filter(symbol => symbol?.name?.includes(params.query));
    }

    return [];
  }

  private _getTextDocuments() {
    return Array.from(this._textDocumentCache);
  }

  private async _cacheSchemaText(
    uri: string,
    text: string,
    version: number,
    project?: GraphQLProjectConfig,
  ) {
    try {
      const contents = await this._parser(text, uri);
      if (contents.length > 0) {
        await this._invalidateCache({ version, uri }, uri, contents);
        await this._updateObjectTypeDefinition(uri, contents, project);
      }
    } catch (err) {
      this._logger.error(String(err));
    }
  }
  private async _cacheSchemaFile(
    fileUri: UnnormalizedTypeDefPointer,
    project: GraphQLProjectConfig,
  ) {
    try {
      // const parsedUri = URI.file(fileUri.toString());
      // @ts-expect-error
      const matches = await glob(fileUri, {
        cwd: project.dirpath,
        absolute: true,
      });
      const uri = matches[0];
      let version = 1;
      if (uri) {
        const schemaUri = URI.file(uri).toString();
        const schemaDocument = this._getCachedDocument(schemaUri);

        if (schemaDocument) {
          version = schemaDocument.version++;
        }
        const schemaText = await readFile(uri, 'utf-8');
        await this._cacheSchemaText(schemaUri, schemaText, version);
      }
    } catch (err) {
      this._logger.error(String(err));
    }
  }
  private _getTmpProjectPath(
    project: GraphQLProjectConfig,
    prependWithProtocol = true,
    appendPath?: string,
  ) {
    const baseDir = this._graphQLCache.getGraphQLConfig().dirpath;
    const workspaceName = path.basename(baseDir);
    const basePath = path.join(this._tmpDirBase, workspaceName);
    let projectTmpPath = path.join(basePath, 'projects', project.name);
    if (!existsSync(projectTmpPath)) {
      mkdirSync(projectTmpPath, {
        recursive: true,
      });
    }
    if (appendPath) {
      projectTmpPath = path.join(projectTmpPath, appendPath);
    }
    if (prependWithProtocol) {
      return URI.file(path.resolve(projectTmpPath)).toString();
    }
    return path.resolve(projectTmpPath);
  }

  private getCachedSchemaSettings(project: GraphQLProjectConfig) {
    const config = project?.extensions?.languageService;
    let cacheSchemaFileForLookup = true;
    let schemaCacheTTL = 1000 * 30;

    if (
      config?.cacheSchemaFileForLookup === false ||
      this?._settings?.cacheSchemaFileForLookup === false
    ) {
      cacheSchemaFileForLookup = false;
    }
    // nullish coalescing allows 0 to be a valid value here
    if (config?.schemaCacheTTL) {
      schemaCacheTTL = config.schemaCacheTTL;
    }
    if (this?._settings?.schemaCacheTTL) {
      schemaCacheTTL = this._settings.schemaCacheTTL;
    }
    return { cacheSchemaFileForLookup, schemaCacheTTL };
  }

  private async _cacheSchemaFilesForProject(project: GraphQLProjectConfig) {
    // const config = project?.extensions?.languageService;
    /**
     * By default, we look for schema definitions in SDL files
     *
     * with the opt-in feature `cacheSchemaOutputFileForLookup` enabled,
     * the resultant `graphql-config` .getSchema() schema output will be cached
     * locally and available as a single file for definition lookup and peek
     *
     * this is helpful when your `graphql-config` `schema` input is:
     * - a remote or local URL
     * - compiled from graphql files and code sources
     * - otherwise where you don't have schema SDL in the codebase or don't want to use it for lookup
     *
     * it is disabled by default
     */
    const { cacheSchemaFileForLookup } = this.getCachedSchemaSettings(project);
    const unwrappedSchema = unwrapProjectSchema(project);

    // only local schema lookups if all of the schema entries are local files
    const sdlOnly = isProjectSDLOnly(unwrappedSchema);

    // const uri = this._getTmpProjectPath(
    //   project,
    //   true,
    //   'generated-schema.graphql',
    // );
    // const fsPath = this._getTmpProjectPath(
    //   project,
    //   false,
    //   'generated-schema.graphql',
    // );
    // invalidate the cache for the generated schema file
    // whether or not the user will continue using this feature
    // because sdlOnly needs this file to be removed as well if the user is switching schemas
    // this._textDocumentCache.delete(uri);
    // skip exceptions if the file doesn't exist
    try {
      // await rm(fsPath, { force: true });
    } catch {}
    // if we are caching the config schema, and it isn't a .graphql file, cache it
    if (cacheSchemaFileForLookup && !sdlOnly) {
      await this.cacheConfigSchemaFile(project);
    } else if (sdlOnly) {
      await Promise.all(
        unwrappedSchema.map(async schemaEntry =>
          this._cacheSchemaFile(schemaEntry, project),
        ),
      );
    }
  }
  /**
   * Cache the schema as represented by graphql-config, with extensions
   * from GraphQLCache.getSchema()
   * @param project {GraphQLProjectConfig}
   */
  private async cacheConfigSchemaFile(project: GraphQLProjectConfig) {
    try {
      const schema = await this._graphQLCache.getSchema(project.name);
      if (schema) {
        let schemaText = printSchema(schema);
        // file:// protocol path
        const uri = this._getTmpProjectPath(
          project,
          true,
          'generated-schema.graphql',
        );

        // no file:// protocol for fs.writeFileSync()
        const fsPath = this._getTmpProjectPath(
          project,
          false,
          'generated-schema.graphql',
        );
        schemaText = `# This is an automatically generated representation of your schema.\n# Any changes to this file will be overwritten and will not be\n# reflected in the resulting GraphQL schema\n\n${schemaText}`;

        const cachedSchemaDoc = this._getCachedDocument(uri);
        this._graphQLCache._schemaMap.set(project.name, schema);
        try {
          await mkdir(path.dirname(fsPath), { recursive: true });
        } catch {}

        if (!cachedSchemaDoc) {
          await writeFile(fsPath, schemaText, {
            encoding: 'utf-8',
          });
          await this._cacheSchemaText(uri, schemaText, 0, project);
        }
        // do we have a change in the getSchema result? if so, update schema cache
        if (cachedSchemaDoc) {
          await writeFile(fsPath, schemaText, 'utf-8');
          await this._cacheSchemaText(
            uri,
            schemaText,
            cachedSchemaDoc.version++,
            project,
          );
        }
      }
    } catch (err) {
      this._logger.error(String(err));
    }
  }
  /**
   * Pre-cache all documents for a project.
   *
   * TODO: Maybe make this optional, where only schema needs to be pre-cached.
   *
   * @param project {GraphQLProjectConfig}
   */
  private async _cacheDocumentFilesforProject(project: GraphQLProjectConfig) {
    try {
      const documents = await project.getDocuments();
      const documentLocations = new Set(
        documents
          .filter(doc => doc.location && doc.rawSDL)
          .map(doc => doc.location!),
      );

      return Promise.all(
        Array.from(documentLocations).map(async loc => {
          let filePath = loc;
          if (!path.isAbsolute(filePath)) {
            filePath = path.join(project.dirpath, loc);
          }

          // build full system URI path with protocol
          const uri = URI.file(filePath).toString();

          const fileContent = await readFile(filePath, 'utf-8');
          // I would use the already existing graphql-config AST, but there are a few reasons we can't yet
          const contents = await this._parser(fileContent, uri);
          if (!contents[0]?.query) {
            return;
          }

          await this._updateObjectTypeDefinition(uri, contents);
          await this._updateFragmentDefinition(uri, contents);
          await this._invalidateCache({ version: 1, uri }, uri, contents);
        }),
      );
    } catch (err) {
      this._logger.error(
        `invalid/unknown file in graphql config documents entry:\n '${project.documents}'`,
      );
      this._logger.error(String(err));
    }
  }
  /**
   * This should only be run on initialize() really.
   * Caching all the document files upfront could be expensive.
   * @param config {GraphQLConfig}
   */
  private async _cacheAllProjectFiles(config: GraphQLConfig) {
    if (config?.projects) {
      return Promise.all(
        Object.keys(config.projects).map(async projectName => {
          const project = config.getProject(projectName);

          await this._cacheSchemaFilesForProject(project);
          if (project.documents?.length) {
            await this._cacheDocumentFilesforProject(project);
          } else {
            this._logger.warn(
              [
                `No 'documents' config found for project: ${projectName}.`,
                'Fragments and query documents cannot be detected.',
                'LSP server will only perform some partial validation and SDL features.',
              ].join('\n'),
            );
          }
        }),
      );
    }
  }
  _isRelayCompatMode(query: string): boolean {
    return (
      query.includes('RelayCompat') || query.includes('react-relay/compat')
    );
  }

  private async _updateFragmentDefinition(
    uri: Uri,
    contents: CachedContent[],
  ): Promise<void> {
    const project = this._graphQLCache.getProjectForFile(uri);
    if (project) {
      const cacheKey = this._graphQLCache._cacheKeyForProject(project);
      await this._graphQLCache.updateFragmentDefinition(
        cacheKey,
        uri,
        contents,
      );
    }
  }

  private async _updateSchemaIfChanged(
    project: GraphQLProjectConfig,
    uri: Uri,
  ): Promise<void> {
    await Promise.all(
      unwrapProjectSchema(project).map(async schema => {
        const schemaFilePath = path.resolve(project.dirpath, schema);
        const uriFilePath = URI.parse(uri).fsPath;

        if (uriFilePath === schemaFilePath) {
          try {
            const file = await readFile(schemaFilePath, 'utf-8');
            // only invalidate the schema cache if we can actually parse the file
            // otherwise, leave the last valid one in place
            parse(file, { noLocation: true });
            this._graphQLCache.invalidateSchemaCacheForProject(project);
          } catch {}
        }
      }),
    );
  }

  private async _updateObjectTypeDefinition(
    uri: Uri,
    contents: CachedContent[],
    project?: GraphQLProjectConfig,
  ): Promise<void> {
    const resolvedProject =
      project ?? (await this._graphQLCache.getProjectForFile(uri));
    if (resolvedProject) {
      const cacheKey = this._graphQLCache._cacheKeyForProject(resolvedProject);
      await this._graphQLCache.updateObjectTypeDefinition(
        cacheKey,
        uri,
        contents,
      );
    }
  }

  private _getCachedDocument(uri: string): CachedDocumentType | null {
    if (this._textDocumentCache.has(uri)) {
      const cachedDocument = this._textDocumentCache.get(uri);
      if (cachedDocument) {
        return cachedDocument;
      }
    }

    return null;
  }
  private async _invalidateCache(
    textDocument: VersionedTextDocumentIdentifier,
    uri: Uri,
    contents: CachedContent[],
  ): Promise<Map<string, CachedDocumentType> | null> {
    if (this._textDocumentCache.has(uri)) {
      const cachedDocument = this._textDocumentCache.get(uri);
      if (
        cachedDocument &&
        textDocument?.version &&
        cachedDocument.version < textDocument.version
      ) {
        // Current server capabilities specify the full sync of the contents.
        // Therefore always overwrite the entire content.
        return this._textDocumentCache.set(uri, {
          version: textDocument.version,
          contents,
        });
      }
    }
    return this._textDocumentCache.set(uri, {
      version: textDocument.version ?? 0,
      contents,
    });
  }
}

export function processDiagnosticsMessage(
  results: Diagnostic[],
  query: string,
  range: RangeType | null,
): Diagnostic[] {
  const queryLines = query.split('\n');
  const totalLines = queryLines.length;
  const lastLineLength = queryLines[totalLines - 1].length;
  const lastCharacterPosition = new Position(totalLines, lastLineLength);
  const processedResults = results.filter(diagnostic =>
    // @ts-ignore
    diagnostic.range.end.lessThanOrEqualTo(lastCharacterPosition),
  );

  if (range) {
    const offset = range.start;
    return processedResults.map(diagnostic => ({
      ...diagnostic,
      range: new Range(
        new Position(
          diagnostic.range.start.line + offset.line,
          diagnostic.range.start.character,
        ),
        new Position(
          diagnostic.range.end.line + offset.line,
          diagnostic.range.end.character,
        ),
      ),
    }));
  }

  return processedResults;
}
