/**
 * @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 {resolveForwardRef} from '../di/forward_ref';
import {INJECTOR, Injector} from '../di/injector';
import {setCurrentInjector} from '../di/injector_compatibility';
import {getInjectableDef, ɵɵInjectableDef} from '../di/interface/defs';
import {APP_ROOT} from '../di/scope';
import {NgModuleRef} from '../linker/ng_module_factory';
import {stringify} from '../util/stringify';

import {DepDef, DepFlags, NgModuleData, NgModuleDefinition, NgModuleProviderDef, NodeFlags} from './types';
import {splitDepsDsl, tokenKey} from './util';

const UNDEFINED_VALUE = new Object();

const InjectorRefTokenKey = tokenKey(Injector);
const INJECTORRefTokenKey = tokenKey(INJECTOR);
const NgModuleRefTokenKey = tokenKey(NgModuleRef);

export function moduleProvideDef(
    flags: NodeFlags, token: any, value: any,
    deps: ([DepFlags, any] | any)[]): NgModuleProviderDef {
  // Need to resolve forwardRefs as e.g. for `useValue` we
  // lowered the expression and then stopped evaluating it,
  // i.e. also didn't unwrap it.
  value = resolveForwardRef(value);
  const depDefs = splitDepsDsl(deps, stringify(token));
  return {
    // will bet set by the module definition
    index: -1,
    deps: depDefs, flags, token, value
  };
}

export function moduleDef(providers: NgModuleProviderDef[]): NgModuleDefinition {
  const providersByKey: {[key: string]: NgModuleProviderDef} = {};
  const modules = [];
  let isRoot: boolean = false;
  for (let i = 0; i < providers.length; i++) {
    const provider = providers[i];
    if (provider.token === APP_ROOT && provider.value === true) {
      isRoot = true;
    }
    if (provider.flags & NodeFlags.TypeNgModule) {
      modules.push(provider.token);
    }
    provider.index = i;
    providersByKey[tokenKey(provider.token)] = provider;
  }
  return {
    // Will be filled later...
    factory: null,
    providersByKey,
    providers,
    modules,
    isRoot,
  };
}

export function initNgModule(data: NgModuleData) {
  const def = data._def;
  const providers = data._providers = new Array(def.providers.length);
  for (let i = 0; i < def.providers.length; i++) {
    const provDef = def.providers[i];
    if (!(provDef.flags & NodeFlags.LazyProvider)) {
      // Make sure the provider has not been already initialized outside this loop.
      if (providers[i] === undefined) {
        providers[i] = _createProviderInstance(data, provDef);
      }
    }
  }
}

export function resolveNgModuleDep(
    data: NgModuleData, depDef: DepDef, notFoundValue: any = Injector.THROW_IF_NOT_FOUND): any {
  const former = setCurrentInjector(data);
  try {
    if (depDef.flags & DepFlags.Value) {
      return depDef.token;
    }
    if (depDef.flags & DepFlags.Optional) {
      notFoundValue = null;
    }
    if (depDef.flags & DepFlags.SkipSelf) {
      return data._parent.get(depDef.token, notFoundValue);
    }
    const tokenKey = depDef.tokenKey;
    switch (tokenKey) {
      case InjectorRefTokenKey:
      case INJECTORRefTokenKey:
      case NgModuleRefTokenKey:
        return data;
    }
    const providerDef = data._def.providersByKey[tokenKey];
    let injectableDef: ɵɵInjectableDef<any>|null;
    if (providerDef) {
      let providerInstance = data._providers[providerDef.index];
      if (providerInstance === undefined) {
        providerInstance = data._providers[providerDef.index] =
            _createProviderInstance(data, providerDef);
      }
      return providerInstance === UNDEFINED_VALUE ? undefined : providerInstance;
    } else if (
        (injectableDef = getInjectableDef(depDef.token)) && targetsModule(data, injectableDef)) {
      const index = data._providers.length;
      data._def.providers[index] = data._def.providersByKey[depDef.tokenKey] = {
        flags: NodeFlags.TypeFactoryProvider | NodeFlags.LazyProvider,
        value: injectableDef.factory,
        deps: [], index,
        token: depDef.token,
      };
      data._providers[index] = UNDEFINED_VALUE;
      return (
          data._providers[index] =
              _createProviderInstance(data, data._def.providersByKey[depDef.tokenKey]));
    } else if (depDef.flags & DepFlags.Self) {
      return notFoundValue;
    }
    return data._parent.get(depDef.token, notFoundValue);
  } finally {
    setCurrentInjector(former);
  }
}

function moduleTransitivelyPresent(ngModule: NgModuleData, scope: any): boolean {
  return ngModule._def.modules.indexOf(scope) > -1;
}

function targetsModule(ngModule: NgModuleData, def: ɵɵInjectableDef<any>): boolean {
  return def.providedIn != null && (moduleTransitivelyPresent(ngModule, def.providedIn) ||
                                    def.providedIn === 'root' && ngModule._def.isRoot);
}

function _createProviderInstance(ngModule: NgModuleData, providerDef: NgModuleProviderDef): any {
  let injectable: any;
  switch (providerDef.flags & NodeFlags.Types) {
    case NodeFlags.TypeClassProvider:
      injectable = _createClass(ngModule, providerDef.value, providerDef.deps);
      break;
    case NodeFlags.TypeFactoryProvider:
      injectable = _callFactory(ngModule, providerDef.value, providerDef.deps);
      break;
    case NodeFlags.TypeUseExistingProvider:
      injectable = resolveNgModuleDep(ngModule, providerDef.deps[0]);
      break;
    case NodeFlags.TypeValueProvider:
      injectable = providerDef.value;
      break;
  }

  // The read of `ngOnDestroy` here is slightly expensive as it's megamorphic, so it should be
  // avoided if possible. The sequence of checks here determines whether ngOnDestroy needs to be
  // checked. It might not if the `injectable` isn't an object or if NodeFlags.OnDestroy is already
  // set (ngOnDestroy was detected statically).
  if (injectable !== UNDEFINED_VALUE && injectable !== null && typeof injectable === 'object' &&
      !(providerDef.flags & NodeFlags.OnDestroy) && typeof injectable.ngOnDestroy === 'function') {
    providerDef.flags |= NodeFlags.OnDestroy;
  }
  return injectable === undefined ? UNDEFINED_VALUE : injectable;
}

function _createClass(ngModule: NgModuleData, ctor: any, deps: DepDef[]): any {
  const len = deps.length;
  switch (len) {
    case 0:
      return new ctor();
    case 1:
      return new ctor(resolveNgModuleDep(ngModule, deps[0]));
    case 2:
      return new ctor(resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]));
    case 3:
      return new ctor(
          resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]),
          resolveNgModuleDep(ngModule, deps[2]));
    default:
      const depValues = new Array(len);
      for (let i = 0; i < len; i++) {
        depValues[i] = resolveNgModuleDep(ngModule, deps[i]);
      }
      return new ctor(...depValues);
  }
}

function _callFactory(ngModule: NgModuleData, factory: any, deps: DepDef[]): any {
  const len = deps.length;
  switch (len) {
    case 0:
      return factory();
    case 1:
      return factory(resolveNgModuleDep(ngModule, deps[0]));
    case 2:
      return factory(resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]));
    case 3:
      return factory(
          resolveNgModuleDep(ngModule, deps[0]), resolveNgModuleDep(ngModule, deps[1]),
          resolveNgModuleDep(ngModule, deps[2]));
    default:
      const depValues = Array(len);
      for (let i = 0; i < len; i++) {
        depValues[i] = resolveNgModuleDep(ngModule, deps[i]);
      }
      return factory(...depValues);
  }
}

export function callNgModuleLifecycle(ngModule: NgModuleData, lifecycles: NodeFlags) {
  const def = ngModule._def;
  const destroyed = new Set<any>();
  for (let i = 0; i < def.providers.length; i++) {
    const provDef = def.providers[i];
    if (provDef.flags & NodeFlags.OnDestroy) {
      const instance = ngModule._providers[i];
      if (instance && instance !== UNDEFINED_VALUE) {
        const onDestroy: Function|undefined = instance.ngOnDestroy;
        if (typeof onDestroy === 'function' && !destroyed.has(instance)) {
          onDestroy.apply(instance);
          destroyed.add(instance);
        }
      }
    }
  }
}
