'use strict';
const fs = require('fs');
const path = require('path');
const async = require('async');
const jsdiff = require('diff');
const _ = require('lodash');
const typedError = require('error/typed');
const binaryDiff = require('./binary-diff');

const AbortedError = typedError({
  type: 'AbortedError',
  message: 'Process aborted by user'
});

const ConflictError = typedError({
  type: 'ConflicterConflict',
  message: 'Process aborted by conflict'
});

/**
 * The Conflicter is a module that can be used to detect conflict between files. Each
 * Generator file system helpers pass files through this module to make sure they don't
 * break a user file.
 *
 * When a potential conflict is detected, we prompt the user and ask them for
 * confirmation before proceeding with the actual write.
 *
 * @constructor
 * @property {Boolean} force - same as the constructor argument
 *
 * @param  {TerminalAdapter} adapter - The generator adapter
 * @param  {Boolean} force - When set to true, we won't check for conflict. (the
 *                           conflicter become a passthrough)
 * @param  {Boolean} bail - When set to true, we will abort on first conflict. (used for
 *                           testing reproducibility)
 */
class Conflicter {
  constructor(adapter, force, options = {}) {
    if (typeof options === 'boolean') {
      this.bail = options;
    } else {
      this.bail = options.bail;
      this.ignoreWhitespace = options.ignoreWhitespace;
      this.skipRegenerate = options.skipRegenerate;
      this.dryRun = options.dryRun;
    }

    this.force = force === true;
    this.adapter = adapter;
    this.conflicts = [];

    this.diffOptions = options.diffOptions;

    if (this.bail) {
      // Set ignoreWhitespace as true by default for bail.
      // Probably just testing, so don't override.
      this.ignoreWhitespace = true;
      this.skipRegenerate = true;
    }

    if (this.dryRun) {
      // Ignore whitespace changes with "ignoreWhitespace === true" option
      this.skipRegenerate = true;
    }
  }

  /**
   * Add a file to conflicter queue
   *
   * @param {String} filepath - File destination path
   * @param {String} contents - File new contents
   * @param {Function} callback - callback to be called once we know if the user want to
   *                              proceed or not.
   */
  checkForCollision(filepath, contents, callback) {
    if (typeof contents === 'function') {
      const status = filepath.conflicter;
      callback = contents;
      contents = filepath.contents;
      filepath = filepath.path;
      if (status) {
        const log = this.adapter.log[status];
        if (log) {
          const rfilepath = path.relative(process.cwd(), filepath);
          log.call(this.adapter.log, rfilepath);
        }

        callback(null, status);
        return;
      }
    }

    this.conflicts.push({
      file: {
        path: path.resolve(filepath),
        contents
      },
      callback
    });
  }

  /**
   * Process the _potential conflict_ queue and ask the user to resolve conflict when they
   * occur
   *
   * The user is presented with the following options:
   *
   *   - `Y` Yes, overwrite
   *   - `n` No, do not overwrite
   *   - `a` All, overwrite this and all others
   *   - `x` Exit, abort
   *   - `d` Diff, show the differences between the old and the new
   *   - `h` Help, show this help
   *
   * @param  {Function} cb Callback once every conflict are resolved. (note that each
   *                       file can specify it's own callback. See `#checkForCollision()`)
   */
  resolve(cb) {
    cb = cb || (() => {});

    const resolveConflicts = conflict => {
      return next => {
        if (!conflict) {
          next();
          return;
        }

        this.collision(conflict.file, status => {
          // Remove the resolved conflict from the queue
          _.pull(this.conflicts, conflict);
          conflict.callback(null, status);
          next();
        });
      };
    };

    async.series(this.conflicts.map(resolveConflicts), cb.bind(this));
  }

  /**
   * Print the file differences to console
   *
   * @param  {Object}   file File object respecting this interface: { path, contents }
   */
  _printDiff(file) {
    if (file.binary === undefined) {
      file.binary = binaryDiff.isBinary(file.path, file.contents);
    }

    if (file.binary) {
      this.adapter.log.writeln(binaryDiff.diff(file.path, file.contents));
    } else {
      const existing = fs.readFileSync(file.path);
      this.adapter.diff(
        existing.toString(),
        (file.contents || '').toString(),
        file.changes
      );
    }
  }

