/**
 * @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, AstPath, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, CompileDirectiveSummary, CompileTypeMetadata, DirectiveAst, ElementAst, EmbeddedTemplateAst, identifierName, ParseSourceSpan, RecursiveTemplateAstVisitor, ReferenceAst, TemplateAst, TemplateAstPath, templateVisitAll, tokenReference, VariableAst} from '@angular/compiler';

import {createDiagnostic, Diagnostic} from './diagnostic_messages';
import {AstType} from './expression_type';
import {BuiltinType, Definition, Span, Symbol, SymbolDeclaration, SymbolQuery, SymbolTable} from './symbols';
import * as ng from './types';
import {findOutputBinding, getPathToNodeAtPosition} from './utils';

export function getTemplateExpressionDiagnostics(info: ng.DiagnosticTemplateInfo): ng.Diagnostic[] {
  const visitor = new ExpressionDiagnosticsVisitor(
      info, (path: TemplateAstPath) => getExpressionScope(info, path));
  templateVisitAll(visitor, info.templateAst);
  return visitor.diagnostics;
}

function getReferences(info: ng.DiagnosticTemplateInfo): SymbolDeclaration[] {
  const result: SymbolDeclaration[] = [];

  function processReferences(references: ReferenceAst[]) {
    for (const reference of references) {
      let type: Symbol|undefined = undefined;
      if (reference.value) {
        type = info.query.getTypeSymbol(tokenReference(reference.value));
      }
      result.push({
        name: reference.name,
        kind: 'reference',
        type: type || info.query.getBuiltinType(BuiltinType.Any),
        get definition() {
          return getDefinitionOf(info, reference);
        }
      });
    }
  }

  const visitor = new class extends RecursiveTemplateAstVisitor {
    visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any {
      super.visitEmbeddedTemplate(ast, context);
      processReferences(ast.references);
    }
    visitElement(ast: ElementAst, context: any): any {
      super.visitElement(ast, context);
      processReferences(ast.references);
    }
  };

  templateVisitAll(visitor, info.templateAst);

  return result;
}

function getDefinitionOf(info: ng.DiagnosticTemplateInfo, ast: TemplateAst): Definition|undefined {
  if (info.fileName) {
    const templateOffset = info.offset;
    return [{
      fileName: info.fileName,
      span: {
        start: ast.sourceSpan.start.offset + templateOffset,
        end: ast.sourceSpan.end.offset + templateOffset
      }
    }];
  }
}

/**
 * Resolve all variable declarations in a template by traversing the specified
 * `path`.
 * @param info
 * @param path template AST path
 */
function getVarDeclarations(
    info: ng.DiagnosticTemplateInfo, path: TemplateAstPath): SymbolDeclaration[] {
  const results: SymbolDeclaration[] = [];
  for (let current = path.head; current; current = path.childOf(current)) {
    if (!(current instanceof EmbeddedTemplateAst)) {
      continue;
    }
    for (const variable of current.variables) {
      let symbol = getVariableTypeFromDirectiveContext(variable.value, info.query, current);

      const kind = info.query.getTypeKind(symbol);
      if (kind === BuiltinType.Any || kind === BuiltinType.Unbound) {
        // For special cases such as ngFor and ngIf, the any type is not very useful.
        // We can do better by resolving the binding value.
        const symbolsInScope = info.query.mergeSymbolTable([
          info.members,
          // Since we are traversing the AST path from head to tail, any variables
          // that have been declared so far are also in scope.
          info.query.createSymbolTable(results),
        ]);
        symbol = refinedVariableType(variable.value, symbolsInScope, info, current);
      }
      results.push({
        name: variable.name,
        kind: 'variable',
        type: symbol,
        get definition() {
          return getDefinitionOf(info, variable);
        },
      });
    }
  }
  return results;
}

/**
 * Resolve the type for the variable in `templateElement` by finding the structural
 * directive which has the context member. Returns any when not found.
 * @param value variable value name
 * @param query type symbol query
 * @param templateElement
 */
function getVariableTypeFromDirectiveContext(
    value: string, query: SymbolQuery, templateElement: EmbeddedTemplateAst): Symbol {
  for (const {directive} of templateElement.directives) {
    const context = query.getTemplateContext(directive.type.reference);
    if (context) {
      const member = context.get(value);
      if (member && member.type) {
        return member.type;
      }
    }
  }

  return query.getBuiltinType(BuiltinType.Any);
}

