
import { readFile, readFileSync, writeFileSync, writeFile, existsSync, copySync, ensureDirSync, createReadStream } from 'fs-extra';
import * as readline from 'readline';
import { join, resolve, parse, ParsedPath, relative, sep } from 'path';
import { logger, ILogger } from 'pargv';
import { EOL } from 'os';
import * as glob from 'glob';
import * as di from 'detect-indent';
import { parallel } from 'async';
import {
  extend as _extend, get as _get,
  set as _set, unset as _unset,
  isUndefined as _isUndefined,
  isPlainObject as _isPlainObject,
  isFunction as _isFunction,
  isString as _isString,
  camelCase as _camelCase,
  lowerCase as _lowerCase,
  upperCase as _upperCase,
  startCase as _startCase,
  capitalize as _capitalize,
  toLower as _toLower
} from 'lodash';

import { EventEmitter } from 'events';
import * as fm from 'front-matter';
import * as Mustache from 'mustache';
import { IMustr, IMustrOptions, ITemplates, ITemplate, BeforeRender, IMetadata, InjectCallback, NodeCallback, AfterRender, IRegister, IRegisterOptions, IPartials, IInject } from './interfaces';

const defaults: IMustrOptions = {
  configDir: './mustr',
  outputDir: './src',
  templateExt: '.tpl'
};

export class Mustr extends EventEmitter implements IMustr {

  // Utils
  cwd: string = process.cwd();
  log: ILogger;

  // Paths
  registerPath: string;
  templatesPath: string;
  outputPath: string;

  // Array of template paths.
  templatesGlob: string[];

  // Parsed templates.
  templates: ITemplates = {};

  // Options
  options: IMustrOptions;

  constructor(options?: IMustrOptions) {

    super();

    this.options = _extend({}, defaults, options);

    this.log = logger();

    this.templatesPath = resolve(this.cwd, join(this.options.configDir, '/**/*.tpl'));

    // Resolve the base directory.
    this.outputPath = resolve(this.cwd, this.options.outputDir);

    // Resolve the config path.
    this.registerPath = resolve(this.cwd, this.options.configDir, 'register.js');

    // Normalized the template extension.
    this.options.templateExt = this.normalizedExt(this.options.templateExt);

  }

  /**
   * Has Matter
   * Tests if string contains front matter.
   *
   * @param str the string to inspect.
   */
  private hasMatter(str) {
    return str.indexOf('---') > -1;
  }

  /**
   * Has Template
   * Loosly check if string contains template chars.
   *
   * @param str the string to inspect.
   */
  private hasTemplate(str) {
    return str.indexOf('{{') > -1;
  }

  /**
   * Try Require
   * Tries to require a module logging error if failed.
   *
   * @param path the module path to be required.
   */
  private tryRequire(path: string) {
    try {
      return require(path);
    }
    catch (ex) {
      this.log.error(`failed to require module at ${path}.`).exit();
    }
  }

  /**
   * Normalize Extension
   * Ensures extension begins with "."
   *
   * @param ext the extension to be normalized.
   */
  private normalizedExt(ext): string {
    return `.${ext.replace(/^\./, '')}`;
  }

  /**
   * Normalize
   * Parses/normalizes a template name, path or static template string.
   * When passing a name it searches loaded template paths and matches
   * the filename to the template name passed.
   *
   * @param template the template name, path or static template string.
   */
  private normalize(template: string): ITemplate {

    let rawTemplate, templatePath, templatePathNormalized,
      templateName, templateExt;
    const EXT_EXP = new RegExp(`${this.options.templateExt}$`);

    let parsedPath, parsedNormalized;

    // When has extension readfile.
    if (EXT_EXP.test(template)) {

      // If template doesn't exist error & exit.
      if (!existsSync(template))
        this.log.error(`failed to resolve template at path ${template}.`).exit();

      // Save the template path.
      templatePath = template;

    }

    // If not a static template try to
    // lookup from loaded template paths and read.
    else if (!this.hasMatter(template) && !this.hasTemplate(template)) {

      const filtered = this.templatesGlob.filter((t) => {
        return parse(t).name === template;
      })[0];

      if (!filtered) {

        this.log
          .error(`failed to resolve template for name ${template}.`)
          .exit();
      }

      templatePath = filtered;

    }

    // If template path exists normalize it.
    if (templatePath) {

      templatePath = templatePath.toLowerCase();

      // We'll resolve the template later
      // set undefined as this is NOT a
      // static template.
      template = undefined;

      // Remove the template ext.
      templatePathNormalized = templatePath.replace(EXT_EXP, '');

      // Parse paths.
      parsedPath = parse(templatePath);
      parsedNormalized = parse(templatePathNormalized);

      // Get the name from the path.
      templateName = parsedPath.name;

      // Template may contain extension in path.
      // So we make sure we used the normalized path
      // which exclusdes the template extension.
      templateExt = parsedNormalized.ext;

      templateName = templateName.toLowerCase();
      templateExt = templateExt.toLowerCase();
      templatePathNormalized = templatePathNormalized.toLowerCase();

    }

    // Otherwise this is a static template
    // set raw to be parsed later.
    else {
      rawTemplate = template;
    }

    // Get the template's configuration.
    const config: ITemplate = {
      name: templateName,
      path: templatePath,
      ext: templateExt,
      pathNormalized: templatePathNormalized,
      raw: rawTemplate,
      partials: {}
    };

    // Return ITemplate
    return config;

  }

