/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {
  AST,
  ASTWithSource,
  BindingPipe,
  BindingType,
  EmptyExpr,
  ImplicitReceiver,
  LiteralPrimitive,
  ParsedEventType,
  ParseSourceSpan,
  PropertyRead,
  SafePropertyRead,
  TmplAstBoundAttribute,
  TmplAstBoundEvent,
  TmplAstBoundEvent as BoundEvent,
  TmplAstElement,
  TmplAstNode,
  TmplAstReference,
  TmplAstSwitchBlock as SwitchBlock,
  TmplAstTemplate,
  TmplAstText,
  TmplAstTextAttribute,
  TmplAstTextAttribute as TextAttribute,
  TmplAstVariable,
  TmplAstLetDeclaration,
  TmplAstSwitchBlock,
  ThisReceiver,
} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {
  CompletionKind,
  PotentialDirective,
  SymbolKind,
  TemplateDeclarationSymbol,
  TemplateTypeChecker,
} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';

import {
  addAttributeCompletionEntries,
  AsciiSortPriority,
  AttributeCompletionKind,
  buildAnimationCompletionEntries,
  buildAttributeCompletionTable,
  getAttributeCompletionSymbol,
} from './attribute_completions';
import {
  DisplayInfo,
  DisplayInfoKind,
  getDirectiveDisplayInfo,
  getSymbolDisplayInfo,
  getTsSymbolDisplayInfo,
  unsafeCastDisplayInfoKindToScriptElementKind,
} from './utils/display_parts';
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
import {
  findTightestNode,
  getCodeActionToImportTheDirectiveDeclaration,
  standaloneTraitOrNgModule,
  getClassDeclarationFromSymbolReference,
} from './utils/ts_utils';
import {filterAliasImports, isBoundEventWithSyntheticHandler, isWithin} from './utils';

type PropertyExpressionCompletionBuilder = CompletionBuilder<
  PropertyRead | EmptyExpr | SafePropertyRead | TmplAstBoundEvent
>;

type ElementAttributeCompletionBuilder = CompletionBuilder<
  TmplAstElement | TmplAstBoundAttribute | TmplAstTextAttribute | TmplAstBoundEvent
>;

type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;

type LiteralCompletionBuilder = CompletionBuilder<LiteralPrimitive | TextAttribute>;

type ElementAnimationCompletionBuilder = CompletionBuilder<
  TmplAstBoundAttribute | TmplAstBoundEvent
>;

type LetCompletionBuilder = CompletionBuilder<TmplAstLetDeclaration>;

export enum CompletionNodeContext {
  None,
  ElementTag,
  ElementAttributeKey,
  ElementAttributeValue,
  EventValue,
  TwoWayBinding,
}

const ANIMATION_PHASES = ['start', 'done'];

function buildBlockSnippet(insertSnippet: boolean, blockName: string, withParens: boolean): string {
  if (!insertSnippet) {
    return blockName;
  }
  if (blockName === 'for') {
    return `${blockName} (\${1:item} of \${2:items}; track \${3:\\$index}) {$4}`;
  }
  if (withParens) {
    return `${blockName} ($1) {$2}`;
  }
  return `${blockName} {$1}`;
}

/**
 * The symbol position in the file.
 */
interface DirectiveInfoForCompletionDetail {
  entryName: string;
  fileName: string;
  pos: number;

  /**
   * The kind of the attribute completion. This value should be set if the completion is for an
   * attribute completion.
   */
  attrKind: AttributeCompletionKind | null;

  /**
   * Sometimes, the location of the tsCompletionEntry symbol does not match the location of the Angular symbol.
   *
   * For example, the BarComponent is declared in `bar.ts` and exported from there. The `public_api.ts` also
   * reexports the BarComponent from `bar.ts`, so the `tsCompletionEntrySymbolFileName` will be `public_api.ts`,
   * and the `fileName` will be the `bar.ts`.
   */
  symbolFileName: string;
  /**
   * Sometime the component can be exported with a different name than the class name.
   * For example, `export {BarComponent as NewBarComponent} from './bar.component';`
   *
   * Sometimes, the component is exported by the `NgModule`.
   */
  symbolName: string;
}

/**
 * Cache the symbol info from the completion entry details. This will be replaced when invoking
 * the `ls.getCompletionsAtPosition`. Only use it in the request for `ls.getCompletionEntryDetails`.
 *
 * There is no way to clear the map. The info on the map may be invalid. So only the symbol
 * position is saved, not the symbol object.
 */
let directiveInfoForCompletionDetailCache = new Map<string, DirectiveInfoForCompletionDetail>();

/**
 * Performs autocompletion operations on a given node in the template.
 *
 * This class acts as a closure around all of the context required to perform the 3 autocompletion
 * operations (completions, get details, and get symbol) at a specific node.
 *
 * The generic `N` type for the template node is narrowed internally for certain operations, as the
 * compiler operations required to implement completion may be different for different node types.
 *
 * @param N type of the template node in question, narrowed accordingly.
 */
export class CompletionBuilder<N extends TmplAstNode | AST> {
  private readonly typeChecker: ts.TypeChecker;
  private readonly templateTypeChecker: TemplateTypeChecker;
  private readonly nodeParent: TmplAstNode | AST | null;
  private readonly nodeContext: CompletionNodeContext;
  private readonly template: TmplAstTemplate | null;
  private readonly position: number;

  constructor(
    private readonly tsLS: ts.LanguageService,
    private readonly compiler: NgCompiler,
    private readonly component: ts.ClassDeclaration,
    private readonly node: N,
    private readonly targetDetails: TemplateTarget,
  ) {
    this.typeChecker = this.compiler.getCurrentProgram().getTypeChecker();
    this.templateTypeChecker = this.compiler.getTemplateTypeChecker();
    this.nodeParent = this.targetDetails.parent;
    this.nodeContext = nodeContextFromTarget(this.targetDetails.context);
    this.template = this.targetDetails.template;
    this.position = this.targetDetails.position;
  }

