import * as LogManager from 'aurelia-logging';
import {Origin, metadata} from 'aurelia-metadata';
import {Loader, LoaderPlugin, TemplateRegistryEntry} from 'aurelia-loader';
import {Container} from 'aurelia-dependency-injection';
import {ViewCompiler} from './view-compiler';
import {ViewResources} from './view-resources';
import {ModuleAnalyzer, ResourceDescription} from './module-analyzer';
import {ViewFactory} from './view-factory';
import {ResourceLoadContext, ViewCompileInstruction, ViewCreateInstruction} from './instructions';
import {SlotCustomAttribute} from './shadow-dom';
import {HtmlBehaviorResource} from './html-behavior';
import {relativeToFile} from 'aurelia-path';
import { View } from './view';

let logger = LogManager.getLogger('templating');

function ensureRegistryEntry(loader, urlOrRegistryEntry) {
  if (urlOrRegistryEntry instanceof TemplateRegistryEntry) {
    return Promise.resolve(urlOrRegistryEntry);
  }

  return loader.loadTemplate(urlOrRegistryEntry);
}

class ProxyViewFactory {
  viewFactory: any;
  constructor(promise) {
    promise.then(x => this.viewFactory = x);
  }

  create(container: Container, bindingContext?: Object, createInstruction?: ViewCreateInstruction, element?: Element): View {
    return this.viewFactory.create(container, bindingContext, createInstruction, element);
  }

  get isCaching() {
    return this.viewFactory.isCaching;
  }

  setCacheSize(size: number | string, doNotOverrideIfAlreadySet: boolean): void {
    this.viewFactory.setCacheSize(size, doNotOverrideIfAlreadySet);
  }

  getCachedView(): View {
    return this.viewFactory.getCachedView();
  }

  returnViewToCache(view: View): void {
    this.viewFactory.returnViewToCache(view);
  }
}

let auSlotBehavior = null;

/**
* Controls the view resource loading pipeline.
*/
export class ViewEngine {

  /** @internal */
  static inject() {
    return [Loader, Container, ViewCompiler, ModuleAnalyzer, ViewResources];
  }

  /**
  * The metadata key for storing requires declared in a ViewModel.
  */
  static viewModelRequireMetadataKey = 'aurelia:view-model-require';

  /** @internal */
  loader: Loader;

  /** @internal */
  container: Container;

  /** @internal */
  viewCompiler: ViewCompiler;

  /** @internal */
  moduleAnalyzer: ModuleAnalyzer;

  /** @internal */
  appResources: ViewResources;

  /** @internal */
  private _pluginMap: {};

  /**
  * Creates an instance of ViewEngine.
  * @param loader The module loader.
  * @param container The root DI container for the app.
  * @param viewCompiler The view compiler.
  * @param moduleAnalyzer The module analyzer.
  * @param appResources The app-level global resources.
  */
  constructor(loader: Loader, container: Container, viewCompiler: ViewCompiler, moduleAnalyzer: ModuleAnalyzer, appResources: ViewResources) {
    this.loader = loader;
    this.container = container;
    this.viewCompiler = viewCompiler;
    this.moduleAnalyzer = moduleAnalyzer;
    this.appResources = appResources;
    this._pluginMap = {};

    if (auSlotBehavior === null) {
      auSlotBehavior  = new HtmlBehaviorResource();
      auSlotBehavior.attributeName = 'au-slot';
      metadata.define(metadata.resource, auSlotBehavior, SlotCustomAttribute);
    }

    auSlotBehavior.initialize(container, SlotCustomAttribute);
    auSlotBehavior.register(appResources);
  }

  /**
  * Adds a resource plugin to the resource loading pipeline.
  * @param extension The file extension to match in require elements.
  * @param implementation The plugin implementation that handles the resource type.
  */
  addResourcePlugin(extension: string, implementation: Object): void {
    let name = extension.replace('.', '') + '-resource-plugin';
    this._pluginMap[extension] = name;
    this.loader.addPlugin(name, implementation as LoaderPlugin);
  }

  /**
  * Loads and compiles a ViewFactory from a url or template registry entry.
  * @param urlOrRegistryEntry A url or template registry entry to generate the view factory for.
  * @param compileInstruction Instructions detailing how the factory should be compiled.
  * @param loadContext The load context if this factory load is happening within the context of a larger load operation.
  * @param target A class from which to extract metadata of additional resources to load.
  * @return A promise for the compiled view factory.
  */
  loadViewFactory(urlOrRegistryEntry: string|TemplateRegistryEntry, compileInstruction?: ViewCompileInstruction, loadContext?: ResourceLoadContext, target?: any): Promise<ViewFactory> {
    loadContext = loadContext || new ResourceLoadContext();

    return ensureRegistryEntry(this.loader, urlOrRegistryEntry).then(registryEntry => {
      const url = registryEntry.address;

      if (registryEntry.onReady) {
        if (!loadContext.hasDependency(url)) {
          loadContext.addDependency(url);
          return registryEntry.onReady;
        }

        if (registryEntry.template === null) {
          // handle NoViewStrategy:
          return registryEntry.onReady;
        }

        return Promise.resolve(new ProxyViewFactory(registryEntry.onReady));
      }

      loadContext.addDependency(url);

      registryEntry.onReady = this.loadTemplateResources(registryEntry, compileInstruction, loadContext, target).then(resources => {
        registryEntry.resources = resources;

        if (registryEntry.template === null) {
          // handle NoViewStrategy:
          return registryEntry.factory = null;
        }

        let viewFactory = this.viewCompiler.compile(registryEntry.template, resources, compileInstruction);
        return registryEntry.factory = viewFactory;
      });

      return registryEntry.onReady;
    });
  }