  /**
   * Truncate
   * Nothing special, cheesy truncation...move along.
   *
   * @param str the string to be truncated.
   * @param max the max len before truncating.
   */
  private truncate(str, max) {
    return str.length > max ? str.substr(0, max - 1) + '...' : str;
  }

  /**
   * Init
   * Initializes Mustr for current project
   *
   * @param force when true forces init.
   */
  init(force?: boolean): void {

    const configPath = resolve(this.cwd, 'mustr.json');
    const blueprintsPath = resolve(this.cwd, 'mustr');

    // Ensure not already initialized.
    if (!force && (existsSync(configPath) || existsSync(blueprintsPath)))
      this.log.warn('project already initialized, use mu.init(true) or mu init -f from cli to force.').exit();

    // Write out the mustr.json config.
    writeFileSync(configPath, JSON.stringify(defaults, null, 2));

    // Copy the blueprints.
    copySync(resolve(__dirname, 'blueprints'), blueprintsPath);

    this.log.info('successfully initialized Mustr.').exit();

  }

  /**
   * Load
   * Loads the template paths and config.
   */
  load(): IMustr {

    if (!existsSync(this.registerPath))
      this.log.error(`failed to resolve register configuration at ${this.registerPath}.`).exit();

    // Load template paths..
    this.templatesGlob = glob.sync(this.templatesPath);

    // Require the config
    const config = this.tryRequire(this.registerPath);

    if (!_isFunction(config))
      this.log
        .error(`failed to load config expected function but got type of ${typeof config}.`)
        .exit();

    // Call/load the configuration.
    config(this);

    return this;

  }

  /**
   * Configure
   * Configures template for output.
   *
   * @param name the name of the template to load.
   * @param output the output name/path to use when rendering.
   * @param options any metadata to merge for rendering.
   */
  configure(name: string, output?: string | IMetadata, metadata?: IMetadata): ITemplate {

    let rendered, ext, parsedOutput, outputName, outputPath;

    // Enable options as second arg.
    if (_isPlainObject(output)) {
      metadata = output as IMetadata;
      output = undefined;
    }

    // set output to name if undefined.
    if (!_isUndefined(name) && _isUndefined(output))
      output = name;

    name = name.toLowerCase();
    output = (output as string).toLowerCase();

    // Ensure we have a template and an output name.
    if (_isUndefined(name) || _isUndefined(output))
      this.log.error('cannot generate template using template name or output name of undefined.').exit();

    let template = this.templates[name];

    // Ensure the template exists.
    if (!template)
      this.log.error(`the template ${name} could not be found.`).exit();

    // If contains path then not static defined
    // we need to read the template file.
    if (template.path)
      template.raw = readFileSync(template.path).toString();

    // We need to inspect partials.
    // Since its a partial we just need to
    // read the file.
    const partialKeys = Object.keys(template.partials);
    partialKeys.forEach((p) => {

      const partial: ITemplate = template.partials[p];

      if (partial.path)
        partial.raw = readFileSync(partial.path).toString();

      // Update the partial template.
      template.partials[p] = partial;

    });

    // If we don't have template.raw string to be parsed
    // we can't continue.
    if (_isUndefined(template.raw))
      this.log.error('cannot generate template with raw template string of undefined.').exit();

    // Parse the template for body and front matter attributes.
    const matter = fm(template.raw);
    const attrs = matter.attributes;
    const tmpConfig = attrs.config || {};
    delete attrs.config;

    template.metadata = attrs;
    template.body = matter.body;

    // Merge in the parsed config with template config.
    template = _extend({}, tmpConfig, template);

    // Parse the output path/name.
    parsedOutput = parse(output as string);

    // Get the output name.
    outputName = parsedOutput.name;

    // Ensure ext from output or defined in template config.
    ext = parsedOutput.ext || tmpConfig.ext;

    // Set the component name in metadata.
    template.metadata.component = outputName;

    // Check component casing and suffix.
    if (template.suffix) {
      template.metadata.component += ` ${template.suffix}`;
      outputName += `.${template.suffix}`;
    }

    // Ensure full template output path.
    outputPath = resolve(this.outputPath, parsedOutput.dir || '', outputName + ext);
    outputPath = outputPath.toLowerCase();

    // Update the template object with
    // the output name and output path.
    template.outputPath = outputPath;

    // Check if casing is specified.
    if (template.casing || template.suffix) {
      const casing = template.casing ? template.casing : 'title';
      template.metadata.component =
        this.transformCase(template.metadata.component, casing);
    }

    // Merge parsed options with template metadata.
    template.metadata = _extend({}, template.metadata, metadata);

    return template;

  }