  /**
   * Analogue for `ts.LanguageService.getCompletionsAtPosition`.
   */
  getCompletionsAtPosition(
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    if (this.isPropertyExpressionCompletion()) {
      return this.getPropertyExpressionCompletion(options);
    } else if (this.isElementTagCompletion()) {
      return this.getElementTagCompletion(options);
    } else if (this.isElementAttributeCompletion()) {
      if (this.isAnimationCompletion()) {
        return this.getAnimationCompletions();
      } else {
        return this.getElementAttributeCompletions(options);
      }
    } else if (this.isPipeCompletion()) {
      return this.getPipeCompletions();
    } else if (this.isLiteralCompletion()) {
      return this.getLiteralCompletions(options);
    } else if (this.isBlockCompletion()) {
      return this.getBlockCompletions(options);
    } else if (this.isLetCompletion()) {
      return this.getGlobalPropertyExpressionCompletion(options);
    } else {
      return undefined;
    }
  }

  private isLetCompletion(): this is LetCompletionBuilder {
    return this.node instanceof TmplAstLetDeclaration;
  }

  private isBlockCompletion(): this is CompletionBuilder<TmplAstText> {
    if (this.node instanceof TmplAstText) {
      return /@[a-z\s]*$/.test(this.node.value);
    }

    return this.node instanceof TmplAstSwitchBlock;
  }

  private getBlockCompletions(
    this: CompletionBuilder<TmplAstText>,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    const blocksWithParens = ['if', 'else if', 'for', 'switch', 'case', 'defer'];
    const blocksWithoutParens = ['else', 'empty', 'placeholder', 'error', 'loading', 'default'];

    // Determine whether to provide a snippet, which includes parens and curly braces.
    // If the block has any expressions or a body, don't provide a snippet as the completion.
    // TODO: We can be smarter about this, e.g. include `default` in `switch` if it is missing.
    const useSnippet = options?.includeCompletionsWithSnippetText ?? false;

    let completionKeywords: string[] = [...blocksWithParens, ...blocksWithoutParens];
    if (this.nodeParent instanceof SwitchBlock) {
      completionKeywords = ['case', 'default'];
    }
    const completionEntries: ts.CompletionEntry[] = completionKeywords.map((name) => ({
      name,
      sortText: `${AsciiSortPriority.First}${name}`,
      insertText: buildBlockSnippet(useSnippet, name, blocksWithParens.includes(name)),
      isSnippet: useSnippet || undefined,
      kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.BLOCK),
    }));

