import { Token } from 'antlr4';
import type { CandidateRule, CandidatesCollection } from 'antlr4-c3';
import { CodeCompletionCore } from 'antlr4-c3';
import {
  CompletionItem,
  CompletionItemKind,
  InsertTextFormat,
} from 'vscode-languageserver-types';
import { DbSchema } from '../dbSchema';
import CypherLexer from '../generated-parser/CypherCmdLexer';
import CypherParser, {
  Expression2Context,
} from '../generated-parser/CypherCmdParser';
import { findPreviousNonSpace, rulesDefiningVariables } from '../helpers';
import {
  CypherTokenType,
  lexerKeywords,
  lexerSymbols,
  tokenNames,
} from '../lexerSymbols';

import { ParsedStatement } from '../parserWrapper';

import { consoleCommandEnabled } from '../parserWrapper';

const uniq = <T>(arr: T[]) => Array.from(new Set(arr));

const labelCompletions = (dbSchema: DbSchema) =>
  dbSchema.labels?.map((labelName) => ({
    label: labelName,
    kind: CompletionItemKind.TypeParameter,
  })) ?? [];

const reltypeCompletions = (dbSchema: DbSchema) =>
  dbSchema.relationshipTypes?.map((relType) => ({
    label: relType,
    kind: CompletionItemKind.TypeParameter,
  })) ?? [];

const functionNameCompletions = (
  candidateRule: CandidateRule,
  tokens: Token[],
  dbSchema: DbSchema,
) =>
  namespacedCompletion(
    candidateRule,
    tokens,
    Object.keys(dbSchema?.functions ?? {}),
    'function',
  );

const procedureNameCompletions = (
  candidateRule: CandidateRule,
  tokens: Token[],
  dbSchema: DbSchema,
) =>
  namespacedCompletion(
    candidateRule,
    tokens,
    Object.keys(dbSchema?.procedures ?? {}),
    'procedure',
  );

const namespacedCompletion = (
  candidateRule: CandidateRule,
  tokens: Token[],
  fullNames: string[],
  type: 'procedure' | 'function',
) => {
  const namespacePrefix = calculateNamespacePrefix(candidateRule, tokens);
  if (namespacePrefix === null) {
    return [];
  }

  const kind =
    type === 'procedure'
      ? CompletionItemKind.Method
      : CompletionItemKind.Function;
  const detail = type === 'procedure' ? '(procedure)' : '(function)';

  if (namespacePrefix === '') {
    // If we don't have any prefix show full functions and top level namespaces
    const topLevelPrefixes = fullNames
      .filter((fn) => fn.includes('.'))
      .map((fnName) => fnName.split('.')[0]);

    return uniq(topLevelPrefixes)
      .map((label) => ({ label, kind, detail: `(namespace)` }))
      .concat(fullNames.map((label) => ({ label, kind, detail })));
  } else {
    // if we have a namespace prefix, complete on the namespace level:
    // apoc. => show `util` | `load` | `date` etc.

    const funcOptions = new Set<string>();
    const namespaceOptions = new Set<string>();

    for (const name of fullNames) {
      if (name.startsWith(namespacePrefix)) {
        // given prefix `apoc.` turn `apoc.util.sleep` => `util`
        const splitByDot = name.slice(namespacePrefix.length).split('.');
        const option = splitByDot[0];
        const isFunctionName = splitByDot.length === 1;

        // handle prefix `time.truncate.` turning `time.truncate` => ``
        if (option !== '') {
          if (isFunctionName) {
            funcOptions.add(option);
          } else {
            namespaceOptions.add(option);
          }
        }
      }
    }

    // test handle namespace with same name as function
    const functionNameCompletions = Array.from(funcOptions).map((label) => ({
      label,
      kind,
      detail,
    }));

    const namespaceCompletions = Array.from(namespaceOptions).map((label) => ({
      label,
      kind,
      detail: '(namespace)',
    }));

    return functionNameCompletions.concat(namespaceCompletions);
  }
};