  /**
   * Register
   * Registers a template with Mustr.
   *
   * @param name to use when registering a template.
   * @param template the template name or path to lookup or a static string.
   */
  register(name: string | IRegisterOptions, template?: string): IRegister {

    let beforeRender, afterRender, partials, methods;
    let addPartials, getPartial;
    let _template: ITemplate;

    if (_isPlainObject(name)) {
      const reg = name as IRegisterOptions;
      name = reg.name;
      template = reg.template;
      partials = reg.partials;
      beforeRender = reg.beforeRender;
      afterRender = reg.afterRender;
    }

    // Parse the supplied template.
    const parsed = this.normalize(template);

    // Register the template with the template collection.
    name = (name as string).toLowerCase();
    _template = this.templates[name] = parsed;

    getPartial = (partial: any) => {

      const partialName = partial.toLowerCase();
      const existing = this.templates[partialName];

      if (existing) return existing;

      partial = this.normalize(partial);
      partial.isPartial = true;

      return partial;


    };

    addPartials = (...args: any[]) => {

      if (!args.length)
        return methods;

      if (_isPlainObject(args[0])) {

        const _partials = args[0];

        Object.keys(_partials).forEach((p) => {

          const partial = getPartial(_partials[p]);
          partial.name = p.toLowerCase();
          _template.partials[partial.name] = partial;

        });

      }

      else {

        // Flatten the array.
        args = [].concat.apply([], args);

        // Iterate partials and load.
        args.forEach((p) => {

          const partial = getPartial(p);
          _template.partials[partial.name] = partial;

        });

      }

    };

    beforeRender = (before: BeforeRender) => {
      _template.beforeRender = before;
      return methods;
    };

    afterRender = (after: AfterRender) => {
      _template.afterRender = after;
      return methods;
    };

    // Add partials if needed.
    addPartials(partials);

    // Build up methods for extending registration.
    // It's just cleaner this way rather than a long
    // signature with lots of params for .register().
    // pass Register object if you don't like chaining.

    methods = {
      partials,
      beforeRender,
      afterRender
    };

    return methods;

  }

  /**
   * Render
   * Renders the requested template.
   *
   * @param name the name of the template to render.
   * @param output the output path/name to render the template as.
   * @param options the metadata options to extend with if any.
   */
  render(name: string | ITemplate, output?: string | IMetadata | NodeCallback, metadata?: IMetadata | NodeCallback, done?: NodeCallback): void {

    // Load the template.
    let template = name as ITemplate;
    let isTemplate;

    // Ensure we have some templates loaded.
    if (!Object.keys(this.templates).length)
      this.log.warn('whoops you no templates registed, add some templates in "register.js"').exit();

    if (_isFunction(output)) {
      done = output;
      output = undefined;
      metadata = undefined;
    }

    else {

      if (_isFunction(metadata)) {
        done = metadata;
        metadata = undefined;
      }

      if (_isPlainObject(output)) {
        done = metadata as NodeCallback;
        metadata = output as IMetadata;
        output = undefined;
      }

    }

    if (_isString(template)) {
      if (!_isString(output))
        output = name;
      template = this.configure(name as string, output, metadata);
    }

    const partials = {};

    // If partials get the raw strings.
    const partialKeys = Object.keys(template.partials);
    partialKeys.forEach((p) => {
      const partial: ITemplate = template.partials[p];
      partials[p] = partial.raw;
    });

    // Ensure before/after render hooks.
    template.beforeRender = template.beforeRender || function (t, d) { d(); };
    template.afterRender = template.afterRender || function (t) { };
    done = done || function (t) { };

    // Ensure injects array.
    template.injects = template.injects || [];

    // Parse the output path.
    const parsedPath = parse(template.outputPath as string);

    // Ensure the directory structure exists.
    ensureDirSync(parsedPath.dir);

    const injects = [];
    let success = 0;
    let failed = 0;

    // Iterate the inject
    template.injects.forEach((inj: IInject) => {

      const wrapper = (cb) => {

        this.inject(inj, (err) => {

          if (err)
            failed += 1;

          else
            success += 1;

          // callback to continue.
          cb();

        });

      };

      injects.push(wrapper);

    });

    // Call before render hook.
    template.beforeRender(template, () => {

      // Render the template.
      template.rendered = Mustache.render(template.body, template.metadata, partials);

      // Write the file.
      writeFile(template.outputPath, template.rendered, (err) => {

        if (err)
          this.log.error(err).exit();

        // No injects required just call callbacks.
        if (!injects.length) {

          // Call after render.
          template.afterRender(template);

          // Render may be called directly with done callback.
          done(template);

        }

        else {

          // Iterate injects.
          parallel(injects, (err, results) => {

            this.log.info(`${success} successfully injected ${failed} failed.`);

            // Call done/after callbacks.
            template.afterRender(template);
            done(template);

          });

        }

      });

    });

  }