    // Return the completions.
    const completionInfo: ts.CompletionInfo = {
      flags: ts.CompletionInfoFlags.IsContinuation,
      isMemberCompletion: false,
      isGlobalCompletion: false,
      isNewIdentifierLocation: false,
      entries: completionEntries,
    };
    return completionInfo;
  }

  private isLiteralCompletion(): this is LiteralCompletionBuilder {
    return (
      this.node instanceof LiteralPrimitive ||
      (this.node instanceof TextAttribute &&
        this.nodeContext === CompletionNodeContext.ElementAttributeValue)
    );
  }

  private getLiteralCompletions(
    this: LiteralCompletionBuilder,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    const location = this.compiler
      .getTemplateTypeChecker()
      .getLiteralCompletionLocation(this.node, this.component);
    if (location === null) {
      return undefined;
    }
    const tsResults = this.tsLS.getCompletionsAtPosition(
      location.tcbPath,
      location.positionInFile,
      options,
    );
    if (tsResults === undefined) {
      return undefined;
    }

    let replacementSpan: ts.TextSpan | undefined;
    if (this.node instanceof TextAttribute && this.node.value.length > 0 && this.node.valueSpan) {
      replacementSpan = {
        start: this.node.valueSpan.start.offset,
        length: this.node.value.length,
      };
    }
    if (this.node instanceof LiteralPrimitive) {
      if (typeof this.node.value === 'string' && this.node.value.length > 0) {
        replacementSpan = {
          // The sourceSpan of `LiteralPrimitive` includes the open quote and the completion
          // entries
          // don't, so skip the open quote here.
          start: this.node.sourceSpan.start + 1,
          length: this.node.value.length,
        };
      } else if (typeof this.node.value === 'number') {
        replacementSpan = {
          start: this.node.sourceSpan.start,
          length: this.node.value.toString().length,
        };
      }
    }

    let ngResults: ts.CompletionEntry[] = [];
    for (const result of tsResults.entries) {
      if (this.isValidNodeContextCompletion(result)) {
        ngResults.push({
          ...result,
          replacementSpan,
        });
      }
    }
    return {
      ...tsResults,
      entries: ngResults,
    };
  }

  /**
   * Analogue for `ts.LanguageService.getCompletionEntryDetails`.
   */
  getCompletionEntryDetails(
    entryName: string,
    formatOptions: ts.FormatCodeOptions | ts.FormatCodeSettings | undefined,
    preferences: ts.UserPreferences | undefined,
    data: ts.CompletionEntryData | undefined,
  ): ts.CompletionEntryDetails | undefined {
    if (this.isPropertyExpressionCompletion()) {
      return this.getPropertyExpressionCompletionDetails(
        entryName,
        formatOptions,
        preferences,
        data,
      );
    } else if (this.isElementTagCompletion()) {
      return this.getElementTagCompletionDetails(entryName, data, preferences);
    } else if (this.isElementAttributeCompletion()) {
      return this.getElementAttributeCompletionDetails(entryName, data, preferences);
    }
    return undefined;
  }

  /**
   * Analogue for `ts.LanguageService.getCompletionEntrySymbol`.
   */
  getCompletionEntrySymbol(name: string): ts.Symbol | undefined {
    if (this.isPropertyExpressionCompletion()) {
      return this.getPropertyExpressionCompletionSymbol(name);
    } else if (this.isElementTagCompletion()) {
      return this.getElementTagCompletionSymbol(name);
    } else if (this.isElementAttributeCompletion()) {
      return this.getElementAttributeCompletionSymbol(name);
    } else {
      return undefined;
    }
  }

  /**
   * Determine if the current node is the completion of a property expression, and narrow the type
   * of `this.node` if so.
   *
   * This narrowing gives access to additional methods related to completion of property
   * expressions.
   */
  private isPropertyExpressionCompletion(
    this: CompletionBuilder<TmplAstNode | AST>,
  ): this is PropertyExpressionCompletionBuilder {
    return (
      this.node instanceof PropertyRead ||
      this.node instanceof SafePropertyRead ||
      this.node instanceof EmptyExpr ||
      // BoundEvent nodes only count as property completions if in an EventValue context.
      (this.node instanceof BoundEvent && this.nodeContext === CompletionNodeContext.EventValue)
    );
  }

  /**
   * Get completions for property expressions.
   */
  private getPropertyExpressionCompletion(
    this: PropertyExpressionCompletionBuilder,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    if (
      this.node instanceof EmptyExpr ||
      this.node instanceof BoundEvent ||
      this.node.receiver instanceof ImplicitReceiver ||
      this.node.receiver instanceof ThisReceiver
    ) {
      return this.getGlobalPropertyExpressionCompletion(options);
    } else {
      const location = this.templateTypeChecker.getExpressionCompletionLocation(
        this.node,
        this.component,
      );
      if (location === null) {
        return undefined;
      }
      const tsResults = this.tsLS.getCompletionsAtPosition(
        location.tcbPath,
        location.positionInFile,
        options,
      );
      if (tsResults === undefined) {
        return undefined;
      }

      const replacementSpan = makeReplacementSpanFromAst(this.node);

      if (
        !(this.node.receiver instanceof ImplicitReceiver) &&
        !(this.node.receiver instanceof ThisReceiver) &&
        !(this.node instanceof SafePropertyRead) &&
        options?.includeCompletionsWithInsertText &&
        options.includeAutomaticOptionalChainCompletions !== false
      ) {
        const symbol = this.templateTypeChecker.getSymbolOfNode(this.node.receiver, this.component);
        if (symbol?.kind === SymbolKind.Expression) {
          const type = this.templateTypeChecker.getTypeOfSymbol(symbol)!;
          const nonNullableType = this.typeChecker.getNonNullableType(type);
          if (type !== nonNullableType && replacementSpan !== undefined) {
            // Shift the start location back one so it includes the `.` of the property access.
            // In combination with the options above, this will indicate to the TS LS to include
            // optional chaining completions `?.` for nullable types.
            replacementSpan.start--;
            replacementSpan.length++;
          }
        }
      }

      let ngResults: ts.CompletionEntry[] = [];
      for (const result of tsResults.entries) {
        ngResults.push({
          ...result,
          replacementSpan,
        });
      }
      return {
        ...tsResults,
        entries: ngResults,
      };
    }
  }

  /**
   * Get the details of a specific completion for a property expression.
   */
  private getPropertyExpressionCompletionDetails(
    this: PropertyExpressionCompletionBuilder,
    entryName: string,
    formatOptions: ts.FormatCodeOptions | ts.FormatCodeSettings | undefined,
    preferences: ts.UserPreferences | undefined,
    data: ts.CompletionEntryData | undefined,
  ): ts.CompletionEntryDetails | undefined {
    let details: ts.CompletionEntryDetails | undefined = undefined;
    if (
      this.node instanceof EmptyExpr ||
      this.node instanceof BoundEvent ||
      this.node.receiver instanceof ImplicitReceiver ||
      this.node.receiver instanceof ThisReceiver
    ) {
      details = this.getGlobalPropertyExpressionCompletionDetails(
        entryName,
        formatOptions,
        preferences,
        data,
      );
    } else {
      const location = this.compiler
        .getTemplateTypeChecker()
        .getExpressionCompletionLocation(this.node, this.component);
      if (location === null) {
        return undefined;
      }
      details = this.tsLS.getCompletionEntryDetails(
        location.tcbPath,
        location.positionInFile,
        entryName,
        formatOptions,
        /* source */ undefined,
        preferences,
        data,
      );
    }
    if (details !== undefined) {
      details.displayParts = filterAliasImports(details.displayParts);
    }
    return details;
  }

  /**
   * Get the `ts.Symbol` for a specific completion for a property expression.
   */
  private getPropertyExpressionCompletionSymbol(
    this: PropertyExpressionCompletionBuilder,
    name: string,
  ): ts.Symbol | undefined {
    if (
      this.node instanceof EmptyExpr ||
      this.node instanceof LiteralPrimitive ||
      this.node instanceof BoundEvent ||
      this.node.receiver instanceof ImplicitReceiver ||
      this.node.receiver instanceof ThisReceiver
    ) {
      return this.getGlobalPropertyExpressionCompletionSymbol(name);
    } else {
      const location = this.compiler
        .getTemplateTypeChecker()
        .getExpressionCompletionLocation(this.node, this.component);
      if (location === null) {
        return undefined;
      }
      return this.tsLS.getCompletionEntrySymbol(
        location.tcbPath,
        location.positionInFile,
        name,
        /* source */ undefined,
      );
    }
  }

  /**
   * Get completions for a property expression in a global context (e.g. `{{y|}}`).
   */
  private getGlobalPropertyExpressionCompletion(
    this: PropertyExpressionCompletionBuilder | LetCompletionBuilder,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    const completions = this.templateTypeChecker.getGlobalCompletions(
      this.template,
      this.component,
      this.node,
    );
    if (completions === null) {
      return undefined;
    }

    const {componentContext, templateContext, nodeContext: astContext} = completions;

    const replacementSpan = makeReplacementSpanFromAst(this.node);

    // Merge TS completion results with results from the template scope.
    let entries: ts.CompletionEntry[] = [];
    const componentCompletions = this.tsLS.getCompletionsAtPosition(
      componentContext.tcbPath,
      componentContext.positionInFile,
      options,
    );
    if (componentCompletions !== undefined) {
      for (const tsCompletion of componentCompletions.entries) {
        // Skip completions that are shadowed by a template entity definition.
        if (templateContext.has(tsCompletion.name)) {
          continue;
        }
        entries.push({
          ...tsCompletion,
          // Substitute the TS completion's `replacementSpan` (which uses offsets within the TCB)
          // with the `replacementSpan` within the template source.
          replacementSpan,
        });
      }
    }

    // Merge TS completion results with results from the ast context.
    if (astContext !== null) {
      const nodeCompletions = this.tsLS.getCompletionsAtPosition(
        astContext.tcbPath,
        astContext.positionInFile,
        options,
      );
      if (nodeCompletions !== undefined) {
        for (const tsCompletion of nodeCompletions.entries) {
          if (this.isValidNodeContextCompletion(tsCompletion)) {
            entries.push({
              ...tsCompletion,
              // Substitute the TS completion's `replacementSpan` (which uses offsets within the
              // TCB) with the `replacementSpan` within the template source.
              replacementSpan,
            });
          }
        }
      }
    }

    for (const [name, entity] of templateContext) {
      let displayInfo: DisplayInfoKind;

      if (entity.kind === CompletionKind.Reference) {
        displayInfo = DisplayInfoKind.REFERENCE;
      } else if (entity.kind === CompletionKind.LetDeclaration) {
        displayInfo = DisplayInfoKind.LET;
      } else {
        displayInfo = DisplayInfoKind.VARIABLE;
      }

      entries.push({
        name,
        sortText: name,
        replacementSpan,
        kindModifiers: ts.ScriptElementKindModifier.none,
        kind: unsafeCastDisplayInfoKindToScriptElementKind(displayInfo),
      });
    }

    return {
      entries,
      // Although this completion is "global" in the sense of an Angular expression (there is no
      // explicit receiver), it is not "global" in a TypeScript sense since Angular expressions
      // have the component as an implicit receiver.
      isGlobalCompletion: false,
      isMemberCompletion: true,
      isNewIdentifierLocation: false,
    };
  }

  /**
   * Get the details of a specific completion for a property expression in a global context (e.g.
   * `{{y|}}`).
   */
  private getGlobalPropertyExpressionCompletionDetails(
    this: PropertyExpressionCompletionBuilder,
    entryName: string,
    formatOptions: ts.FormatCodeOptions | ts.FormatCodeSettings | undefined,
    preferences: ts.UserPreferences | undefined,
    data: ts.CompletionEntryData | undefined,
  ): ts.CompletionEntryDetails | undefined {
    const completions = this.templateTypeChecker.getGlobalCompletions(
      this.template,
      this.component,
      this.node,
    );
    if (completions === null) {
      return undefined;
    }
    const {componentContext, templateContext} = completions;

    if (templateContext.has(entryName)) {
      const entry = templateContext.get(entryName)!;
      // Entries that reference a symbol in the template context refer either to local references
      // or variables.
      const symbol = this.templateTypeChecker.getSymbolOfNode(
        entry.node,
        this.component,
      ) as TemplateDeclarationSymbol | null;
      if (symbol === null) {
        return undefined;
      }

      const {kind, displayParts, documentation, tags} = getSymbolDisplayInfo(
        this.tsLS,
        this.typeChecker,
        symbol,
        this.templateTypeChecker,
      );
      return {
        kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
        name: entryName,
        kindModifiers: ts.ScriptElementKindModifier.none,
        displayParts,
        documentation,
        tags,
      };
    } else {
      return this.tsLS.getCompletionEntryDetails(
        componentContext.tcbPath,
        componentContext.positionInFile,
        entryName,
        formatOptions,
        /* source */ undefined,
        preferences,
        data,
      );
    }
  }

  /**
   * Get the `ts.Symbol` of a specific completion for a property expression in a global context
   * (e.g. `{{y|}}`).
   */
  private getGlobalPropertyExpressionCompletionSymbol(
    this: PropertyExpressionCompletionBuilder,
    entryName: string,
  ): ts.Symbol | undefined {
    const completions = this.templateTypeChecker.getGlobalCompletions(
      this.template,
      this.component,
      this.node,
    );
    if (completions === null) {
      return undefined;
    }
    const {componentContext, templateContext} = completions;
    if (templateContext.has(entryName)) {
      const node: TmplAstReference | TmplAstVariable | TmplAstLetDeclaration =
        templateContext.get(entryName)!.node;
      const symbol = this.templateTypeChecker.getSymbolOfNode(
        node,
        this.component,
      ) as TemplateDeclarationSymbol | null;
      if (symbol === null || this.templateTypeChecker.getTsSymbolOfSymbol(symbol) === null) {
        return undefined;
      }
      return this.templateTypeChecker.getTsSymbolOfSymbol(symbol)!;
    } else {
      return this.tsLS.getCompletionEntrySymbol(
        componentContext.tcbPath,
        componentContext.positionInFile,
        entryName,
        /* source */ undefined,
      );
    }
  }

  private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement | TmplAstText> {
    if (this.node instanceof TmplAstText) {
      const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
      // We only provide element completions in a text node when there is an open tag immediately
      // to the left of the position.
      return this.node.value.substring(0, positionInTextNode).endsWith('<');
    } else if (this.node instanceof TmplAstElement) {
      return this.nodeContext === CompletionNodeContext.ElementTag;
    }
    return false;
  }

  private getElementTagCompletion(
    this: CompletionBuilder<TmplAstElement | TmplAstText>,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    const templateTypeChecker = this.compiler.getTemplateTypeChecker();

    let start: number;
    let length: number;
    if (this.node instanceof TmplAstElement) {
      // The replacementSpan is the tag name.
      start = this.node.sourceSpan.start.offset + 1; // account for leading '<'
      length = this.node.name.length;
    } else {
      const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
      const textToLeftOfPosition = this.node.value.substring(0, positionInTextNode);
      start = this.node.sourceSpan.start.offset + textToLeftOfPosition.lastIndexOf('<') + 1;
      // We only autocomplete immediately after the < so we don't replace any existing text
      length = 0;
    }

    const replacementSpan: ts.TextSpan = {start, length};

    let potentialTags = Array.from(
      templateTypeChecker.getPotentialElementTags(this.component, this.tsLS, {
        includeExternalModule: options?.includeCompletionsForModuleExports ?? false,
      }),
    );
    // Don't provide non-Angular tags (directive === null) because we expect other extensions
    // (i.e. Emmet) to provide those for HTML files.
    potentialTags = potentialTags.filter(([_, directive]) => directive !== null);
    const directiveCompletionDetailMap = new Map<string, DirectiveInfoForCompletionDetail>();

    const entries: ts.CompletionEntry[] = [];
    for (let [tag, directive] of potentialTags) {
      if (
        directive?.tsCompletionEntryInfos != null &&
        directive.tsCompletionEntryInfos.length > 0
      ) {
        directiveCompletionDetailMap.set(tag, {
          fileName: directive.ref.filePath,
          entryName: directive.ref.name,
          pos: directive.ref.position,
          attrKind: null,

          // The Angular LS only supports displaying one directive at a time when
          // providing the completion item, even if it's exported by multiple modules.
          symbolFileName: directive.tsCompletionEntryInfos[0].tsCompletionEntrySymbolFileName,
          symbolName: directive.tsCompletionEntryInfos[0].tsCompletionEntrySymbolName,
        });
      }
      entries.push({
        kind: tagCompletionKind(directive),
        name: tag,
        sortText: tag,
        replacementSpan,
        hasAction: directive?.isInScope === true ? undefined : true,
        data: directive?.tsCompletionEntryInfos?.[0]?.tsCompletionEntryData,
      });
    }

    directiveInfoForCompletionDetailCache = directiveCompletionDetailMap;
    return {
      entries,
      isGlobalCompletion: false,
      isMemberCompletion: false,
      isNewIdentifierLocation: false,
    };
  }

  private getTsNodeAtPosition(filePath: string, position: number): ts.Node | null {
    const sf = this.compiler.getCurrentProgram().getSourceFile(filePath);
    if (!sf) {
      return null;
    }
    return findTightestNode(sf, position) ?? null;
  }

  private getElementTagCompletionDetails(
    this: CompletionBuilder<TmplAstElement | TmplAstText>,
    entryName: string,
    data: ts.CompletionEntryData | undefined,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.CompletionEntryDetails | undefined {
    const templateTypeChecker = this.compiler.getTemplateTypeChecker();
    const directiveCompletionDetail = directiveInfoForCompletionDetailCache.get(entryName);

    let directive: PotentialDirective | null | undefined;

    if (directiveCompletionDetail === undefined) {
      const tagMap = templateTypeChecker.getElementsInFileScope(this.component);
      directive = tagMap.get(entryName);
    } else {
      const {fileName, entryName: directiveName, pos} = directiveCompletionDetail;
      const node = this.getTsNodeAtPosition(fileName, pos)?.parent;
      if (
        node !== undefined &&
        ts.isClassDeclaration(node) &&
        node.name?.getText() === directiveName
      ) {
        directive = templateTypeChecker.getDirectiveScopeData(node, false, {
          tsCompletionEntrySymbolFileName: directiveCompletionDetail.symbolFileName,
          tsCompletionEntryData: data,
          tsCompletionEntrySymbolName: directiveCompletionDetail.symbolName,
        });
      }
    }

    if (directive === undefined || directive === null) {
      return undefined;
    }

    const displayInfo = getDirectiveDisplayInfo(this.tsLS, directive);
    const displayParts = displayInfo.displayParts;
    const documentation = displayInfo.documentation;
    const tags = displayInfo.tags;

    let codeActions: ts.CodeAction[] | undefined;
    if (!directive.isInScope) {
      const importOn = standaloneTraitOrNgModule(templateTypeChecker, this.component);

      const codeActionsCache =
        importOn !== null
          ? getCodeActionToImportTheDirectiveDeclaration(
              this.compiler,
              this.component,
              importOn,
              directive,
              this.tsLS,
              options?.includeCompletionsWithInsertText,
            )
          : undefined;

      codeActions =
        codeActionsCache !== undefined && codeActionsCache.length > 0
          ? [codeActionsCache[0]]
          : undefined;
    }

    return {
      kind: tagCompletionKind(directive),
      name: entryName,
      kindModifiers: ts.ScriptElementKindModifier.none,
      displayParts,
      documentation,
      tags,
      codeActions,
    };
  }

  private getElementTagCompletionSymbol(
    this: CompletionBuilder<TmplAstElement | TmplAstText>,
    entryName: string,
  ): ts.Symbol | undefined {
    const templateTypeChecker = this.compiler.getTemplateTypeChecker();

    const tagMap = templateTypeChecker.getPotentialElementTags(this.component, this.tsLS, {
      includeExternalModule: true,
    });
    if (!tagMap.has(entryName)) {
      return undefined;
    }

    const directive = tagMap.get(entryName)!;
    const decl = getClassDeclarationFromSymbolReference(this.tsLS, directive.ref);

    if (!decl || !ts.isClassDeclaration(decl)) return undefined;
    return decl.name ? this.typeChecker.getSymbolAtLocation(decl.name) : undefined;
  }

  private isAnimationCompletion(): this is ElementAnimationCompletionBuilder {
    return (
      (this.node instanceof TmplAstBoundAttribute &&
        this.node.type === BindingType.LegacyAnimation) ||
      (this.node instanceof TmplAstBoundEvent && this.node.type === ParsedEventType.LegacyAnimation)
    );
  }

  private getAnimationCompletions(
    this: ElementAnimationCompletionBuilder,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    if (this.node instanceof TmplAstBoundAttribute) {
      const animations = this.compiler.getTemplateTypeChecker().getDirectiveMetadata(this.component)
        ?.animationTriggerNames?.staticTriggerNames;
      const replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan);

      if (animations === undefined) {
        return undefined;
      }

      const entries = buildAnimationCompletionEntries(
        [...animations, '.disabled'],
        replacementSpan,
        DisplayInfoKind.ATTRIBUTE,
      );
      return {
        entries,
        isGlobalCompletion: false,
        isMemberCompletion: false,
        isNewIdentifierLocation: true,
      };
    } else {
      const animationNameSpan = buildAnimationNameSpan(this.node);
      const phaseSpan = buildAnimationPhaseSpan(this.node);
      if (isWithin(this.position, animationNameSpan)) {
        const animations = this.compiler
          .getTemplateTypeChecker()
          .getDirectiveMetadata(this.component)?.animationTriggerNames?.staticTriggerNames;
        const replacementSpan = makeReplacementSpanFromParseSourceSpan(animationNameSpan);

        if (animations === undefined) {
          return undefined;
        }

        const entries = buildAnimationCompletionEntries(
          animations,
          replacementSpan,
          DisplayInfoKind.EVENT,
        );
        return {
          entries,
          isGlobalCompletion: false,
          isMemberCompletion: false,
          isNewIdentifierLocation: true,
        };
      }
      if (phaseSpan !== null && isWithin(this.position, phaseSpan)) {
        const replacementSpan = makeReplacementSpanFromParseSourceSpan(phaseSpan);
        const entries = buildAnimationCompletionEntries(
          ANIMATION_PHASES,
          replacementSpan,
          DisplayInfoKind.EVENT,
        );
        return {
          entries,
          isGlobalCompletion: false,
          isMemberCompletion: false,
          isNewIdentifierLocation: true,
        };
      }
      return undefined;
    }
  }

  private isElementAttributeCompletion(): this is ElementAttributeCompletionBuilder {
    return (
      (this.nodeContext === CompletionNodeContext.ElementAttributeKey ||
        this.nodeContext === CompletionNodeContext.TwoWayBinding) &&
      (this.node instanceof TmplAstElement ||
        this.node instanceof TmplAstBoundAttribute ||
        this.node instanceof TmplAstTextAttribute ||
        this.node instanceof TmplAstBoundEvent)
    );
  }

  private getElementAttributeCompletions(
    this: ElementAttributeCompletionBuilder,
    options: ts.GetCompletionsAtPositionOptions | undefined,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    let element: TmplAstElement | TmplAstTemplate;
    if (this.node instanceof TmplAstElement) {
      element = this.node;
    } else if (
      this.nodeParent instanceof TmplAstElement ||
      this.nodeParent instanceof TmplAstTemplate
    ) {
      element = this.nodeParent;
    } else {
      // Nothing to do without an element to process.
      return undefined;
    }
    if (
      !element.isSelfClosing &&
      element.endSourceSpan !== null &&
      isWithin(this.position, element.endSourceSpan)
    ) {
      return undefined;
    }

    let replacementSpan: ts.TextSpan | undefined = undefined;
    if (
      (this.node instanceof TmplAstBoundAttribute ||
        this.node instanceof TmplAstBoundEvent ||
        this.node instanceof TmplAstTextAttribute) &&
      this.node.keySpan !== undefined
    ) {
      replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan);
    }

    let insertSnippet: true | undefined;
    if (options?.includeCompletionsWithSnippetText && options.includeCompletionsWithInsertText) {
      if (this.node instanceof TmplAstBoundEvent && isBoundEventWithSyntheticHandler(this.node)) {
        replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
        insertSnippet = true;
      }

      const isBoundAttributeValueEmpty =
        this.node instanceof TmplAstBoundAttribute &&
        (this.node.valueSpan === undefined ||
          (this.node.value instanceof ASTWithSource && this.node.value.ast instanceof EmptyExpr));
      if (isBoundAttributeValueEmpty) {
        replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
        insertSnippet = true;
      }

      if (this.node instanceof TmplAstTextAttribute && this.node.keySpan !== undefined) {
        // The `sourceSpan` only includes `ngFor` and the `valueSpan` is always empty even if
        // there is something there because we split this up into the desugared AST, `ngFor
        // ngForOf=""`.
        const nodeStart = this.node.keySpan.start.getContext(1, 1);
        if (nodeStart?.before[0] === '*') {
          const nodeEnd = this.node.keySpan.end.getContext(1, 1);
          if (nodeEnd?.after[0] !== '=') {
            // *ngFor -> *ngFor="¦"
            insertSnippet = true;
          }
        } else {
          if (this.node.value === '') {
            replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.sourceSpan);
            insertSnippet = true;
          }
        }
      }

      if (this.node instanceof TmplAstElement) {
        // <div ¦ />
        insertSnippet = true;
      }
    }

    const attrTable = buildAttributeCompletionTable(
      this.component,
      element,
      this.compiler.getTemplateTypeChecker(),
      this.tsLS,
      options?.includeCompletionsForModuleExports,
    );

    let entries: ts.CompletionEntry[] = [];
    const directiveCompletionDetailMap = new Map<string, DirectiveInfoForCompletionDetail>();

    for (const [key, completion] of attrTable.entries()) {
      // First, filter out completions that don't make sense for the current node. For example, if
      // the user is completing on a property binding `[foo|]`, don't offer output event
      // completions.
      switch (completion.kind) {
        case AttributeCompletionKind.DomEvent:
          if (this.node instanceof TmplAstBoundAttribute) {
            continue;
          }
          break;
        case AttributeCompletionKind.DomAttribute:
        case AttributeCompletionKind.DomProperty:
          if (this.node instanceof TmplAstBoundEvent) {
            continue;
          }
          break;
        case AttributeCompletionKind.DirectiveInput:
          if (this.node instanceof TmplAstBoundEvent) {
            continue;
          }
          if (
            !completion.twoWayBindingSupported &&
            this.nodeContext === CompletionNodeContext.TwoWayBinding
          ) {
            continue;
          }
          break;
        case AttributeCompletionKind.DirectiveOutput:
          if (this.node instanceof TmplAstBoundAttribute) {
            continue;
          }
          break;
        case AttributeCompletionKind.DirectiveAttribute:
          if (
            this.node instanceof TmplAstBoundAttribute ||
            this.node instanceof TmplAstBoundEvent
          ) {
            continue;
          }
          break;
      }

      // Save tsCompletionEntryInfos if present and completion has a directive property
      if (
        'directive' in completion &&
        completion.directive.tsCompletionEntryInfos !== null &&
        completion.directive.tsCompletionEntryInfos.length > 0
      ) {
        directiveCompletionDetailMap.set(key, {
          fileName: completion.directive.ref.filePath,
          entryName: completion.directive.ref.name,
          pos: completion.directive.ref.position,
          attrKind: completion.kind,

          // The Angular LS only supports displaying one directive at a time when
          // providing the completion item, even if it's exported by multiple modules.
          symbolFileName:
            completion.directive.tsCompletionEntryInfos[0].tsCompletionEntrySymbolFileName,
          symbolName: completion.directive.tsCompletionEntryInfos[0].tsCompletionEntrySymbolName,
        });
      }

      // Is the completion in an attribute context (instead of a property context)?
      const isAttributeContext =
        this.node instanceof TmplAstElement || this.node instanceof TmplAstTextAttribute;
      // Is the completion for an element (not an <ng-template>)?
      const isElementContext =
        this.node instanceof TmplAstElement || this.nodeParent instanceof TmplAstElement;

      addAttributeCompletionEntries(
        entries,
        completion,
        isAttributeContext,
        isElementContext,
        replacementSpan,
        insertSnippet,
      );
    }

    directiveInfoForCompletionDetailCache = directiveCompletionDetailMap;

    return {
      entries,
      isGlobalCompletion: false,
      isMemberCompletion: false,
      isNewIdentifierLocation: true,
    };
  }

  private getElementAttributeCompletionDetails(
    this: ElementAttributeCompletionBuilder,
    entryName: string,
    data: ts.CompletionEntryData | undefined,
    preferences: ts.UserPreferences | undefined,
  ): ts.CompletionEntryDetails | undefined {
    // `entryName` here may be `foo` or `[foo]`, depending on which suggested completion the user
    // chose. Strip off any binding syntax to get the real attribute name.
    const {name, kind} = stripBindingSugar(entryName);

    let element: TmplAstElement | TmplAstTemplate;
    if (this.node instanceof TmplAstElement || this.node instanceof TmplAstTemplate) {
      element = this.node;
    } else if (
      this.nodeParent instanceof TmplAstElement ||
      this.nodeParent instanceof TmplAstTemplate
    ) {
      element = this.nodeParent;
    } else {
      // Nothing to do without an element to process.
      return undefined;
    }

    const templateTypeChecker = this.compiler.getTemplateTypeChecker();

    const directiveCompletionDetail = directiveInfoForCompletionDetailCache.get(name);
    let directive: PotentialDirective | null = null;
    let attrKind: null | AttributeCompletionKind = null;

    /**
     * If a directive is present on the node, the directive may be a host directive,
     * the `classPropertyName` can't get from the directive meta by the entry name.
     * So get it from the `attrTable`.
     *
     * If the directive is out of the component scope, the host directive is not available,
     * so the `classPropertyName` can get from the directive meta by the entry name.
     */
    let classPropertyName: string | null = null;

    if (directiveCompletionDetail === undefined) {
      // If the directive completion detail is not available in the cache, it means that the directive
      // is in the current component scope. Get the directive from the attribute completion table.
      const attrTable = buildAttributeCompletionTable(
        this.component,
        element,
        templateTypeChecker,
        this.tsLS,
        false /* includeExternalModule. Avoid getting the global completion item from the tsLs */,
      );
      const completion = attrTable.get(name);
      if (completion === undefined) {
        return undefined;
      }
      directive = 'directive' in completion ? completion.directive : null;
      attrKind = completion.kind;
      classPropertyName = 'classPropertyName' in completion ? completion.classPropertyName : null;
    } else {
      const {fileName, entryName: directiveName, pos} = directiveCompletionDetail;
      const node = this.getTsNodeAtPosition(fileName, pos)?.parent;
      if (
        node !== undefined &&
        ts.isClassDeclaration(node) &&
        node.name?.getText() === directiveName
      ) {
        directive = templateTypeChecker.getDirectiveScopeData(node, false, {
          tsCompletionEntrySymbolFileName: directiveCompletionDetail.symbolFileName,
          tsCompletionEntryData: data,
          tsCompletionEntrySymbolName: directiveCompletionDetail.symbolName,
        });
        attrKind = directiveCompletionDetail.attrKind;
        classPropertyName = getClassPropertyNameFromDirective(
          attrKind,
          name,
          directive,
          templateTypeChecker,
          this.tsLS,
        );
      }
    }

    if (attrKind === null) {
      return undefined;
    }

    let displayParts: ts.SymbolDisplayPart[] = [];
    let documentation: ts.SymbolDisplayPart[] | undefined = undefined;
    let tags: ts.JSDocTagInfo[] | undefined = undefined;
    let info: DisplayInfo | null;
    switch (attrKind) {
      case AttributeCompletionKind.DomEvent:
      case AttributeCompletionKind.DomAttribute:
      case AttributeCompletionKind.DomProperty:
        // TODO(alxhub): ideally we would show the same documentation as quick info here. However,
        // since these bindings don't exist in the TCB, there is no straightforward way to
        // retrieve a `ts.Symbol` for the field in the TS DOM definition.
        displayParts = [];
        break;
      case AttributeCompletionKind.DirectiveAttribute:
        if (directive === null) {
          break;
        }
        info = getDirectiveDisplayInfo(this.tsLS, directive);
        displayParts = info.displayParts;
        documentation = info.documentation;
        tags = info.tags;
        break;
      case AttributeCompletionKind.StructuralDirectiveAttribute:
      case AttributeCompletionKind.DirectiveInput:
      case AttributeCompletionKind.DirectiveOutput:
        if (directive === null) {
          break;
        }
        const propertySymbol = getAttributeCompletionSymbol(
          attrKind,
          directive,
          classPropertyName,
          this.typeChecker,
          this.tsLS,
        );
        if (propertySymbol === null) {
          break;
        }

        let kind: DisplayInfoKind;
        if (attrKind === AttributeCompletionKind.DirectiveInput) {
          kind = DisplayInfoKind.PROPERTY;
        } else if (attrKind === AttributeCompletionKind.DirectiveOutput) {
          kind = DisplayInfoKind.EVENT;
        } else {
          kind = DisplayInfoKind.DIRECTIVE;
        }

        info = getTsSymbolDisplayInfo(
          this.tsLS,
          this.typeChecker,
          propertySymbol,
          kind,
          directive.ref.name,
        );
        if (info === null) {
          break;
        }
        displayParts = info.displayParts;
        documentation = info.documentation;
        tags = info.tags;
    }

    let codeActions: ts.CodeAction[] | undefined;
    if (directive !== null && !directive.isInScope) {
      const importOn = standaloneTraitOrNgModule(templateTypeChecker, this.component);

      const codeActionsCache =
        importOn !== null
          ? getCodeActionToImportTheDirectiveDeclaration(
              this.compiler,
              this.component,
              importOn,
              directive,
              this.tsLS,
              preferences?.includeCompletionsWithInsertText,
            )
          : undefined;

      codeActions =
        codeActionsCache !== undefined && codeActionsCache.length > 0
          ? [codeActionsCache[0]]
          : undefined;
    }

    return {
      name: entryName,
      kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
      kindModifiers: ts.ScriptElementKindModifier.none,
      displayParts,
      documentation,
      tags,
      codeActions,
    };
  }

  private getElementAttributeCompletionSymbol(
    this: ElementAttributeCompletionBuilder,
    attribute: string,
  ): ts.Symbol | undefined {
    const {name} = stripBindingSugar(attribute);

    let element: TmplAstElement | TmplAstTemplate;
    if (this.node instanceof TmplAstElement || this.node instanceof TmplAstTemplate) {
      element = this.node;
    } else if (
      this.nodeParent instanceof TmplAstElement ||
      this.nodeParent instanceof TmplAstTemplate
    ) {
      element = this.nodeParent;
    } else {
      // Nothing to do without an element to process.
      return undefined;
    }

    const attrTable = buildAttributeCompletionTable(
      this.component,
      element,
      this.compiler.getTemplateTypeChecker(),
      this.tsLS,
      true,
    );

    if (!attrTable.has(name)) {
      return undefined;
    }

    const completion = attrTable.get(name)!;
    return (
      getAttributeCompletionSymbol(
        completion.kind,
        'directive' in completion ? completion.directive : null,
        'classPropertyName' in completion ? completion.classPropertyName : null,
        this.typeChecker,
        this.tsLS,
      ) ?? undefined
    );
  }

  private isPipeCompletion(): this is PipeCompletionBuilder {
    return this.node instanceof BindingPipe;
  }

  private getPipeCompletions(
    this: PipeCompletionBuilder,
  ): ts.WithMetadata<ts.CompletionInfo> | undefined {
    const pipes = this.templateTypeChecker
      .getPotentialPipes(this.component)
      .filter((p) => p.isInScope);
    if (pipes === null) {
      return undefined;
    }

    const replacementSpan = makeReplacementSpanFromAst(this.node);
    const entries: ts.CompletionEntry[] = [];

    for (const pipe of pipes) {
      if (pipe.name !== null) {
        entries.push({
          name: pipe.name,
          sortText: pipe.name,
          kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
          replacementSpan,
        });
      }
    }

    return {
      entries,
      isGlobalCompletion: false,
      isMemberCompletion: false,
      isNewIdentifierLocation: false,
    };
  }

  /**
   * From the AST node of the cursor position, include completion of string literals, number
   * literals, `true`, `false`, `null`, and `undefined`.
   */
  private isValidNodeContextCompletion(completion: ts.CompletionEntry): boolean {
    if (completion.kind === ts.ScriptElementKind.string) {
      // 'string' kind includes both string literals and number literals
      return true;
    }
    if (completion.kind === ts.ScriptElementKind.keyword) {
      return (
        completion.name === 'true' || completion.name === 'false' || completion.name === 'null'
      );
    }
    if (completion.kind === ts.ScriptElementKind.variableElement) {
      return completion.name === 'undefined';
    }
    return false;
  }
}