function getTokenCompletions(
  candidates: CandidatesCollection,
  ignoredTokens: Set<number>,
): CompletionItem[] {
  const tokenEntries = candidates.tokens.entries();

  const completions = Array.from(tokenEntries).flatMap((value) => {
    const [tokenNumber, followUpList] = value;

    if (!ignoredTokens.has(tokenNumber)) {
      const isConsoleCommand =
        lexerSymbols[tokenNumber] === CypherTokenType.consoleCommand;

      const kind = isConsoleCommand
        ? CompletionItemKind.Event
        : CompletionItemKind.Keyword;

      const firstToken = isConsoleCommand
        ? tokenNames[tokenNumber].toLowerCase()
        : tokenNames[tokenNumber];

      const followUpIndexes = followUpList.indexes;
      const firstIgnoredToken = followUpIndexes.findIndex((t) =>
        ignoredTokens.has(t),
      );

      const followUpTokens = followUpIndexes.slice(
        0,
        firstIgnoredToken >= 0 ? firstIgnoredToken : followUpIndexes.length,
      );

      const followUpString = followUpTokens.map((i) => tokenNames[i]).join(' ');

      if (firstToken === undefined) {
        return [];
      } else if (followUpString === '') {
        return [{ label: firstToken, kind }];
      } else {
        const followUp =
          firstToken +
          ' ' +
          (isConsoleCommand ? followUpString.toLowerCase() : followUpString);

        if (followUpList.optional) {
          return [
            { label: firstToken, kind },
            { label: followUp, kind },
          ];
        }

        return [{ label: followUp, kind }];
      }
    } else {
      return [];
    }
  });

  return completions;
}

const parameterCompletions = (
  dbInfo: DbSchema,
  expectedType: ExpectedParameterType,
): CompletionItem[] =>
  Object.entries(dbInfo.parameters ?? {})
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    .filter(([_, paramType]) =>
      isExpectedParameterType(expectedType, paramType),
    )
    .map(([paramName]) => ({
      label: `$${paramName}`,
      kind: CompletionItemKind.Variable,
    }));
const propertyKeyCompletions = (dbInfo: DbSchema): CompletionItem[] =>
  dbInfo.propertyKeys?.map((propertyKey) => ({
    label: propertyKey,
    kind: CompletionItemKind.Property,
  })) ?? [];

enum ExpectedParameterType {
  String = 'STRING',
  Map = 'MAP',
  Any = 'ANY',
}

const inferExpectedParameterTypeFromContext = (context: CandidateRule) => {
  const parentRule = context.ruleList.at(-1);

  if (
    [
      CypherParser.RULE_stringOrParameter,
      CypherParser.RULE_commandNameExpression,
      CypherParser.RULE_symbolicNameOrStringParameter,
      CypherParser.RULE_symbolicNameOrStringParameterList,
      CypherParser.RULE_symbolicAliasNameOrParameter,
      CypherParser.RULE_passwordExpression,
      CypherParser.RULE_createUser,
      CypherParser.RULE_dropUser,
      CypherParser.RULE_alterUser,
      CypherParser.RULE_renameUser,
      CypherParser.RULE_createRole,
      CypherParser.RULE_dropRole,
      CypherParser.RULE_userNames,
      CypherParser.RULE_roleNames,
      CypherParser.RULE_renameRole,
    ].includes(parentRule)
  ) {
    return ExpectedParameterType.String;
  } else if (
    [CypherParser.RULE_properties, CypherParser.RULE_mapOrParameter].includes(
      parentRule,
    )
  ) {
    return ExpectedParameterType.Map;
  } else {
    return ExpectedParameterType.Any;
  }
};

const isExpectedParameterType = (
  expectedType: ExpectedParameterType,
  value: unknown,
) => {
  const typeName = typeof value;
  switch (expectedType) {
    case ExpectedParameterType.String:
      return typeName === 'string';
    case ExpectedParameterType.Map:
      return typeName === 'object';
    case ExpectedParameterType.Any:
      return true;
  }
};

function calculateNamespacePrefix(
  candidateRule: CandidateRule,
  tokens: Token[],
): string | null {
  const ruleTokens = tokens.slice(candidateRule.startTokenIndex);
  const lastNonEOFToken = ruleTokens.at(-2);

  const nonSpaceTokens = ruleTokens.filter(
    (token) =>
      token.type !== CypherLexer.SPACE && token.type !== CypherLexer.EOF,
  );

  const lastNonSpaceIsDot = nonSpaceTokens.at(-1)?.type === CypherLexer.DOT;

  // `gds version` is invalid but `gds .version` and `gds. version` are valid
  // so if the last token is a space and the last non-space token
  // is anything but a dot return empty completions to avoid
  // creating invalid suggestions (db ping)
  if (lastNonEOFToken?.type === CypherLexer.SPACE && !lastNonSpaceIsDot) {
    return null;
  }

  // calculate the current namespace prefix
  // only keep finished namespaces both second level `gds.ver` => `gds.`
  // and first level make `gds` => ''
  if (!lastNonSpaceIsDot) {
    nonSpaceTokens.pop();
  }

  const namespacePrefix = nonSpaceTokens.map((token) => token.text).join('');
  return namespacePrefix;
}

