/**
 * @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.io/license
 */
import {AST, TmplAstNode} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MetaType, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata';
import {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf';
import {ProgramDriver} from '@angular/compiler-cli/src/ngtsc/program_driver';
import {SymbolKind} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';

import {convertToTemplateDocumentSpan, createLocationKey, FilePosition, getParentClassMeta, getRenameTextAndSpanAtPosition, getTargetDetailsAtTemplatePosition, TemplateLocationDetails} from './references_and_rename_utils';
import {collectMemberMethods, findTightestNode} from './ts_utils';
import {getTemplateInfoAtPosition, TemplateInfo} from './utils';

export class ReferencesBuilder {
  private readonly ttc = this.compiler.getTemplateTypeChecker();

  constructor(
      private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService,
      private readonly compiler: NgCompiler) {}

  getReferencesAtPosition(filePath: string, position: number): ts.ReferenceEntry[]|undefined {
    this.ttc.generateAllTypeCheckBlocks();
    const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler);
    if (templateInfo === undefined) {
      return this.getReferencesAtTypescriptPosition(filePath, position);
    }
    return this.getReferencesAtTemplatePosition(templateInfo, position);
  }

  private getReferencesAtTemplatePosition(templateInfo: TemplateInfo, position: number):
      ts.ReferenceEntry[]|undefined {
    const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc);
    if (allTargetDetails === null) {
      return undefined;
    }
    const allReferences: ts.ReferenceEntry[] = [];
    for (const targetDetails of allTargetDetails) {
      for (const location of targetDetails.typescriptLocations) {
        const refs = this.getReferencesAtTypescriptPosition(location.fileName, location.position);
        if (refs !== undefined) {
          allReferences.push(...refs);
        }
      }
    }
    return allReferences.length > 0 ? allReferences : undefined;
  }

  private getReferencesAtTypescriptPosition(fileName: string, position: number):
      ts.ReferenceEntry[]|undefined {
    const refs = this.tsLS.getReferencesAtPosition(fileName, position);
    if (refs === undefined) {
      return undefined;
    }

    const entries: ts.ReferenceEntry[] = [];
    for (const ref of refs) {
      if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(ref.fileName))) {
        const entry = convertToTemplateDocumentSpan(ref, this.ttc, this.driver.getProgram());
        if (entry !== null) {
          entries.push(entry);
        }
      } else {
        entries.push(ref);
      }
    }
    return entries;
  }
}

enum RequestKind {
  DirectFromTemplate,
  DirectFromTypeScript,
  PipeName,
  Selector,
}

/** The context needed to perform a rename of a pipe name. */
interface PipeRenameContext {
  type: RequestKind.PipeName;

  /** The string literal for the pipe name that appears in the @Pipe meta */
  pipeNameExpr: ts.StringLiteral;

  /**
   * The location to use for querying the native TS LS for rename positions. This will be the
   * pipe's transform method.
   */
  renamePosition: FilePosition;
}

/** The context needed to perform a rename of a directive/component selector. */
interface SelectorRenameContext {
  type: RequestKind.Selector;

  /** The string literal that appears in the directive/component metadata. */
  selectorExpr: ts.StringLiteral;

  /**
   * The location to use for querying the native TS LS for rename positions. This will be the
   * component/directive class itself. Doing so will allow us to find the location of the
   * directive/component instantiations, which map to template elements.
   */
  renamePosition: FilePosition;
}

interface DirectFromTypescriptRenameContext {
  type: RequestKind.DirectFromTypeScript;

  /** The node that is being renamed. */
  requestNode: ts.Node;
}

interface DirectFromTemplateRenameContext {
  type: RequestKind.DirectFromTemplate;

  /** The position in the TCB file to use as the request to the native TSLS for renaming. */
  renamePosition: FilePosition;

  /** The position in the template the request originated from. */
  templatePosition: number;

  /** The target node in the template AST that corresponds to the template position. */
  requestNode: AST|TmplAstNode;
}

type IndirectRenameContext = PipeRenameContext|SelectorRenameContext;
type RenameRequest =
    IndirectRenameContext|DirectFromTemplateRenameContext|DirectFromTypescriptRenameContext;

function isDirectRenameContext(context: RenameRequest): context is DirectFromTemplateRenameContext|
    DirectFromTypescriptRenameContext {
  return context.type === RequestKind.DirectFromTemplate ||
      context.type === RequestKind.DirectFromTypeScript;
}

export class RenameBuilder {
  private readonly ttc = this.compiler.getTemplateTypeChecker();

  constructor(
      private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService,
      private readonly compiler: NgCompiler) {}