function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan {
  return {
    start: span.start.offset,
    length: span.end.offset - span.start.offset,
  };
}

function makeReplacementSpanFromAst(
  node:
    | PropertyRead
    | SafePropertyRead
    | BindingPipe
    | EmptyExpr
    | LiteralPrimitive
    | BoundEvent
    | TmplAstLetDeclaration,
): ts.TextSpan | undefined {
  if (
    node instanceof EmptyExpr ||
    node instanceof LiteralPrimitive ||
    node instanceof BoundEvent ||
    node instanceof TmplAstLetDeclaration
  ) {
    // empty nodes do not replace any existing text
    return undefined;
  }

  return {
    start: node.nameSpan.start,
    length: node.nameSpan.end - node.nameSpan.start,
  };
}

function tagCompletionKind(directive: PotentialDirective | null): ts.ScriptElementKind {
  let kind: DisplayInfoKind;
  if (directive === null) {
    kind = DisplayInfoKind.ELEMENT;
  } else if (directive.isComponent) {
    kind = DisplayInfoKind.COMPONENT;
  } else {
    kind = DisplayInfoKind.DIRECTIVE;
  }
  return unsafeCastDisplayInfoKindToScriptElementKind(kind);
}

const BINDING_SUGAR = /[\[\(\)\]]/g;