/**
 * Resolve a more specific type for the variable in `templateElement` by inspecting
 * all variables that are in scope in the `mergedTable`. This function is a special
 * case for `ngFor` and `ngIf`. If resolution fails, return the `any` type.
 * @param value variable value name
 * @param mergedTable symbol table for all variables in scope
 * @param info available template information
 * @param templateElement
 */
function refinedVariableType(
    value: string, mergedTable: SymbolTable, info: ng.DiagnosticTemplateInfo,
    templateElement: EmbeddedTemplateAst): Symbol {
  if (value === '$implicit') {
    // Special case: ngFor directive
    const ngForDirective = templateElement.directives.find(d => {
      const name = identifierName(d.directive.type);
      return name == 'NgFor' || name == 'NgForOf';
    });
    if (ngForDirective) {
      const ngForOfBinding = ngForDirective.inputs.find(i => i.directiveName == 'ngForOf');
      if (ngForOfBinding) {
        // Check if there is a known type for the ngFor binding.
        const bindingType =
            new AstType(mergedTable, info.query, {}, info.source).getType(ngForOfBinding.value);
        if (bindingType) {
          const result = info.query.getElementType(bindingType);
          if (result) {
            return result;
          }
        }
      }
    }
  }

  if (value === 'ngIf' || value === '$implicit') {
    const ngIfDirective =
        templateElement.directives.find(d => identifierName(d.directive.type) === 'NgIf');
    if (ngIfDirective) {
      // Special case: ngIf directive. The NgIf structural directive owns a template context with
      // "$implicit" and "ngIf" members. These properties are typed as generics. Until the language
      // service uses an Ivy and TypecheckBlock backend, we cannot bind these values to a concrete
      // type without manual inference. To get the concrete type, look up the type of the "ngIf"
      // import on the NgIf directive bound to the template.
      //
      // See @angular/common/ng_if.ts for more information.
      const ngIfBinding = ngIfDirective.inputs.find(i => i.directiveName === 'ngIf');
      if (ngIfBinding) {
        // Check if there is a known type bound to the ngIf input.
        const bindingType =
            new AstType(mergedTable, info.query, {}, info.source).getType(ngIfBinding.value);
        if (bindingType) {
          return bindingType;
        }
      }
    }
  }

  // We can't do better, return any
  return info.query.getBuiltinType(BuiltinType.Any);
}

function getEventDeclaration(
    info: ng.DiagnosticTemplateInfo, path: TemplateAstPath): SymbolDeclaration|undefined {
  const event = path.tail;
  if (!(event instanceof BoundEventAst)) {
    // No event available in this context.
    return;
  }

  const genericEvent: SymbolDeclaration = {
    name: '$event',
    kind: 'variable',
    type: info.query.getBuiltinType(BuiltinType.Any),
  };

  const outputSymbol = findOutputBinding(event, path, info.query);
  if (!outputSymbol) {
    // The `$event` variable doesn't belong to an output, so its type can't be refined.
    // TODO: type `$event` variables in bindings to DOM events.
    return genericEvent;
  }

  // The raw event type is wrapped in a generic, like EventEmitter<T> or Observable<T>.
  const ta = outputSymbol.typeArguments();
  if (!ta || ta.length !== 1) return genericEvent;
  const eventType = ta[0];

  return {...genericEvent, type: eventType};
}

/**
 * Returns the symbols available in a particular scope of a template.
 * @param info parsed template information
 * @param path path of template nodes narrowing to the context the expression scope should be
 * derived for.
 */
export function getExpressionScope(
    info: ng.DiagnosticTemplateInfo, path: TemplateAstPath): SymbolTable {
  let result = info.members;
  const references = getReferences(info);
  const variables = getVarDeclarations(info, path);
  const event = getEventDeclaration(info, path);
  if (references.length || variables.length || event) {
    const referenceTable = info.query.createSymbolTable(references);
    const variableTable = info.query.createSymbolTable(variables);
    const eventsTable = info.query.createSymbolTable(event ? [event] : []);
    result = info.query.mergeSymbolTable([result, referenceTable, variableTable, eventsTable]);
  }
  return result;
}

