import {
  Rule,
  chain,
  externalSchematic,
  SchematicContext,
  Tree
} from '@angular-devkit/schematics';

import { ReplaceChange } from '@schematics/angular/utility/change';
import { getSourceNodes } from '@schematics/angular/utility/ast-utils';

import * as ts from 'typescript';

import {
  updateJsonInTree,
  readJsonInTree,
  insert
} from '../../src/utils/ast-utils';
import { formatFiles } from '../../src/utils/rules/format-files';

const addExtensionRecommendations = updateJsonInTree(
  '.vscode/extensions.json',
  (json: { recommendations?: string[] }) => {
    json.recommendations = json.recommendations || [];
    [
      'nrwl.angular-console',
      'angular.ng-template',
      'esbenp.prettier-vscode'
    ].forEach(extension => {
      if (!json.recommendations.includes(extension)) {
        json.recommendations.push(extension);
      }
    });

    return json;
  }
);

function addItemToImport(
  path: string,
  sourceFile: ts.SourceFile,
  printer: ts.Printer,
  importStatement: ts.ImportDeclaration,
  symbol: string
) {
  const newImport = ts.createImportDeclaration(
    importStatement.decorators,
    importStatement.modifiers,
    ts.createImportClause(
      importStatement.importClause.name,
      ts.createNamedImports([
        ...(importStatement.importClause.namedBindings as ts.NamedImports)
          .elements,
        ts.createImportSpecifier(undefined, ts.createIdentifier(symbol))
      ])
    ),
    importStatement.moduleSpecifier
  );
  return new ReplaceChange(
    path,
    importStatement.getStart(sourceFile),
    importStatement.getText(sourceFile),
    printer.printNode(ts.EmitHint.Unspecified, newImport, sourceFile)
  );
}

function isEffectDecorator(decorator: ts.Decorator) {
  return (
    ts.isCallExpression(decorator.expression) &&
    ts.isIdentifier(decorator.expression.expression) &&
    decorator.expression.expression.text === 'Effect'
  );
}

function getImport(sourceFile: ts.SourceFile, path: string, symbol: string) {
  return sourceFile.statements
    .filter(ts.isImportDeclaration)
    .filter(statement =>
      statement.moduleSpecifier.getText(sourceFile).includes(path)
    )
    .find(statement => {
      if (!ts.isNamedImports(statement.importClause.namedBindings)) {
        return false;
      }

      return statement.importClause.namedBindings.elements.some(
        element => element.getText(sourceFile) === symbol
      );
    });
}

function updateOfTypeCode(path: string, sourceFile: ts.SourceFile) {
  const effectsImport = getImport(sourceFile, '@ngrx/effects', 'Effect');
  if (!effectsImport) {
    return [];
  }

  const effects: ts.PropertyDeclaration[] = [];
  const changes: ReplaceChange[] = [];

  const printer = ts.createPrinter();

  sourceFile.statements
    .filter(ts.isClassDeclaration)
    .map(clazz =>
      clazz.members
        .filter(ts.isPropertyDeclaration)
        .filter(
          member =>
            member.decorators && member.decorators.some(isEffectDecorator)
        )
    )
    .forEach(properties => {
      effects.push(...properties);
    });

  effects.forEach(effect => {
    if (
      ts.isCallExpression(effect.initializer) &&
      ts.isPropertyAccessExpression(effect.initializer.expression) &&
      effect.initializer.expression.name.text === 'pipe' &&
      ts.isCallExpression(effect.initializer.expression.expression) &&
      ts.isPropertyAccessExpression(
        effect.initializer.expression.expression.expression
      ) &&
      effect.initializer.expression.expression.expression.name.text === 'ofType'
    ) {
      const originalText = effect.initializer.getText(sourceFile);

      const ofTypeExpression = ts.createCall(
        ts.createIdentifier('ofType'),
        effect.initializer.expression.expression.typeArguments,
        effect.initializer.expression.expression.arguments
      );

      const node = ts.createCall(
        ts.createPropertyAccess(
          effect.initializer.expression.expression.expression.expression,
          'pipe'
        ),
        effect.initializer.typeArguments,
        ts.createNodeArray([
          ofTypeExpression,
          ...(effect.initializer as ts.CallExpression).arguments
        ])
      );
      const newEffect = printer.printNode(
        ts.EmitHint.Expression,
        node,
        sourceFile
      );

      const change = new ReplaceChange(
        path,
        effect.initializer.getStart(sourceFile),
        originalText,
        newEffect
      );
      changes.push(change);
    }
  });

  if (changes.length > 0) {
    changes.unshift(
      addItemToImport(path, sourceFile, printer, effectsImport, 'ofType')
    );
  }

  return changes;
}

