const cp = require('child_process');
const path = require('path');
const util = require('util');
const fs = require('fs-extra');

/**
 * @function Object() { [native code] }
 * @param {number} code Error code.
 * @param {string} message Error message.
 */
function ProcessError(code, message) {
  const callee = arguments.callee;
  Error.apply(this, [message]);
  Error.captureStackTrace(this, callee);
  this.code = code;
  this.message = message;
  this.name = callee.name;
}
util.inherits(ProcessError, Error);

/**
 * Util function for handling spawned processes as promises.
 * @param {string} exe Executable.
 * @param {Array<string>} args Arguments.
 * @param {string} cwd Working directory.
 * @return {Promise} A promise.
 */
function spawn(exe, args, cwd) {
  return new Promise((resolve, reject) => {
    const child = cp.spawn(exe, args, {cwd: cwd || process.cwd()});
    const buffer = [];
    child.stderr.on('data', (chunk) => {
      buffer.push(chunk.toString());
    });
    child.stdout.on('data', (chunk) => {
      buffer.push(chunk.toString());
    });
    child.on('close', (code) => {
      const output = buffer.join('');
      if (code) {
        const msg = output || 'Process failed: ' + code;
        reject(new ProcessError(code, msg));
      } else {
        resolve(output);
      }
    });
  });
}

/**
 * Create an object for executing git commands.
 * @param {string} cwd Repository directory.
 * @param {string} cmd Git executable (full path if not already on path).
 * @function Object() { [native code] }
 */
function Git(cwd, cmd) {
  this.cwd = cwd;
  this.cmd = cmd || 'git';
  this.output = '';
}

/**
 * Execute an arbitrary git command.
 * @param {Array<string>} args Arguments (e.g. ['remote', 'update']).
 * @return {Promise} A promise.  The promise will be resolved with this instance
 *     or rejected with an error.
 */
Git.prototype.exec = function (...args) {
  return spawn(this.cmd, [...args], this.cwd).then((output) => {
    this.output = output;
    return this;
  });
};

/**
 * Initialize repository.
 * @return {Promise} A promise.
 */
Git.prototype.init = function () {
  return this.exec('init');
};

/**
 * Clean up unversioned files.
 * @return {Promise} A promise.
 */
Git.prototype.clean = function () {
  return this.exec('clean', '-f', '-d');
};

/**
 * Hard reset to remote/branch
 * @param {string} remote Remote alias.
 * @param {string} branch Branch name.
 * @return {Promise} A promise.
 */
Git.prototype.reset = function (remote, branch) {
  return this.exec('reset', '--hard', remote + '/' + branch);
};

/**
 * Fetch from a remote.
 * @param {string} remote Remote alias.
 * @return {Promise} A promise.
 */
Git.prototype.fetch = function (remote) {
  return this.exec('fetch', remote);
};

/**
 * Checkout a branch (create an orphan if it doesn't exist on the remote).
 * @param {string} remote Remote alias.
 * @param {string} branch Branch name.
 * @return {Promise} A promise.
 */
Git.prototype.checkout = function (remote, branch) {
  const treeish = remote + '/' + branch;
  return this.exec('ls-remote', '--exit-code', '.', treeish).then(
    () => {
      // branch exists on remote, hard reset
      return this.exec('checkout', branch)
        .then(() => this.clean())
        .then(() => this.reset(remote, branch));
    },
    (error) => {
      if (error instanceof ProcessError && error.code === 2) {
        // branch doesn't exist, create an orphan
        return this.exec('checkout', '--orphan', branch);
      } else {
        // unhandled error
        throw error;
      }
    },
  );
};

/**
 * Remove all unversioned files.
 * @param {string | Array<string>} files Files argument.
 * @return {Promise} A promise.
 */
Git.prototype.rm = function (files) {
  if (!Array.isArray(files)) {
    files = [files];
  }
  return this.exec('rm', '--ignore-unmatch', '-r', '-f', '--', ...files);
};

/**
 * Add files.
 * @param {string | Array<string>} files Files argument.
 * @return {Promise} A promise.
 */
Git.prototype.add = function (files) {
  if (!Array.isArray(files)) {
    files = [files];
  }
  return this.exec('add', ...files);
};

/**
 * Commit (if there are any changes).
 * @param {string} message Commit message.
 * @return {Promise} A promise.
 */
Git.prototype.commit = function (message) {
  return this.exec('diff-index', '--quiet', 'HEAD').catch(() =>
    this.exec('commit', '-m', message),
  );
};

/**
 * Add tag
 * @param {string} name Name of tag.
 * @return {Promise} A promise.
 */
Git.prototype.tag = function (name) {
  return this.exec('tag', name);
};

/**
 * Push a branch.
 * @param {string} remote Remote alias.
 * @param {string} branch Branch name.
 * @param {boolean} force Force push.
 * @return {Promise} A promise.
 */
Git.prototype.push = function (remote, branch, force) {
  const args = ['push', '--tags', remote, branch];
  if (force) {
    args.push('--force');
  }
  return this.exec.apply(this, args);
};

/**
 * Get the URL for a remote.
 * @param {string} remote Remote alias.
 * @return {Promise<string>} A promise for the remote URL.
 */
Git.prototype.getRemoteUrl = function (remote) {
  return this.exec('config', '--get', 'remote.' + remote + '.url')
    .then((git) => {
      const repo = git.output && git.output.split(/[\n\r]/).shift();
      if (repo) {
        return repo;
      } else {
        throw new Error(
          'Failed to get repo URL from options or current directory.',
        );
      }
    })
    .catch((err) => {
      throw new Error(
        'Failed to get remote.' +
          remote +
          '.url (task must either be ' +
          'run in a git repository with a configured ' +
          remote +
          ' remote ' +
          'or must be configured with the "repo" option).',
      );
    });
};

/**
 * Delete ref to remove branch history
 * @param {string} branch The branch name.
 * @return {Promise} A promise.  The promise will be resolved with this instance
 *     or rejected with an error.
 */
Git.prototype.deleteRef = function (branch) {
  return this.exec('update-ref', '-d', 'refs/heads/' + branch);
};

/**
 * Clone a repo into the given dir if it doesn't already exist.
 * @param {string} repo Repository URL.
 * @param {string} dir Target directory.
 * @param {string} branch Branch name.
 * @param {options} options All options.
 * @return {Promise<Git>} A promise.
 */
Git.clone = function clone(repo, dir, branch, options) {
  return fs.exists(dir).then((exists) => {
    if (exists) {
      return Promise.resolve(new Git(dir, options.git));
    } else {
      return fs.mkdirp(path.dirname(path.resolve(dir))).then(() => {
        const args = [
          'clone',
          repo,
          dir,
          '--branch',
          branch,
          '--single-branch',
          '--origin',
          options.remote,
          '--depth',
          options.depth,
        ];
        return spawn(options.git, args)
          .catch((err) => {
            // try again without branch or depth options
            return spawn(options.git, [
              'clone',
              repo,
              dir,
              '--origin',
              options.remote,
            ]);
          })
          .then(() => new Git(dir, options.git));
      });
    }
  });
};

module.exports = Git;