  getRenameInfo(filePath: string, position: number):
      Omit<ts.RenameInfoSuccess, 'kind'|'kindModifiers'>|ts.RenameInfoFailure {
    return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => {
      const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler);
      // We could not get a template at position so we assume the request came from outside the
      // template.
      if (templateInfo === undefined) {
        const renameRequest = this.buildRenameRequestAtTypescriptPosition(filePath, position);
        if (renameRequest === null) {
          return {
            canRename: false,
            localizedErrorMessage: 'Could not determine rename info at typescript position.',
          };
        }
        if (renameRequest.type === RequestKind.PipeName) {
          const pipeName = renameRequest.pipeNameExpr.text;
          return {
            canRename: true,
            displayName: pipeName,
            fullDisplayName: pipeName,
            triggerSpan: {
              length: pipeName.length,
              // Offset the pipe name by 1 to account for start of string '/`/"
              start: renameRequest.pipeNameExpr.getStart() + 1,
            },
          };
        } else {
          // TODO(atscott): Add support for other special indirect renames from typescript files.
          return this.tsLS.getRenameInfo(filePath, position);
        }
      }

      const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc);
      if (allTargetDetails === null) {
        return {
          canRename: false,
          localizedErrorMessage: 'Could not find template node at position.'
        };
      }
      const {templateTarget} = allTargetDetails[0];
      const templateTextAndSpan = getRenameTextAndSpanAtPosition(
          templateTarget,
          position,
      );
      if (templateTextAndSpan === null) {
        return {canRename: false, localizedErrorMessage: 'Could not determine template node text.'};
      }
      const {text, span} = templateTextAndSpan;
      return {
        canRename: true,
        displayName: text,
        fullDisplayName: text,
        triggerSpan: span,
      };
    });
  }

  findRenameLocations(filePath: string, position: number): readonly ts.RenameLocation[]|null {
    this.ttc.generateAllTypeCheckBlocks();
    return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => {
      const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler);
      // We could not get a template at position so we assume the request came from outside the
      // template.
      if (templateInfo === undefined) {
        const renameRequest = this.buildRenameRequestAtTypescriptPosition(filePath, position);
        if (renameRequest === null) {
          return null;
        }
        return this.findRenameLocationsAtTypescriptPosition(renameRequest);
      }
      return this.findRenameLocationsAtTemplatePosition(templateInfo, position);
    });
  }

  private findRenameLocationsAtTemplatePosition(templateInfo: TemplateInfo, position: number):
      readonly ts.RenameLocation[]|null {
    const allTargetDetails = getTargetDetailsAtTemplatePosition(templateInfo, position, this.ttc);
    if (allTargetDetails === null) {
      return null;
    }
    const renameRequests = this.buildRenameRequestsFromTemplateDetails(allTargetDetails, position);
    if (renameRequests === null) {
      return null;
    }

    const allRenameLocations: ts.RenameLocation[] = [];
    for (const renameRequest of renameRequests) {
      const locations = this.findRenameLocationsAtTypescriptPosition(renameRequest);
      // If we couldn't find rename locations for _any_ result, we should not allow renaming to
      // proceed instead of having a partially complete rename.
      if (locations === null) {
        return null;
      }
      allRenameLocations.push(...locations);
    }
    return allRenameLocations.length > 0 ? allRenameLocations : null;
  }

  findRenameLocationsAtTypescriptPosition(renameRequest: RenameRequest):
      readonly ts.RenameLocation[]|null {
    return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => {
      const renameInfo = getExpectedRenameTextAndInitalRenameEntries(renameRequest);
      if (renameInfo === null) {
        return null;
      }
      const {entries, expectedRenameText} = renameInfo;
      const {fileName, position} = getRenameRequestPosition(renameRequest);
      const findInStrings = false;
      const findInComments = false;
      const locations =
          this.tsLS.findRenameLocations(fileName, position, findInStrings, findInComments);
      if (locations === undefined) {
        return null;
      }

      for (const location of locations) {
        if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) {
          const entry = convertToTemplateDocumentSpan(
              location, this.ttc, this.driver.getProgram(), expectedRenameText);
          // There is no template node whose text matches the original rename request. Bail on
          // renaming completely rather than providing incomplete results.
          if (entry === null) {
            return null;
          }
          entries.push(entry);
        } else {
          if (!isDirectRenameContext(renameRequest)) {
            // Discard any non-template results for non-direct renames. We should only rename
            // template results + the name/selector/alias `ts.Expression`. The other results
            // will be the the `ts.Identifier` of the transform method (pipe rename) or the
            // directive class (selector rename).
            continue;
          }
          // Ensure we only allow renaming a TS result with matching text
          const refNode = this.getTsNodeAtPosition(location.fileName, location.textSpan.start);
          if (refNode === null || refNode.getText() !== expectedRenameText) {
            return null;
          }
          entries.push(location);
        }
      }
      return entries;
    });
  }

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

  private buildRenameRequestsFromTemplateDetails(
      allTargetDetails: TemplateLocationDetails[], templatePosition: number): RenameRequest[]|null {
    const renameRequests: RenameRequest[] = [];
    for (const targetDetails of allTargetDetails) {
      for (const location of targetDetails.typescriptLocations) {
        if (targetDetails.symbol.kind === SymbolKind.Pipe) {
          const meta =
              this.compiler.getMeta(targetDetails.symbol.classSymbol.tsSymbol.valueDeclaration);
          if (meta === null || meta.type !== MetaType.Pipe) {
            return null;
          }
          const renameRequest = this.buildPipeRenameRequest(meta);
          if (renameRequest === null) {
            return null;
          }
          renameRequests.push(renameRequest);
        } else {
          const renameRequest: RenameRequest = {
            type: RequestKind.DirectFromTemplate,
            templatePosition,
            requestNode: targetDetails.templateTarget,
            renamePosition: location
          };
          renameRequests.push(renameRequest);
        }
      }
    }
    return renameRequests;
  }

  private buildRenameRequestAtTypescriptPosition(filePath: string, position: number): RenameRequest
      |null {
    const requestNode = this.getTsNodeAtPosition(filePath, position);
    if (requestNode === null) {
      return null;
    }
    const meta = getParentClassMeta(requestNode, this.compiler);
    if (meta !== null && meta.type === MetaType.Pipe && meta.nameExpr === requestNode) {
      return this.buildPipeRenameRequest(meta);
    } else {
      return {type: RequestKind.DirectFromTypeScript, requestNode};
    }
  }

  private buildPipeRenameRequest(meta: PipeMeta): PipeRenameContext|null {
    if (!ts.isClassDeclaration(meta.ref.node) || meta.nameExpr === null ||
        !ts.isStringLiteral(meta.nameExpr)) {
      return null;
    }
    const typeChecker = this.driver.getProgram().getTypeChecker();
    const memberMethods = collectMemberMethods(meta.ref.node, typeChecker) ?? [];
    const pipeTransformNode: ts.MethodDeclaration|undefined =
        memberMethods.find(m => m.name.getText() === 'transform');
    if (pipeTransformNode === undefined) {
      return null;
    }
    return {
      type: RequestKind.PipeName,
      pipeNameExpr: meta.nameExpr,
      renamePosition: {
        fileName: pipeTransformNode.getSourceFile().fileName,
        position: pipeTransformNode.getStart(),
      }
    };
  }
}