function getConstructor(
  classDeclaration: ts.ClassDeclaration
): ts.ConstructorDeclaration {
  return classDeclaration.members.find(ts.isConstructorDeclaration);
}

function getStoreProperty(
  sourceFile: ts.SourceFile,
  constructor: ts.ConstructorDeclaration
): string {
  const storeParameter = constructor.parameters.find(
    parameter =>
      parameter.type && parameter.type.getText(sourceFile).includes('Store')
  );
  return storeParameter ? storeParameter.name.getText(sourceFile) : null;
}

function updateSelectorCode(path: string, sourceFile: ts.SourceFile) {
  const storeImport = getImport(sourceFile, '@ngrx/store', 'Store');
  if (!storeImport) {
    return [];
  }
  const changes: ReplaceChange[] = [];

  const printer = ts.createPrinter();

  sourceFile.statements
    .filter(ts.isClassDeclaration)
    .forEach(classDeclaration => {
      const constructor = getConstructor(classDeclaration);
      if (!constructor) {
        return;
      }

      const storeProperty = getStoreProperty(sourceFile, constructor);
      getSourceNodes(sourceFile).forEach(node => {
        if (
          ts.isCallExpression(node) &&
          ts.isPropertyAccessExpression(node.expression) &&
          ts.isPropertyAccessExpression(node.expression.expression) &&
          ts.isIdentifier(node.expression.name) &&
          ts.isIdentifier(node.expression.expression.name) &&
          node.expression.name.getText(sourceFile) === 'select' &&
          node.expression.expression.name.getText(sourceFile) ===
            storeProperty &&
          node.expression.expression.expression.kind ===
            ts.SyntaxKind.ThisKeyword
        ) {
          const newExpression = ts.createCall(
            ts.createPropertyAccess(
              ts.createPropertyAccess(
                ts.createIdentifier('this'),
                ts.createIdentifier(storeProperty)
              ),
              ts.createIdentifier('pipe')
            ),
            [],
            [
              ts.createCall(
                ts.createIdentifier('select'),
                node.typeArguments,
                node.arguments
              )
            ]
          );
          const newNode = printer.printNode(
            ts.EmitHint.Expression,
            newExpression,
            sourceFile
          );
          changes.push(
            new ReplaceChange(
              path,
              node.getStart(sourceFile),
              node.getText(sourceFile),
              newNode
            )
          );
        }
      });
    });

  if (changes.length > 0) {
    changes.unshift(
      addItemToImport(path, sourceFile, printer, storeImport, 'select')
    );
  }

  return changes;
}

function migrateNgrx(host: Tree) {
  const ngrxVersion = readJsonInTree(host, 'package.json').dependencies[
    '@ngrx/store'
  ];
  if (
    !(
      ngrxVersion.startsWith('6.') ||
      ngrxVersion.startsWith('~6.') ||
      ngrxVersion.startsWith('^6.')
    )
  ) {
    return host;
  }

  host.visit(path => {
    if (!path.endsWith('.ts')) {
      return;
    }

    let sourceFile = ts.createSourceFile(
      path,
      host.read(path).toString(),
      ts.ScriptTarget.Latest
    );

    if (sourceFile.isDeclarationFile) {
      return;
    }

    insert(host, path, updateOfTypeCode(path, sourceFile));

    sourceFile = ts.createSourceFile(
      path,
      host.read(path).toString(),
      ts.ScriptTarget.Latest
    );

    insert(host, path, updateSelectorCode(path, sourceFile));

    sourceFile = ts.createSourceFile(
      path,
      host.read(path).toString(),
      ts.ScriptTarget.Latest
    );

    insert(host, path, cleanUpDoublePipes(path, sourceFile));
  });
}