export function completionCoreCompletion(
  parsingResult: ParsedStatement,
  dbSchema: DbSchema,
  manualTrigger = false,
): CompletionItem[] {
  const parser = parsingResult.parser;
  const tokens = parsingResult.tokens;

  const codeCompletion = new CodeCompletionCore(parser);

  // Move the caret index to the end of the query
  let caretIndex = tokens.length > 0 ? tokens.length - 1 : 0;

  const eof = tokens[caretIndex];

  // When we have EOF with a different text in the token, it means the parser has failed to parse it.
  // We give empty completions in that case because the query is severely broken at the
  // point of completion (e.g. an unclosed string)
  if (eof.text !== '<EOF>') {
    return [];
  }

  // If the previous token is an identifier, we don't count it as "finished" so we move the caret back one token
  // The identifier is finished when the last token is a SPACE or dot etc. etc.
  // this allows us to give completions that replace the current text => for example `RET` <- it's parsed as an identifier
  // The need for this caret movement is outlined in the documentation of antlr4-c3 in the section about caret position
  // When an identifier overlaps with a keyword, it's no longer treats as an identifier (although it's a valid identifier)
  // So we need to move the caret back for keywords as well
  const previousToken = tokens[caretIndex - 1]?.type;
  if (
    previousToken === CypherLexer.IDENTIFIER ||
    lexerKeywords.includes(previousToken)
  ) {
    caretIndex--;
  }

  codeCompletion.preferredRules = new Set<number>([
    CypherParser.RULE_functionName,
    CypherParser.RULE_procedureName,
    CypherParser.RULE_labelExpression1,
    CypherParser.RULE_symbolicAliasName,
    CypherParser.RULE_parameter,
    CypherParser.RULE_propertyKeyName,
    CypherParser.RULE_variable,
    CypherParser.RULE_leftArrow,
    // this rule is used for usernames and roles.
    CypherParser.RULE_commandNameExpression,

    // Either enable the helper rules for lexer clashes,
    // or collect all console commands like below with symbolicNameString
    ...(consoleCommandEnabled()
      ? [
          CypherParser.RULE_useCompletionRule,
          CypherParser.RULE_listCompletionRule,
        ]
      : [CypherParser.RULE_consoleCommand]),

    // Because of the overlap of keywords and identifiers in cypher
    // We will suggest keywords when users type identifiers as well
    // To avoid this we want custom completion for identifiers
    // Until we've covered all the ways we can reach symbolic name string we'll keep this here
    // Ideally we'd find another way to get around this issue
    CypherParser.RULE_symbolicNameString,
  ]);

  // Keep only keywords as suggestions
  const ignoredTokens = new Set<number>(
    Object.entries(lexerSymbols)
      .filter(
        ([, type]) =>
          type !== CypherTokenType.keyword &&
          type !== CypherTokenType.consoleCommand,
      )
      .map(([token]) => Number(token)),
  );

  const candidates = codeCompletion.collectCandidates(caretIndex);

  const ruleCompletions = Array.from(candidates.rules.entries()).flatMap(
    (candidate): CompletionItem[] => {
      const [ruleNumber, candidateRule] = candidate;
      if (ruleNumber === CypherParser.RULE_functionName) {
        return functionNameCompletions(candidateRule, tokens, dbSchema);
      }

      if (ruleNumber === CypherParser.RULE_procedureName) {
        return procedureNameCompletions(candidateRule, tokens, dbSchema);
      }

      if (ruleNumber === CypherParser.RULE_parameter) {
        return parameterCompletions(
          dbSchema,
          inferExpectedParameterTypeFromContext(candidateRule),
        );
      }

      if (ruleNumber === CypherParser.RULE_propertyKeyName) {
        // Map literal keys are also parsed as "propertyKey"s even though
        // they are not considered propertyKeys by the database
        // We check if the parent mapLiteral is used as a literal
        // to avoid suggesting property keys when defining a map literal
        const parentRule = candidateRule.ruleList.at(-1);
        const grandParentRule = candidateRule.ruleList.at(-2);
        if (
          parentRule === CypherParser.RULE_map &&
          grandParentRule === CypherParser.RULE_literal
        ) {
          return [];
        }

        const greatGrandParentRule = candidateRule.ruleList.at(-3);
        // When propertyKey is used as postfix to an expr there are many false positives
        // because expression are very flexible. For this case we only suggest property
        // keys if the expr is a simple variable that is defined.
        // We still don't know the type of the variable we're completing without a symbol table
        // but it is likely to be a node/relationship
        if (
          parentRule === CypherParser.RULE_property &&
          grandParentRule == CypherParser.RULE_postFix &&
          greatGrandParentRule === CypherParser.RULE_expression2
        ) {
          const expr2 = parsingResult.stopNode?.parentCtx?.parentCtx?.parentCtx;
          if (expr2 instanceof Expression2Context) {
            const variableName = expr2.expression1().variable()?.getText();
            if (
              !variableName ||
              !parsingResult.collectedVariables.includes(variableName)
            ) {
              return [];
            }
          }
        }

        return propertyKeyCompletions(dbSchema);
      }

      if (ruleNumber === CypherParser.RULE_variable) {
        const parentRule = candidateRule.ruleList.at(-1);
        // some rules only define, never use variables
        if (
          typeof parentRule === 'number' &&
          rulesDefiningVariables.includes(parentRule)
        ) {
          return [];
        }

        return parsingResult.collectedVariables.map((variableNames) => ({
          label: variableNames,
          kind: CompletionItemKind.Variable,
        }));
      }

      if (ruleNumber === CypherParser.RULE_symbolicAliasName) {
        return completeAliasName({ candidateRule, dbSchema, parsingResult });
      }

      if (ruleNumber === CypherParser.RULE_commandNameExpression) {
        return completeSymbolicName({ candidateRule, dbSchema, parsingResult });
      }

      if (ruleNumber === CypherParser.RULE_labelExpression1) {
        const topExprIndex = candidateRule.ruleList.indexOf(
          CypherParser.RULE_labelExpression,
        );

        const topExprParent = candidateRule.ruleList[topExprIndex - 1];

        if (topExprParent === undefined) {
          return [];
        }

        if (topExprParent === CypherParser.RULE_nodePattern) {
          return labelCompletions(dbSchema);
        }

        if (topExprParent === CypherParser.RULE_relationshipPattern) {
          return reltypeCompletions(dbSchema);
        }

        return [...labelCompletions(dbSchema), ...reltypeCompletions(dbSchema)];
      }

      // These are simple tokens that get completed as the wrong kind, due to a lexer conflict
      if (ruleNumber === CypherParser.RULE_useCompletionRule) {
        return [{ label: 'use', kind: CompletionItemKind.Event }];
      }

      if (ruleNumber === CypherParser.RULE_listCompletionRule) {
        return [{ label: 'list', kind: CompletionItemKind.Event }];
      }

      if (ruleNumber === CypherParser.RULE_leftArrow) {
        return [
          {
            label: '-[]->()',
            kind: CompletionItemKind.Snippet,
            insertTextFormat: InsertTextFormat.Snippet,
            insertText: '-[${1: }]->(${2: })',
            detail: 'path template',
            // vscode does not call the completion provider for every single character
            // after the second character is typed (i.e) `MATCH ()-[` the completion is no longer valid
            // it'd insert `MATCH ()-[[]->()` which is not valid. Hence we filter it out by using the filterText
            filterText: '',
          },
          {
            label: '-[]-()',
            kind: CompletionItemKind.Snippet,
            insertTextFormat: InsertTextFormat.Snippet,
            insertText: '-[${1: }]-(${2: })',
            detail: 'path template',
            filterText: '',
          },
          {
            label: '<-[]-()',
            kind: CompletionItemKind.Snippet,
            insertTextFormat: InsertTextFormat.Snippet,
            insertText: '<-[${1: }]-(${2: })',
            detail: 'path template',
            filterText: '',
          },
        ];
      }

      return [];
    },
  );

  // if the completion was automatically triggered by a snippet trigger character
  // we should only return snippet completions
  if (CypherLexer.RPAREN === previousToken && !manualTrigger) {
    return ruleCompletions.filter(
      (completion) => completion.kind === CompletionItemKind.Snippet,
    );
  }

  return [
    ...ruleCompletions,
    ...getTokenCompletions(candidates, ignoredTokens),
  ];
}