  /**
   * Detect conflicts between file contents at `filepath` with the `contents` passed to the
   * function
   *
   * If `filepath` points to a folder, we'll always return true.
   *
   * Based on detect-conflict module
   *
   * @param  {Object}   file File object respecting this interface: { path, contents }
   * @return {Boolean} `true` if there's a conflict, `false` otherwise.
   */
  _detectConflict(file) {
    let contents = file.contents;
    const filepath = path.resolve(file.path);

    // If file path point to a directory, then it's not safe to write
    if (fs.statSync(filepath).isDirectory()) return true;

    if (file.binary === undefined) {
      file.binary = binaryDiff.isBinary(file.path, file.contents);
    }

    const actual = fs.readFileSync(path.resolve(filepath));

    if (!(contents instanceof Buffer)) {
      contents = Buffer.from(contents || '', 'utf8');
    }

    if (file.binary) {
      return actual.toString('hex') !== contents.toString('hex');
    }

    if (this.ignoreWhitespace) {
      file.changes = jsdiff.diffWords(
        actual.toString(),
        contents.toString(),
        this.diffOptions
      );
    } else {
      file.changes = jsdiff.diffLines(
        actual.toString(),
        contents.toString(),
        this.diffOptions
      );
    }

    const changes = file.changes;
    return changes.length > 1 || changes[0].added || changes[0].removed;
  }

  /**
   * Check if a file conflict with the current version on the user disk
   *
   * A basic check is done to see if the file exists, if it does:
   *
   *   1. Read its content from  `fs`
   *   2. Compare it with the provided content
   *   3. If identical, mark it as is and skip the check
   *   4. If diverged, prepare and show up the file collision menu
   *
   * @param  {Object}   file File object respecting this interface: { path, contents }
   * @param  {Function} cb Callback receiving a status string ('identical', 'create',
   *                       'skip', 'force')
   */
  collision(file, cb) {
    const rfilepath = path.relative(process.cwd(), file.path);

    if (!fs.existsSync(file.path)) {
      this.adapter.log.create(rfilepath);
      if (this.bail) {
        this.adapter.log.writeln('Aborting ...');
        throw new ConflictError();
      }

      if (this.dryRun) {
        cb('skip');
        return;
      }

      cb('create');
      return;
    }

    if (this.force) {
      this.adapter.log.force(rfilepath);
      cb('force');
      return;
    }

    if (this._detectConflict(file)) {
      this.adapter.log.conflict(rfilepath);
      if (this.bail) {
        this._printDiff(file);
        this.adapter.log.writeln('Aborting ...');
        throw new ConflictError();
      }

      if (this.dryRun) {
        this._printDiff(file);
        cb('skip');
        return;
      }

      this._ask(file, cb, 1);
    } else {
      this.adapter.log.identical(rfilepath);
      if (this.skipRegenerate) {
        cb('skip');
        return;
      }

      cb('identical');
    }
  }

  /**
   * Actual prompting logic
   * @private
   * @param {Object} file vinyl file object
   * @param {Function} cb callback receiving the next action
   * @param {Number} counter prompts
   */
  _ask(file, cb, counter) {
    const rfilepath = path.relative(process.cwd(), file.path);
    const prompt = {
      name: 'action',
      type: 'expand',
      message: `Overwrite ${rfilepath}?`,
      choices: [
        {
          key: 'y',
          name: 'overwrite',
          value: 'write'
        },
        {
          key: 'n',
          name: 'do not overwrite',
          value: 'skip'
        },
        {
          key: 'a',
          name: 'overwrite this and all others',
          value: 'force'
        },
        {
          key: 'x',
          name: 'abort',
          value: 'abort'
        }
      ]
    };

    // Only offer diff option for files
    if (fs.statSync(file.path).isFile()) {
      prompt.choices.push({
        key: 'd',
        name: 'show the differences between the old and the new',
        value: 'diff'
      });
    }

    this.adapter.prompt([prompt]).then(result => {
      if (result.action === 'abort') {
        this.adapter.log.writeln('Aborting ...');
        throw new AbortedError();
      }

      if (result.action === 'diff') {
        this._printDiff(file);

        counter++;
        if (counter === 5) {
          throw new Error(`Recursive error ${prompt.message}`);
        }

        return this._ask(file, cb, counter);
      }

      if (result.action === 'force') {
        this.force = true;
      }

      if (result.action === 'write') {
        result.action = 'force';
      }

      this.adapter.log[result.action || 'force'](rfilepath);
      return cb(result.action);
    });
  }
}

module.exports = Conflicter;