function stripBindingSugar(binding: string): {name: string; kind: DisplayInfoKind} {
  const name = binding.replace(BINDING_SUGAR, '');
  if (binding.startsWith('[')) {
    return {name, kind: DisplayInfoKind.PROPERTY};
  } else if (binding.startsWith('(')) {
    return {name, kind: DisplayInfoKind.EVENT};
  } else {
    return {name, kind: DisplayInfoKind.ATTRIBUTE};
  }
}

function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
  switch (target.kind) {
    case TargetNodeKind.ElementInTagContext:
      return CompletionNodeContext.ElementTag;
    case TargetNodeKind.ElementInBodyContext:
      // Completions in element bodies are for new attributes.
      return CompletionNodeContext.ElementAttributeKey;
    case TargetNodeKind.TwoWayBindingContext:
      return CompletionNodeContext.TwoWayBinding;
    case TargetNodeKind.AttributeInKeyContext:
      return CompletionNodeContext.ElementAttributeKey;
    case TargetNodeKind.AttributeInValueContext:
      if (target.node instanceof TmplAstBoundEvent) {
        return CompletionNodeContext.EventValue;
      } else if (target.node instanceof TextAttribute) {
        return CompletionNodeContext.ElementAttributeValue;
      } else {
        return CompletionNodeContext.None;
      }
    default:
      // No special context is available.
      return CompletionNodeContext.None;
  }
}

