/**
 * @license
 * Copyright Google Inc. 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 * as ts from 'typescript';
import { addPureComment } from '../helpers/ast-utils';

function isBlockLike(node: ts.Node): node is ts.BlockLike {
  return node.kind === ts.SyntaxKind.Block
      || node.kind === ts.SyntaxKind.ModuleBlock
      || node.kind === ts.SyntaxKind.CaseClause
      || node.kind === ts.SyntaxKind.DefaultClause
      || node.kind === ts.SyntaxKind.SourceFile;
}

export function getWrapEnumsTransformer(): ts.TransformerFactory<ts.SourceFile> {
  return (context: ts.TransformationContext): ts.Transformer<ts.SourceFile> => {
    const transformer: ts.Transformer<ts.SourceFile> = sf => {
      const result = visitBlockStatements(sf.statements, context);

      return ts.updateSourceFileNode(sf, ts.setTextRange(result, sf.statements));
    };

    return transformer;
  };
}

function visitBlockStatements(
  statements: ts.NodeArray<ts.Statement>,
  context: ts.TransformationContext,
): ts.NodeArray<ts.Statement> {

  // copy of statements to modify; lazy initialized
  let updatedStatements: Array<ts.Statement> | undefined;

  const visitor: ts.Visitor = (node) => {
    if (isBlockLike(node)) {
      let result = visitBlockStatements(node.statements, context);
      if (result === node.statements) {
        return node;
      }
      result = ts.setTextRange(result, node.statements);
      switch (node.kind) {
        case ts.SyntaxKind.Block:
          return ts.updateBlock(node, result);
        case ts.SyntaxKind.ModuleBlock:
          return ts.updateModuleBlock(node, result);
        case ts.SyntaxKind.CaseClause:
          return ts.updateCaseClause(node, node.expression, result);
        case ts.SyntaxKind.DefaultClause:
          return ts.updateDefaultClause(node, result);
        default:
          return node;
      }
    } else {
      return ts.visitEachChild(node, visitor, context);
    }
  };

  // 'oIndex' is the original statement index; 'uIndex' is the updated statement index
  for (let oIndex = 0, uIndex = 0; oIndex < statements.length; oIndex++, uIndex++) {
    const currentStatement = statements[oIndex];

    // these can't contain an enum declaration
    if (currentStatement.kind === ts.SyntaxKind.ImportDeclaration) {
      continue;
    }

    // enum declarations must:
    //   * not be last statement
    //   * be a variable statement
    //   * have only one declaration
    //   * have an identifer as a declaration name
    if (oIndex < statements.length - 1
        && ts.isVariableStatement(currentStatement)
        && currentStatement.declarationList.declarations.length === 1) {

      const variableDeclaration = currentStatement.declarationList.declarations[0];
      if (ts.isIdentifier(variableDeclaration.name)) {
        const name = variableDeclaration.name.text;

        if (!variableDeclaration.initializer) {
          const iife = findTs2_3EnumIife(name, statements[oIndex + 1]);
          if (iife) {
            // found an enum
            if (!updatedStatements) {
              updatedStatements = statements.slice();
            }
            // update IIFE and replace variable statement and old IIFE
            updatedStatements.splice(uIndex, 2, updateEnumIife(
              currentStatement,
              iife[0],
              iife[1],
            ));
            // skip IIFE statement
            oIndex++;
            continue;
          }
        } else if (ts.isObjectLiteralExpression(variableDeclaration.initializer)
                   && variableDeclaration.initializer.properties.length === 0) {
          const enumStatements = findTs2_2EnumStatements(name, statements, oIndex + 1);
          if (enumStatements.length > 0) {
            // found an enum
            if (!updatedStatements) {
              updatedStatements = statements.slice();
            }
            // create wrapper and replace variable statement and enum member statements
            updatedStatements.splice(uIndex, enumStatements.length + 1, createWrappedEnum(
              name,
              currentStatement,
              enumStatements,
              variableDeclaration.initializer,
            ));
            // skip enum member declarations
            oIndex += enumStatements.length;
            continue;
          }
        } else if (ts.isObjectLiteralExpression(variableDeclaration.initializer)
          && variableDeclaration.initializer.properties.length !== 0) {
          const literalPropertyCount = variableDeclaration.initializer.properties.length;

          // tsickle es2015 enums first statement is an export declaration
          const isPotentialEnumExport = ts.isExportDeclaration(statements[oIndex + 1]);
          if (isPotentialEnumExport) {
            // skip the export
            oIndex ++;
          }

          const enumStatements = findEnumNameStatements(name, statements, oIndex + 1);
          if (enumStatements.length === literalPropertyCount) {
            // found an enum
            if (!updatedStatements) {
              updatedStatements = statements.slice();
            }
            // create wrapper and replace variable statement and enum member statements
            const deleteCount = enumStatements.length + (isPotentialEnumExport ? 2 : 1);
            updatedStatements.splice(uIndex, deleteCount, createWrappedEnum(
              name,
              currentStatement,
              enumStatements,
              variableDeclaration.initializer,
              isPotentialEnumExport,
            ));
            // skip enum member declarations
            oIndex += enumStatements.length;
            continue;
          }
        }
      }
    }

    const result = ts.visitNode(currentStatement, visitor);
    if (result !== currentStatement) {
      if (!updatedStatements) {
        updatedStatements = statements.slice();
      }
      updatedStatements[uIndex] = result;
    }
  }

  // if changes, return updated statements
  // otherwise, return original array instance
  return updatedStatements ? ts.createNodeArray(updatedStatements) : statements;
}

// TS 2.3 enums have statements that are inside a IIFE.
function findTs2_3EnumIife(
  name: string,
  statement: ts.Statement,
): [ts.CallExpression, ts.Expression | undefined] | null {
  if (!ts.isExpressionStatement(statement)) {
    return null;
  }

  let expression = statement.expression;
  while (ts.isParenthesizedExpression(expression)) {
    expression = expression.expression;
  }

  if (!expression || !ts.isCallExpression(expression) || expression.arguments.length !== 1) {
    return null;
  }

  const callExpression = expression;
  let exportExpression;

  let argument = expression.arguments[0];
  if (!ts.isBinaryExpression(argument)) {
    return null;
  }

  if (!ts.isIdentifier(argument.left) || argument.left.text !== name) {
    return null;
  }

  let potentialExport = false;
  if (argument.operatorToken.kind === ts.SyntaxKind.FirstAssignment) {
    if (!ts.isBinaryExpression(argument.right)
        || argument.right.operatorToken.kind !== ts.SyntaxKind.BarBarToken) {
      return null;
    }

    potentialExport = true;
    argument = argument.right;
  }

  if (!ts.isBinaryExpression(argument)) {
    return null;
  }

  if (argument.operatorToken.kind !== ts.SyntaxKind.BarBarToken) {
    return null;
  }

  if (potentialExport && !ts.isIdentifier(argument.left)) {
    exportExpression = argument.left;
  }

  expression = expression.expression;
  while (ts.isParenthesizedExpression(expression)) {
    expression = expression.expression;
  }

  if (!expression || !ts.isFunctionExpression(expression) || expression.parameters.length !== 1) {
    return null;
  }

  const parameter = expression.parameters[0];
  if (!ts.isIdentifier(parameter.name)) {
    return null;
  }

  // The name of the parameter can be different than the name of the enum if it was renamed
  // due to scope hoisting.
  const parameterName = parameter.name.text;

  // In TS 2.3 enums, the IIFE contains only expressions with a certain format.
  // If we find any that is different, we ignore the whole thing.
  for (let bodyIndex = 0; bodyIndex < expression.body.statements.length; ++bodyIndex) {
    const bodyStatement = expression.body.statements[bodyIndex];

    if (!ts.isExpressionStatement(bodyStatement) || !bodyStatement.expression) {
      return null;
    }

    if (!ts.isBinaryExpression(bodyStatement.expression)
        || bodyStatement.expression.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) {
      return null;
    }

    const assignment = bodyStatement.expression.left;
    const value = bodyStatement.expression.right;
    if (!ts.isElementAccessExpression(assignment) || !ts.isStringLiteral(value)) {
      return null;
    }

    if (!ts.isIdentifier(assignment.expression) || assignment.expression.text !== parameterName) {
      return null;
    }

    const memberArgument = assignment.argumentExpression;
    if (!memberArgument || !ts.isBinaryExpression(memberArgument)
        || memberArgument.operatorToken.kind !== ts.SyntaxKind.FirstAssignment) {
      return null;
    }


    if (!ts.isElementAccessExpression(memberArgument.left)) {
      return null;
    }

    if (!ts.isIdentifier(memberArgument.left.expression)
      || memberArgument.left.expression.text !== parameterName) {
      return null;
    }

    if (!memberArgument.left.argumentExpression
        || !ts.isStringLiteral(memberArgument.left.argumentExpression)) {
      return null;
    }

    if (memberArgument.left.argumentExpression.text !== value.text) {
      return null;
    }
  }

  return [callExpression, exportExpression];
}

// TS 2.2 enums have statements after the variable declaration, with index statements followed
// by value statements.
function findTs2_2EnumStatements(
  name: string,
  statements: ts.NodeArray<ts.Statement>,
  statementOffset: number,
): ts.Statement[] {
  const enumValueStatements: ts.Statement[] = [];
  const memberNames: string[] = [];

  let index = statementOffset;
  for (; index < statements.length; ++index) {
    // Ensure all statements are of the expected format and using the right identifer.
    // When we find a statement that isn't part of the enum, return what we collected so far.
    const current = statements[index];
    if (!ts.isExpressionStatement(current) || !ts.isBinaryExpression(current.expression)) {
      break;
    }

    const property = current.expression.left;
    if (!property || !ts.isPropertyAccessExpression(property)) {
      break;
    }

    if (!ts.isIdentifier(property.expression) || property.expression.text !== name) {
      break;
    }

    memberNames.push(property.name.text);
    enumValueStatements.push(current);
  }

  if (enumValueStatements.length === 0) {
    return [];
  }

  const enumNameStatements = findEnumNameStatements(name, statements, index, memberNames);
  if (enumNameStatements.length !== enumValueStatements.length) {
    return [];
  }

  return enumValueStatements.concat(enumNameStatements);
}

// Tsickle enums have a variable statement with indexes, followed by value statements.
// See https://github.com/angular/devkit/issues/229#issuecomment-338512056 fore more information.
function findEnumNameStatements(
  name: string,
  statements: ts.NodeArray<ts.Statement>,
  statementOffset: number,
  memberNames?: string[],
): ts.Statement[] {
  const enumStatements: ts.Statement[] = [];

  for (let index = statementOffset; index < statements.length; ++index) {
    // Ensure all statements are of the expected format and using the right identifer.
    // When we find a statement that isn't part of the enum, return what we collected so far.
    const current = statements[index];
    if (!ts.isExpressionStatement(current) || !ts.isBinaryExpression(current.expression)) {
      break;
    }

    const access = current.expression.left;
    const value = current.expression.right;
    if (!access || !ts.isElementAccessExpression(access) || !value || !ts.isStringLiteral(value)) {
      break;
    }

    if (memberNames && !memberNames.includes(value.text)) {
      break;
    }

    if (!ts.isIdentifier(access.expression) || access.expression.text !== name) {
      break;
    }

    if (!access.argumentExpression || !ts.isPropertyAccessExpression(access.argumentExpression)) {
      break;
    }

    const enumExpression = access.argumentExpression.expression;
    if (!ts.isIdentifier(enumExpression) || enumExpression.text !== name) {
      break;
    }

    if (value.text !== access.argumentExpression.name.text) {
      break;
    }

    enumStatements.push(current);
  }

  return enumStatements;
}

function updateHostNode(
  hostNode: ts.VariableStatement,
  expression: ts.Expression,
): ts.Statement {

  // Update existing host node with the pure comment before the variable declaration initializer.
  const variableDeclaration = hostNode.declarationList.declarations[0];
  const outerVarStmt = ts.updateVariableStatement(
    hostNode,
    hostNode.modifiers,
    ts.updateVariableDeclarationList(
      hostNode.declarationList,
      [
        ts.updateVariableDeclaration(
          variableDeclaration,
          variableDeclaration.name,
          variableDeclaration.type,
          expression,
        ),
      ],
    ),
  );

  return outerVarStmt;
}

function updateEnumIife(
  hostNode: ts.VariableStatement,
  iife: ts.CallExpression,
  exportAssignment?: ts.Expression,
): ts.Statement {
  if (!ts.isParenthesizedExpression(iife.expression)
      || !ts.isFunctionExpression(iife.expression.expression)) {
    throw new Error('Invalid IIFE Structure');
  }

  // Ignore export assignment if variable is directly exported
  if (hostNode.modifiers
      && hostNode.modifiers.findIndex(m => m.kind == ts.SyntaxKind.ExportKeyword) != -1) {
    exportAssignment = undefined;
  }

  const expression = iife.expression.expression;
  const updatedFunction = ts.updateFunctionExpression(
    expression,
    expression.modifiers,
    expression.asteriskToken,
    expression.name,
    expression.typeParameters,
    expression.parameters,
    expression.type,
    ts.updateBlock(
      expression.body,
      [
        ...expression.body.statements,
        ts.createReturn(expression.parameters[0].name as ts.Identifier),
      ],
    ),
  );

  let arg: ts.Expression = ts.createObjectLiteral();
  if (exportAssignment) {
    arg = ts.createBinary(exportAssignment, ts.SyntaxKind.BarBarToken, arg);
  }
  const updatedIife = ts.updateCall(
    iife,
    ts.updateParen(
      iife.expression,
      updatedFunction,
    ),
    iife.typeArguments,
    [arg],
  );

  let value: ts.Expression = addPureComment(updatedIife);
  if (exportAssignment) {
    value = ts.createBinary(
      exportAssignment,
      ts.SyntaxKind.FirstAssignment,
      updatedIife);
  }

  return updateHostNode(hostNode, value);
}

function createWrappedEnum(
  name: string,
  hostNode: ts.VariableStatement,
  statements: Array<ts.Statement>,
  literalInitializer: ts.ObjectLiteralExpression | undefined,
  addExportModifier = false,
): ts.Statement {
  literalInitializer = literalInitializer || ts.createObjectLiteral();

  const node = addExportModifier
    ? ts.updateVariableStatement(
      hostNode,
      [ts.createToken(ts.SyntaxKind.ExportKeyword)],
      hostNode.declarationList,
    )
    : hostNode;

  const innerVarStmt = ts.createVariableStatement(
    undefined,
    ts.createVariableDeclarationList([
      ts.createVariableDeclaration(name, undefined, literalInitializer),
    ]),
  );

  const innerReturn = ts.createReturn(ts.createIdentifier(name));

  const iife = ts.createImmediatelyInvokedFunctionExpression([
    innerVarStmt,
    ...statements,
    innerReturn,
  ]);

  return updateHostNode(node, addPureComment(ts.createParen(iife)));
}