  /**
   * Inject
   * Creates a file stream reading chunks until match.
   * Upon match the provided value is injected.
   * NOTE: lines are auto updated with the correct
   * line ending for returns.
   *
   * @param filename the file name to inject into.
   * @param find the string or expression to match.
   * @param insert the value or callback for injecting upon match.
   * @param strategy insert on before, after, replace, first or last when matched.
   * @param done the callback on file written.
   */
  inject(filename: string | IInject,
    find?: string | RegExp | NodeCallback,
    strategy?: 'before' | 'after' | 'first' | 'last' | 'replace',
    insert?: string | string[],
    done?: NodeCallback): void {

    let data = [];
    let idx = 0;
    let matches = 0;
    let insertLen = 0;
    let lastMatch = 0;
    let exp: RegExp;

    // Allows done callback as second arg.
    if (_isFunction(find))
      done = find;

    if (_isPlainObject(filename)) {
      const obj = filename as IInject;
      filename = obj.filename;
      find = obj.find;
      insert = obj.insert;
      strategy = obj.strategy;
      done = obj.done || done;
    }

    done = done || function () { };

    // Inserts at a given index.
    function insertAt(arr, index, item) {
      const start = arr.slice(0, index);
      const end = arr.slice(index);
      return [].concat(start).concat(item).concat(end);
    }

    // Sets indentation for inserts.
    function setIndent(from, to) {
      const indent = di(from).indent;
      to.forEach((t, i) => {
        to[i] = (indent + t);
      });
      return to;
    }

    // Resolve the filename.
    filename = resolve(this.outputPath, filename);

    if (!existsSync(filename as string))
      return done(new Error(`failed to inject in ${filename}, file not found.`));

    exp = find as RegExp;

    // If is string convert to expression.
    if (_isString(exp))
      exp = new RegExp(exp);

    // Normalize the insert
    if (_isString(insert))
      insert = [insert];

    insert = insert || [];

    insertLen = insert.length;

    const rl = readline.createInterface({
      input: createReadStream(filename as string)
    });

    rl.on('line', (line) => {

      if (exp.test(line)) {

        matches += 1;
        if (strategy !== 'last')
          insert = setIndent(line, insert);

        if (strategy === 'before') {

          data = data.concat(insert);
          data.push(line);
          idx += insertLen;

        }

        else if (strategy === 'after') {

          data.push(line);
          data = data.concat(insert);
          idx += insertLen;

        }

        else if (strategy === 'replace') {

          data = data.concat(insert);
          idx += (insertLen - 1);

        }

        else if (strategy === 'first' && matches === 1) {

          data = data.concat(insert);
          data.push(line);
          idx += insertLen;

        }

        else {
          data.push(line);
        }

      }

      // If not match just push.
      else {

        data.push(line);

      }

      // keep track of our index.
      idx += 1;

      if (exp.test(line))
        lastMatch = idx;

    });

    rl.on('close', () => {

      if (strategy === 'last' && matches > 0 && lastMatch >= 0) {
        insert = setIndent(data[lastMatch - 1], insert);
        data = insertAt(data, lastMatch, insert);
      }

      const joined = data.join(EOL);
      writeFile(filename as string, joined, (err) => {

        const insertStr = (insert as string[]).join(', ');

        if (err)
          this.log.warn(err);
        else
          this.log.info(`injected: ${this.truncate(insert, 18)} in: ${parse(filename as string).base}.`);

        // Pass error back for render counter.
        // does not count when manually calling
        // .inject method.
        done(err);

      });

    });

  }

  /**
   * Transform To
   * Transforms a string to the desired casing.
   *
   * @param str the string to be transformed.
   * @param to the casing to be transformed to using lodash.
   */
  transformCase(str: string, to: string): string {

    let caseMap: any = {
      lower: _lowerCase,
      upper: _upperCase,
      capitalize: _capitalize,
      camel: _camelCase,
      lowerCase: _lowerCase,
      upperCase: _upperCase,
      camelCase: _camelCase,
      title: (str) => {
        return _startCase(_toLower(str));
      }
    };

    caseMap.titleCase = caseMap.title;

    if (!caseMap[to])
      this.log.error(`invalid transform case ${to}.`).exit();

    return caseMap[to](str).replace(/\s/g, '');

  }

}