function buildAnimationNameSpan(node: TmplAstBoundEvent): ParseSourceSpan {
  return new ParseSourceSpan(node.keySpan.start, node.keySpan.start.moveBy(node.name.length));
}

function buildAnimationPhaseSpan(node: TmplAstBoundEvent): ParseSourceSpan | null {
  if (node.phase !== null) {
    return new ParseSourceSpan(node.keySpan.end.moveBy(-node.phase.length), node.keySpan.end);
  }
  return null;
}

function getClassPropertyNameFromDirective(
  attrKind: AttributeCompletionKind | null,
  attrName: string,
  directive: PotentialDirective | null,
  templateTypeChecker: TemplateTypeChecker,
  ls?: ts.LanguageService,
): string | null {
  if (directive === null || attrKind === null) {
    return null;
  }
  const dirNode = ls ? getClassDeclarationFromSymbolReference(ls, directive.ref) : null;

  if (!dirNode || !ts.isClassDeclaration(dirNode)) {
    return null;
  }
  const meta = templateTypeChecker.getDirectiveMetadata(dirNode);
  switch (attrKind) {
    case AttributeCompletionKind.DirectiveInput:
      return meta?.inputs.getByBindingPropertyName(attrName)?.[0].classPropertyName ?? null;
    case AttributeCompletionKind.DirectiveOutput:
      return meta?.outputs.getByBindingPropertyName(attrName)?.[0].classPropertyName ?? null;
    default:
      return null;
  }
}