function cleanUpDoublePipes(
  path: string,
  sourceFile: ts.SourceFile
): ReplaceChange[] {
  const changes: ReplaceChange[] = [];

  const printer = ts.createPrinter();

  getSourceNodes(sourceFile).forEach(node => {
    if (
      ts.isCallExpression(node) &&
      ts.isPropertyAccessExpression(node.expression) &&
      ts.isCallExpression(node.expression.expression) &&
      ts.isPropertyAccessExpression(node.expression.expression.expression) &&
      node.expression.name.text === 'pipe' &&
      node.expression.expression.expression.name.text === 'pipe'
    ) {
      const singlePipe = ts.createCall(
        node.expression.expression.expression,
        node.typeArguments,
        [...node.expression.expression.arguments, ...node.arguments]
      );
      changes.push(
        new ReplaceChange(
          path,
          node.getStart(sourceFile),
          node.getText(sourceFile),
          printer.printNode(ts.EmitHint.Expression, singlePipe, sourceFile)
        )
      );
    }
  });

  return changes;
}

const updateNgrx = updateJsonInTree('package.json', json => {
  json.devDependencies = json.devDependencies || {};
  json.dependencies = json.dependencies || {};

  json.dependencies = {
    ...json.dependencies,
    '@ngrx/effects': '7.2.0',
    '@ngrx/router-store': '7.2.0',
    '@ngrx/store': '7.2.0'
  };

  json.devDependencies = {
    ...json.devDependencies,
    '@ngrx/schematics': '7.2.0',
    '@ngrx/store-devtools': '7.2.0'
  };
  return json;
});

const addDotEnv = updateJsonInTree('package.json', json => {
  json.devDependencies = json.devDependencies || {};
  json.devDependencies = {
    ...json.devDependencies,
    dotenv: '6.2.0'
  };
  return json;
});

const setDefaults = updateJsonInTree('angular.json', json => {
  if (!json.schematics) {
    json.schematics = {};
  }
  if (!json.schematics['@nrwl/schematics:library']) {
    json.schematics['@nrwl/schematics:library'] = {};
  }
  if (!json.schematics['@nrwl/schematics:library'].unitTestRunner) {
    json.schematics['@nrwl/schematics:library'].unitTestRunner = 'karma';
  }
  if (!json.schematics['@nrwl/schematics:application']) {
    json.schematics['@nrwl/schematics:application'] = {};
  }
  if (!json.schematics['@nrwl/schematics:application'].unitTestRunner) {
    json.schematics['@nrwl/schematics:application'].unitTestRunner = 'karma';
  }
  if (!json.schematics['@nrwl/schematics:application'].e2eTestRunner) {
    json.schematics['@nrwl/schematics:application'].e2eTestRunner =
      'protractor';
  }
  if (!json.schematics['@nrwl/schematics:node-application']) {
    json.schematics['@nrwl/schematics:node-application'] = {};
  }
  if (!json.schematics['@nrwl/schematics:node-application'].framework) {
    json.schematics['@nrwl/schematics:node-application'].framework = 'express';
  }
  return json;
});

const updateAngularCLI = chain([
  externalSchematic('@schematics/update', 'update', {
    packages: ['@angular/cli'],
    from: '7.2.2',
    to: '7.3.1',
    force: true
  }),
  updateJsonInTree('package.json', json => {
    json.devDependencies = json.devDependencies || {};
    json.devDependencies = {
      ...json.devDependencies,
      '@angular/cli': '7.3.1',
      '@angular-devkit/build-angular': '~0.13.1'
    };
    return json;
  })
]);

export default function(): Rule {
  return chain([
    addExtensionRecommendations,
    addDotEnv,
    updateAngularCLI,
    migrateNgrx,
    updateNgrx,
    setDefaults,
    formatFiles()
  ]);
}