class ExpressionDiagnosticsVisitor extends RecursiveTemplateAstVisitor {
  private path: TemplateAstPath;
  private directiveSummary: CompileDirectiveSummary|undefined;

  diagnostics: ng.Diagnostic[] = [];

  constructor(
      private info: ng.DiagnosticTemplateInfo,
      private getExpressionScope: (path: TemplateAstPath, includeEvent: boolean) => SymbolTable) {
    super();
    this.path = new AstPath<TemplateAst>([]);
  }

  visitDirective(ast: DirectiveAst, context: any): any {
    // Override the default child visitor to ignore the host properties of a directive.
    if (ast.inputs && ast.inputs.length) {
      templateVisitAll(this, ast.inputs, context);
    }
  }

  visitBoundText(ast: BoundTextAst): void {
    this.push(ast);
    this.diagnoseExpression(ast.value, ast.sourceSpan.start.offset, false);
    this.pop();
  }

  visitDirectiveProperty(ast: BoundDirectivePropertyAst): void {
    this.push(ast);
    this.diagnoseExpression(ast.value, this.attributeValueLocation(ast), false);
    this.pop();
  }

  visitElementProperty(ast: BoundElementPropertyAst): void {
    this.push(ast);
    this.diagnoseExpression(ast.value, this.attributeValueLocation(ast), false);
    this.pop();
  }

  visitEvent(ast: BoundEventAst): void {
    this.push(ast);
    this.diagnoseExpression(ast.handler, this.attributeValueLocation(ast), true);
    this.pop();
  }

  visitVariable(ast: VariableAst): void {
    const directive = this.directiveSummary;
    if (directive && ast.value) {
      const context = this.info.query.getTemplateContext(directive.type.reference)!;
      if (context && !context.has(ast.value)) {
        const missingMember =
            ast.value === '$implicit' ? 'an implicit value' : `a member called '${ast.value}'`;

        const span = this.absSpan(spanOf(ast.sourceSpan));
        this.diagnostics.push(createDiagnostic(
            span, Diagnostic.template_context_missing_member, directive.type.reference.name,
            missingMember));
      }
    }
  }

  visitElement(ast: ElementAst, context: any): void {
    this.push(ast);
    super.visitElement(ast, context);
    this.pop();
  }

  visitEmbeddedTemplate(ast: EmbeddedTemplateAst, context: any): any {
    const previousDirectiveSummary = this.directiveSummary;

    this.push(ast);

    // Find directive that references this template
    this.directiveSummary =
        ast.directives.map(d => d.directive).find(d => hasTemplateReference(d.type))!;

    // Process children
    super.visitEmbeddedTemplate(ast, context);

    this.pop();

    this.directiveSummary = previousDirectiveSummary;
  }

  private attributeValueLocation(ast: TemplateAst) {
    const path = getPathToNodeAtPosition(this.info.htmlAst, ast.sourceSpan.start.offset);
    const last = path.tail;
    if (last instanceof Attribute && last.valueSpan) {
      return last.valueSpan.start.offset;
    }
    return ast.sourceSpan.start.offset;
  }

  private diagnoseExpression(ast: AST, offset: number, inEvent: boolean) {
    const scope = this.getExpressionScope(this.path, inEvent);
    const analyzer = new AstType(scope, this.info.query, {inEvent}, this.info.source);
    for (const diagnostic of analyzer.getDiagnostics(ast)) {
      diagnostic.span = this.absSpan(diagnostic.span, offset);
      this.diagnostics.push(diagnostic);
    }
  }

  private push(ast: TemplateAst) {
    this.path.push(ast);
  }

  private pop() {
    this.path.pop();
  }

  private absSpan(span: Span, additionalOffset: number = 0): Span {
    return {
      start: span.start + this.info.offset + additionalOffset,
      end: span.end + this.info.offset + additionalOffset,
    };
  }
}

function hasTemplateReference(type: CompileTypeMetadata): boolean {
  if (type.diDeps) {
    for (let diDep of type.diDeps) {
      if (diDep.token && diDep.token.identifier &&
          identifierName(diDep.token!.identifier!) == 'TemplateRef')
        return true;
    }
  }
  return false;
}

function spanOf(sourceSpan: ParseSourceSpan): Span {
  return {start: sourceSpan.start.offset, end: sourceSpan.end.offset};
}