  /**
  * Loads all the resources specified by the registry entry.
  * @param registryEntry The template registry entry to load the resources for.
  * @param compileInstruction The compile instruction associated with the load.
  * @param loadContext The load context if this is happening within the context of a larger load operation.
  * @param target A class from which to extract metadata of additional resources to load.
  * @return A promise of ViewResources for the registry entry.
  */
  loadTemplateResources(registryEntry: TemplateRegistryEntry, compileInstruction?: ViewCompileInstruction, loadContext?: ResourceLoadContext, target?: any): Promise<ViewResources> {
    let resources = new ViewResources(this.appResources, registryEntry.address);
    let dependencies = registryEntry.dependencies;
    let importIds;
    let names;

    compileInstruction = compileInstruction || ViewCompileInstruction.normal;

    if (dependencies.length === 0 && !compileInstruction.associatedModuleId) {
      return Promise.resolve(resources);
    }

    importIds = dependencies.map(x => x.src);
    names = dependencies.map(x => x.name);
    logger.debug(`importing resources for ${registryEntry.address}`, importIds);

    if (target) {
      type Req = { src: string; as: string };
      let viewModelRequires = metadata.get(ViewEngine.viewModelRequireMetadataKey, target) as (string | Function | Req)[];
      if (viewModelRequires) {
        let templateImportCount = importIds.length;
        for (let i = 0, ii = viewModelRequires.length; i < ii; ++i) {
          let req = viewModelRequires[i];
          let importId = typeof req === 'function'
            ? Origin.get(req).moduleId
            : relativeToFile((req as Req).src || req as string, registryEntry.address);

          if (importIds.indexOf(importId) === -1) {
            importIds.push(importId);
            names.push((req as Req).as);
          }
        }
        logger.debug(`importing ViewModel resources for ${compileInstruction.associatedModuleId}`, importIds.slice(templateImportCount));
      }
    }

    return this.importViewResources(importIds, names, resources, compileInstruction, loadContext);
  }

  /**
  * Loads a view model as a resource.
  * @param moduleImport The module to import.
  * @param moduleMember The export from the module to generate the resource for.
  * @return A promise for the ResourceDescription.
  */
  importViewModelResource(moduleImport: string, moduleMember?: string): Promise<ResourceDescription> {
    return this.loader.loadModule(moduleImport).then(viewModelModule => {
      let normalizedId = Origin.get(viewModelModule).moduleId;
      let resourceModule = this.moduleAnalyzer.analyze(normalizedId, viewModelModule, moduleMember);

      if (!resourceModule.mainResource) {
        throw new Error(`No view model found in module "${moduleImport}".`);
      }

      resourceModule.initialize(this.container);

      return resourceModule.mainResource;
    });
  }

  /**
  * Imports the specified resources with the specified names into the view resources object.
  * @param moduleIds The modules to load.
  * @param names The names associated with resource modules to import.
  * @param resources The resources lookup to add the loaded resources to.
  * @param compileInstruction The compilation instruction associated with the resource imports.
  * @return A promise for the ViewResources.
  */
  importViewResources(moduleIds: string[], names: string[], resources: ViewResources, compileInstruction?: ViewCompileInstruction, loadContext?: ResourceLoadContext): Promise<ViewResources> {
    loadContext = loadContext || new ResourceLoadContext();
    compileInstruction = compileInstruction || ViewCompileInstruction.normal;

    moduleIds = moduleIds.map(x => this._applyLoaderPlugin(x));

    return this.loader.loadAllModules(moduleIds).then(imports => {
      let i;
      let ii;
      let analysis;
      let normalizedId;
      let current;
      let associatedModule;
      let container = this.container;
      let moduleAnalyzer = this.moduleAnalyzer;
      let allAnalysis = new Array(imports.length);

      //initialize and register all resources first
      //this enables circular references for global refs
      //and enables order independence
      for (i = 0, ii = imports.length; i < ii; ++i) {
        current = imports[i];
        normalizedId = Origin.get(current).moduleId;

        analysis = moduleAnalyzer.analyze(normalizedId, current);
        analysis.initialize(container);
        analysis.register(resources, names[i]);

        allAnalysis[i] = analysis;
      }

      if (compileInstruction.associatedModuleId) {
        associatedModule = moduleAnalyzer.getAnalysis(compileInstruction.associatedModuleId);

        if (associatedModule) {
          associatedModule.register(resources);
        }
      }

      //cause compile/load of any associated views second
      //as a result all globals have access to all other globals during compilation
      for (i = 0, ii = allAnalysis.length; i < ii; ++i) {
        allAnalysis[i] = allAnalysis[i].load(container, loadContext);
      }

      return Promise.all(allAnalysis).then(() => resources);
    });
  }

  /** @internal */
  _applyLoaderPlugin(id) {
    let index = id.lastIndexOf('.');
    if (index !== -1) {
      let ext = id.substring(index);
      let pluginName = this._pluginMap[ext];

      if (pluginName === undefined) {
        return id;
      }

      return this.loader.applyPluginToUrl(id, pluginName);
    }

    return id;
  }
}