type CompletionHelperArgs = {
  parsingResult: ParsedStatement;
  dbSchema: DbSchema;
  candidateRule: CandidateRule;
};
function completeAliasName({
  candidateRule,
  dbSchema,
  parsingResult,
}: CompletionHelperArgs): CompletionItem[] {
  // The rule for RULE_symbolicAliasName technically allows for spaces given that a dot is included in the name
  // so ALTER ALIAS a . b  FOR DATABASE neo4j is accepted by neo4j. It does however only drop the spaces for the alias
  // it becomes just a.b

  // The issue for us is that when we complete "ALTER ALIAS a " <- according to the grammar points say we could still be building a name
  // To handle this we check if the token after the first identifier in the rule is a space (as opposed to a dot)
  // if so we have a false positive and we return null to ignore the rule
  // symbolicAliasName: (symbolicNameString (DOT symbolicNameString)* | parameter);
  if (
    parsingResult.tokens[candidateRule.startTokenIndex + 1]?.type ===
    CypherLexer.SPACE
  ) {
    return [];
  }

  // parameters are valid values in all cases of symbolicAliasName
  const baseSuggestions = parameterCompletions(
    dbSchema,
    ExpectedParameterType.String,
  );
  const rulesCreatingNewAliasOrDb = [
    CypherParser.RULE_createAlias,
    CypherParser.RULE_createDatabase,
    CypherParser.RULE_createCompositeDatabase,
  ];
  // avoid suggesting existing database names when creating a new alias or database
  if (
    rulesCreatingNewAliasOrDb.some((rule) =>
      candidateRule.ruleList.includes(rule),
    )
  ) {
    return baseSuggestions;
  }

  const rulesThatOnlyAcceptAlias = [
    CypherParser.RULE_dropAlias,
    CypherParser.RULE_alterAlias,
    CypherParser.RULE_showAliases,
  ];
  if (
    rulesThatOnlyAcceptAlias.some((rule) =>
      candidateRule.ruleList.includes(rule),
    )
  ) {
    return [
      ...baseSuggestions,
      ...(dbSchema?.aliasNames ?? []).map((aliasName) => ({
        label: aliasName,
        kind: CompletionItemKind.Value,
      })),
    ];
  }

  // Suggest both database and alias names when it's not alias specific or creating new alias or database
  return [
    ...baseSuggestions,
    ...(dbSchema.databaseNames ?? [])
      .concat(dbSchema.aliasNames ?? [])
      .map((databaseName) => ({
        label: databaseName,
        kind: CompletionItemKind.Value,
      })),
  ];
}