/**
 * From the provided `RenameRequest`, determines what text we should expect all produced
 * `ts.RenameLocation`s to have and creates an initial entry for indirect renames (one which is
 * required for the rename operation, but cannot be found by the native TS LS).
 */
function getExpectedRenameTextAndInitalRenameEntries(renameRequest: RenameRequest):
    {expectedRenameText: string, entries: ts.RenameLocation[]}|null {
  let expectedRenameText: string;
  const entries: ts.RenameLocation[] = [];
  if (renameRequest.type === RequestKind.DirectFromTypeScript) {
    expectedRenameText = renameRequest.requestNode.getText();
  } else if (renameRequest.type === RequestKind.DirectFromTemplate) {
    const templateNodeText =
        getRenameTextAndSpanAtPosition(renameRequest.requestNode, renameRequest.templatePosition);
    if (templateNodeText === null) {
      return null;
    }
    expectedRenameText = templateNodeText.text;
  } else if (renameRequest.type === RequestKind.PipeName) {
    const {pipeNameExpr} = renameRequest;
    expectedRenameText = pipeNameExpr.text;
    const entry: ts.RenameLocation = {
      fileName: renameRequest.pipeNameExpr.getSourceFile().fileName,
      textSpan: {start: pipeNameExpr.getStart() + 1, length: pipeNameExpr.getText().length - 2},
    };
    entries.push(entry);
  } else {
    // TODO(atscott): Implement other types of special renames
    return null;
  }

  return {entries, expectedRenameText};
}

/**
 * Given a `RenameRequest`, determines the `FilePosition` to use asking the native TS LS for rename
 * locations.
 */
function getRenameRequestPosition(renameRequest: RenameRequest): FilePosition {
  const fileName = renameRequest.type === RequestKind.DirectFromTypeScript ?
      renameRequest.requestNode.getSourceFile().fileName :
      renameRequest.renamePosition.fileName;
  const position = renameRequest.type === RequestKind.DirectFromTypeScript ?
      renameRequest.requestNode.getStart() :
      renameRequest.renamePosition.position;
  return {fileName, position};
}