function completeSymbolicName({
  candidateRule,
  dbSchema,
  parsingResult,
}: CompletionHelperArgs): CompletionItem[] {
  // parameters are valid values in all cases of symbolic name
  const baseSuggestions = parameterCompletions(
    dbSchema,
    inferExpectedParameterTypeFromContext(candidateRule),
  );

  const rulesCreatingNewUserOrRole = [
    CypherParser.RULE_createUser,
    CypherParser.RULE_createRole,
  ];

  const previousToken = findPreviousNonSpace(
    parsingResult.tokens,
    candidateRule.startTokenIndex,
  );
  const afterToToken = previousToken.type === CypherParser.TO;
  const ruleList = candidateRule.ruleList;

  // avoid suggesting existing user names or role names when creating a new one
  if (
    rulesCreatingNewUserOrRole.some((rule) => ruleList.includes(rule)) ||
    // We are suggesting an user as target for the renaming
    //      RENAME USER existing TO target
    // so target should be non-existent
    (ruleList.includes(CypherParser.RULE_renameUser) && afterToToken)
  ) {
    return baseSuggestions;
  }

  const rulesThatAcceptExistingUsers = [
    CypherParser.RULE_dropUser,
    CypherParser.RULE_renameUser,
    CypherParser.RULE_alterUser,
    CypherParser.RULE_userNames,
  ];

  if (rulesThatAcceptExistingUsers.some((rule) => ruleList.includes(rule))) {
    const result = [
      ...baseSuggestions,
      ...(dbSchema?.userNames ?? []).map((userName) => ({
        label: userName,
        kind: CompletionItemKind.Value,
      })),
    ];

    return result;
  }

  const rulesThatAcceptExistingRoles = [
    CypherParser.RULE_roleNames,
    CypherParser.RULE_dropRole,
    CypherParser.RULE_renameRole,
  ];

  if (rulesThatAcceptExistingRoles.some((rule) => ruleList.includes(rule))) {
    return [
      ...baseSuggestions,
      ...(dbSchema?.roleNames ?? []).map((roleName) => ({
        label: roleName,
        kind: CompletionItemKind.Value,
      })),
    ];
  }

  return [];
}
