'use strict';

var fs      = require('fs'),
    path    = require('path'),
    url     = require('url');

module.exports = function configParser(options) {
  var config = createDefaultConfig();

  options = options || {};

  // read environmental variables first
  config = parseEnv(config);

  // read a config file next
  config = parseFile(options.file, config);

  // process any command line arguments
  config = parseCommandLine(options.argv, config);

  // process the hard code values
  parseConfig(options, config);

  // normalize the path to config.server.webroot
  if (config.server.webroot && config.server.webroot.length > 0) {
    config.server.webroot = path.normalize(config.server.webroot);
  }

  return config;
};

function createDefaultConfig() {
  return {
    server: {
      port: 8080,
      webroot: process.cwd(),
      html5mode: false
    },
    proxy: {
      gateway: null,
      forward: [],
      headers: []
    }
  };
}

function parseEnv(config) {
  var env = process.env;
  var temp;

  if (env['JSON_PROXY_PORT']) {
    temp = parseInt(env['JSON_PROXY_PORT'], 10);
    if (temp && !isNaN(temp)) {
      config.server.port = temp;
    }
  }

  if (env['JSON_PROXY_WEBROOT']) {
    temp = path.normalize(env['JSON_PROXY_WEBROOT']);
    if (temp && fs.existsSync(temp)) {
      config.server.webroot = temp;
    }
  }

  if (env['JSON_PROXY_GATEWAY']) {
    config.proxy.gateway = parseGateway(env['JSON_PROXY_GATEWAY'], env['JSON_PROXY_GATEWAY_AUTH']);
  }

  return config;
}

// reads a config file from either the config file specified on the command line,
// or fallback to a file name json-proxy.config in the working directory
// return true if the file can be read, otherwise return false
function parseFile(filepath, config) {
  var contents;

  filepath = filepath || path.join(process.cwd(), '/json-proxy.json');

  // if we were passed a config file, read and parse it
  if (fs.existsSync(filepath)) {
    try {
      var data = fs.readFileSync(filepath);
      contents = JSON.parse(data.toString());
      config = parseConfig(contents, config);

      // replace the token $config_dir in the webroot arg
      if (config.server.webroot && config.server.webroot.length > 0) {
        config.server.webroot = config.server.webroot.replace("$config_dir", path.dirname(filepath));
      }
    } catch (ex) {
      throw new Error('Cannot parse the config file "' + filepath + '": ' + ex);
    }

  }

  return config;
}

// parse a config structure, overriding any values in config
function parseConfig(contents, config) {
  contents.server = contents.server || {};
  contents.proxy = contents.proxy || {};

  if (contents.proxy.gateway && typeof(contents.proxy.gateway) === "string" && contents.proxy.gateway.length > 0) {
    contents.proxy.gateway = parseGateway(contents.proxy.gateway);
  }

  contents.proxy.forward = parseConfigMap(contents.proxy.forward, parseForwardRule);
  contents.proxy.headers = parseConfigMap(contents.proxy.headers, parseHeaderRule);

  // override any values in the config object with values specified in the file;
  config.server.port = contents.server.port || config.server.port;
  config.server.webroot = contents.server.webroot || config.server.webroot;
  config.server.html5mode = contents.server.html5mode || config.server.html5mode;
  config.proxy.gateway = contents.proxy.gateway || config.proxy.gateway;
  config.proxy.forward = contents.proxy.forward || config.proxy.forward;
  config.proxy.headers = contents.proxy.headers || config.proxy.headers;

  return config;
}

// transform a config hash object into an array
function parseConfigMap(map, callback) {
  var result = [];

  if (!(map instanceof Object)) {
    return map;
  }

  for(var property in map) {
    if (map.hasOwnProperty(property)) {
      result.push(callback(property, map[property]));
    }
  }

  return result;
}

// reads command line parameters
function parseCommandLine(argv, config) {
  if (argv) {
    // read the command line arguments if no config file was given
    parseCommandLineArgument(argv.port, function(item){
      config.server.port = item;
    });

    parseCommandLineArgument(argv.html5mode, function(item){
      config.server.html5mode = item;
    });

    parseCommandLineArgument(argv._, function(item){
      config.server.webroot = path.normalize(item);
    });

    parseCommandLineArgument(argv.gateway, function(item){
      config.proxy.gateway = parseGateway(item);
    });

    parseCommandLineArgument(argv.forward, function(item){
      var rule = parseForwardRule(item);
      var match = false;

      config.proxy.forward.forEach(function(item) {
        if (item.regexp.source === rule.regexp.source) {
          item.target = rule.target;
          match = true;
        }
      });

      if (!match) {
        config.proxy.forward.push(rule);
      }
    });

    parseCommandLineArgument(argv.header, function(item){
      var rule = parseHeaderRule(item);
      var match = false;

      config.proxy.headers.forEach(function(item) {
        if (item.name === rule.name) {
          item.value = rule.value;
          match = true;
        }
      });

      if (!match) {
        config.proxy.headers.push(rule);
      }
    });
  }

  return config;
}

// argv.X will be an array if multiple -X options are provided
// otherwise argv.X will just be a scalar value
function parseCommandLineArgument(arg, fn) {
  if (typeof(fn) !== 'function')
    return;

  if (Array.isArray(arg)) {
    arg.forEach(function(item) {
      fn.call(null, item);
    });
  } else {
    if (arg !== null && arg !== undefined) {
      fn.call(null, arg);
    }
  }
}

function parseGateway(gateway, auth) {
  var config = null;

  if (gateway !== undefined && gateway !== null && typeof(gateway) === 'string' && gateway.length > 0) {
    config = parseTargetServer(gateway);
    config.auth = url.parse(gateway).auth;

    if (auth && typeof(auth) === "string" && auth.length > 0) {
      config.auth  = auth;
    }
  }

  return config;
}

function parseHeaderRule() {
  return tokenize.apply(null, arguments);
}

// parses rule syntax to create forwarding rules
function parseForwardRule() {
  var token,
      rule;

  if (arguments[0] === undefined || arguments[0] === null) {
    return;
  }

  if (typeof(arguments[0]) === "object") {
    return arguments[0];
  }

  try {
    token = tokenize.apply(null, arguments);
    rule = { regexp: new RegExp('^' + token.name, 'i'), target: parseTargetServer(token.value) };
  } catch (e) {
    throw new Error('cannot parse the forwarding rule ' + arguments[0] + ' - ' + e);
  }

  return rule;
}

// parses a simple hostname:port argument, defaulting to port 80 if not specified
function parseTargetServer(value) {
  var target,
      path;

  // insert a http protocol handler if not found in the string
  if (value.indexOf('http://') !== 0 && value.indexOf('https://') !== 0) {
    value = 'http://' + value + '/';
  }
  target = url.parse(value);
  path = target.path;

  // url.parse() will default to '/' as the path
  // we can safely strip this for regexp matches to function properly
  if (path === '/') {
    path = '';
  }

  // support an explict set of regexp unnamed captures (prefixed with `$`)
  // if the pattern doesn't include a match operator like '$1', '$2', '$3',
  // then use the RegExp lastMatch operator `$&` if the rewrite rule
  if (/\$\d/.test(path) === false) {
    path = [path ,'$&'].join('');
  }

  return {
    host: target.hostname,
    // inject a default port if one wasn't specified
    port: target.port || ((target.protocol === 'https:') ? 443 : 80),
    originalPort: target.port,
    protocol: target.protocol,
    path: path
  };
}

// reads name/value tokens for command line parameters
function tokenize() {
  var token = { name: null, value: null },
      temp = null;

  if (arguments.length !== 1) {
    token.name = arguments[0];
    token.value = arguments[1];
    return token;
  }

  temp = arguments[0];

  if (undefined !== temp && null !== temp) {
    temp = temp.split('=');
  }

  if (Array.isArray(temp) && temp.length > 1) {
    token.name = temp[0];
    token.value = temp[1];
  }

  return token;
}


const ERRORS = require('../constants/errors');

/**
 * Return an error with a specific code
 */
function withCode(code, msg) {
    const err = new Error(msg);
    err.code = code;
    return err;
}

module.exports = {
    invalidArgs(err) {
        return withCode(400, err);
    },
    fileAlreadyExist(filepath) {
        return withCode(ERRORS.ALREADY_EXIST, 'File already exist "' + filepath + '"');
    },
    dirAlreadyExist(filepath) {
        return withCode(ERRORS.ALREADY_EXIST, 'Directory already exist "' + filepath + '"');
    },
    fileNotFound(filepath) {
        return withCode(ERRORS.NOT_FOUND, 'File not found "' + filepath + '"');
    },
    notDirectory(filepath) {
        return withCode(406, '"' + filepath + '" is not a directory');
    },
    refNotFound(ref) {
        return withCode(ERRORS.NOT_FOUND, 'Ref not found "' + ref + '"');
    },
    commitNotFound(sha) {
        return withCode(ERRORS.NOT_FOUND, 'Commit not found "' + sha + '"');
    },
    fileHasBeenModified(filepath) {
        return withCode(406, 'File "' + filepath + '" has been modified');
    }
};


const Branch = require('../models/branch');
const WorkingUtils = require('./working');
const RepositoryState = require('../models/repositoryState');

/**
 * Change workinState for a specific branch
 * @param {RepositoryState} repoState
 * @param {Branch} branch
 * @param {WorkingState | Null} newWorkingState Pass null to delete
 * @return {RepositoryState}
 */
function updateWorkingState(repoState, branch, newWorkingState) {
    let workingStates = repoState.getWorkingStates();

    const key = branch.getFullName();
    if (newWorkingState === null) {
        // Delete
        workingStates = workingStates.delete(key);
    } else {
        // Update the entry in the map
        workingStates = workingStates.set(key, newWorkingState);
    }

    return repoState.set('workingStates', workingStates);
}

/**
 * Change current working tree
 */
function updateCurrentWorkingState(repoState, workingState) {
    return updateWorkingState(repoState, repoState.getCurrentBranch(), workingState);
}

/**
 * Fetches the given branch's tree, from its SHA, and __resets any
 * WorkingState for it__
 * @param {RepositoryState}
 * @param {Driver}
 * @param {Branch}
 * @return {Promise<RepositoryState>}
 */
function fetchTree(repoState, driver, branch) {
    // Fetch a working tree for this branch
    return WorkingUtils.fetch(driver, branch)
    .then((newWorkingState) => {
        return updateWorkingState(repoState, branch, newWorkingState);
    });
}

/**
 * Change current branch in the repository (sync). Requires to have
 * fetched the branch.
 * @param {RepositoryState} repoState
 * @param {Branch | String} branch Can provide the fullname of the branch instead
 * @return {RepositoryState}
 */
function checkout(repoState, branch) {
    let _branch = branch;
    if (!(branch instanceof Branch)) {
        _branch = repoState.getBranch(branch);
        if (branch === null) {
            throw Error('Unknown branch ' + branch);
        }
    }

    if (!repoState.isFetched(_branch)) {
        throw Error('Tree for branch ' + _branch.getFullName() + ' must be fetched first');
    }
    return repoState.set('currentBranchName', _branch.getFullName());
}

/**
 * Fetch the list of branches in the repository and update them all. Will clear the
 * WorkingStates of all branches that have updated.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @return {Promise<RepositoryState>} with list of branches fetched
 */
function fetchBranches(repoState, driver) {
    const oldBranches = repoState.getBranches();
    return driver.fetchBranches()
    .then((branches) => {
        return repoState.set('branches', branches);
    })
    .then(function refreshWorkingStates(repoState) {
        // Remove outdated WorkingStates
        return oldBranches.reduce((repoState, oldBranch) => {
            const fullName = oldBranch.getFullName();
            const newBranch = repoState.getBranch(fullName);
            if (newBranch === null || newBranch.getSha() !== oldBranch.getSha()) {
                // Was removed OR updated
                return updateWorkingState(repoState, oldBranch, null);
            } else {
                // Unchanged
                return repoState;
            }
        }, repoState);
    });
}

/**
 * Initialize a new RepositoryState from the repo of a Driver. Fetch
 * the branches, and checkout master or the first available branch.
 * @param {Driver} driver
 * @return {Promise<RepositoryState>}
 */
function initialize(driver) {
    const repoState = RepositoryState.createEmpty();
    return fetchBranches(repoState, driver)
    .then((repoState) => {
        const branches = repoState.getBranches();
        const master = branches.find(function isMaster(branch) {
            return branch.getFullName() === 'master';
        });
        const branch = master || branches.first();

        return fetchTree(repoState, driver, branch)
        .then((repoState) => {
            return checkout(repoState, branch);
        });
    });
}

/**
 * Synchronize the filesystem to reflect the repository state on the given branch.
 * @param {Branch} branch
 * @return {Promise<Undefined, Error>}
 */
function syncFilesystem(driver, branch) {
    return driver.checkout(branch);
}

/**
 * @param {RepositoryState} repoState
 * @return {Boolean} True if the repository has some changes on any branch
 */
function isClean(repoState) {
    return repoState
        .getWorkingStates()
        .every(workingState => workingState.isClean());
}

const RemoteUtils = {
    initialize,
    isClean,
    checkout,
    syncFilesystem,
    fetchTree,
    fetchBranches,
    updateWorkingState,
    updateCurrentWorkingState
};
module.exports = RemoteUtils;


const Buffer = require('buffer').Buffer;
const is = require('is');

/**
 * Test if is arraybuffer
 */
function isArrayBuffer(b) {
    return Object.prototype.toString.call(b) === '[object ArrayBuffer]';
}
function isBuffer(b) {
    return Object.prototype.toString.call(b) === '[object Buffer]';
}

/**
 * Convert from a string
 */
function fromString(str, encoding) {
    return fromBuffer(new Buffer(str, encoding || 'utf8'));
}

/**
 * Convert from a base64 string
 */
function fromBase64(str) {
    return fromString(str, 'base64');
}

/**
 * Convert from a buffer to an ArrayBuffer
 */
function fromBuffer(buffer) {
    const ab = new ArrayBuffer(buffer.length);
    const view = new Uint8Array(ab);
    for (let i = 0; i < buffer.length; ++i) {
        view[i] = buffer[i];
    }
    return ab;
}

/**
 * Convert to a buffer
 */
function toBuffer(ab) {
    const buffer = new Buffer(ab.byteLength);
    const view = new Uint8Array(ab);
    for (let i = 0; i < buffer.length; ++i) {
        buffer[i] = view[i];
    }
    return buffer;
}

/**
 * Force conversion to a Base64 string
 */
function enforceBase64(b) {
    return enforceBuffer(b).toString('base64');
}

/**
 * Force conversion to a buffer
 */
function enforceBuffer(b) {
    if (isArrayBuffer(b)) return toBuffer(b);
    else return new Buffer(b);
}

/**
 * Force conversion to an arraybuffer
 */
function enforceArrayBuffer(b, encoding) {
    if (isArrayBuffer(b)) return b;
    else if (isBuffer(b)) return fromBuffer(b);
    else return fromString(b, encoding);
}

/**
 * Force conversion to string with specific encoding
 */
function enforceString(b, encoding) {
    if (is.string(b)) return b;
    if (isArrayBuffer(b)) b = toBuffer(b);

    return b.toString(encoding);
}

/**
 * Tests equality of two ArrayBuffer
 * @param {ArrayBuffer} buf1
 * @param {ArrayBuffer} buf2
 * @return {Boolean}
 */
function equals(buf1, buf2) {
    if (buf1.byteLength != buf2.byteLength) return false;
    const dv1 = new Int8Array(buf1);
    const dv2 = new Int8Array(buf2);
    for (let i = 0 ; i != buf1.byteLength ; i++) {
        if (dv1[i] != dv2[i]) return false;
    }
    return true;
}

const BufferUtils = {
    equals,
    fromBuffer,
    fromString,
    fromBase64,
    toBuffer,
    toBase64: enforceBase64,
    enforceBase64,
    enforceBuffer,
    enforceArrayBuffer,
    enforceString
};

module.exports = BufferUtils;


const { Record } = require('immutable');

const Blob = require('./blob');

// Describe a change made on a reference, compared to its parent
const CHANGE = {
    ADDED: 'added',
    DELETED: 'deleted',
    IDENTICAL: 'identical',
    MODIFIED: 'modified'
};

// There is four types of conflicts depending on changes on each branch:
// |-----------+------------+-----------------+-----------+-----------------|
// | base\head | ADDED      | DELETED         | IDENTICAL | MODIFIED        |
// |-----------+------------+-----------------+-----------+-----------------|
// | ADDED     | Both added | X               | X         | X               |
// |-----------+------------+-----------------+-----------+-----------------|
// | DELETED   | X          | X               | X         | Deleted on base |
// |-----------+------------+-----------------+-----------+-----------------|
// | IDENTICAL | X          | X               | X         | X               |
// |-----------+------------+-----------------+-----------+-----------------|
// | MODIFIED  | X          | Deleted on head | X         | Both modified   |
// |-----------+------------+-----------------+-----------+-----------------|
const TYPE = {
    BOTH_ADDED: 'added',
    DELETED_BASE: 'deleted-base',
    DELETED_HEAD: 'deleted-head',
    BOTH_MODIFIED: 'modified',
    NO_CONFLICT: 'no-conflict'
};

const DEFAULTS = {
    // Blob SHA in head. Null when absent
    headSha: null, // String

    // Blob SHA in base. Null when absent
    baseSha: null, // String

    // Blob SHA in the closest parent. Null when absent
    parentSha: null, // String

    // Is solved ?
    solved: false,

    // The SHA it is solved with. null when using solvedContent, or to solve by
    // deleting the entry.
    solvedSha: null, // SHA

    // The solved content
    solvedContent: null // Blob
};

/**
 * Conflict represents a git conflict.
 * @type {Class}
 */
class Conflict extends Record(DEFAULTS) {
    // ---- Properties Getter ----

    getBaseSha() {
        return this.get('baseSha');
    }

    getHeadSha() {
        return this.get('headSha');
    }

    getParentSha() {
        return this.get('parentSha');
    }

    isSolved() {
        return this.get('solved');
    }

    getSolvedSha() {
        return this.get('solvedSha');
    }

    getSolvedContent() {
        return this.get('solvedContent');
    }

    // ---- Methods ----

    /**
     * @return {Boolean}
     */
    isDeleted() {
        return this.isSolved()
            && this.getSolvedContent() === null
            && this.getSolvedSha() === null;
    }

    /**
     * @return {Conflict.CHANGE} Return the kind of change made by base
     */
    getBaseStatus() {
        return getChange(this.getParentSha(), this.getBaseSha());
    }

    /**
     * @return {Conflict.CHANGE} Return the kind of change made by head
     */
    getHeadStatus() {
        return getChange(this.getParentSha(), this.getHeadSha());
    }

    /**
     * @return {Conflict.TYPE} Return the type of this conflict
     */
    getType() {
        const base = this.getBaseStatus();
        const head = this.getHeadStatus();

        // Based on the TYPE matrix :
        if (base === CHANGE.ADDED && head === CHANGE.ADDED) {
            return TYPE.BOTH_ADDED;
        } else if (base === CHANGE.DELETED && head === CHANGE.MODIFIED) {
            return TYPE.DELETED_BASE;
        } else if (base === CHANGE.MODIFIED && head === CHANGE.DELETED) {
            return TYPE.DELETED_HEAD;
        } else if (base === CHANGE.MODIFIED && head === CHANGE.MODIFIED) {
            return TYPE.BOTH_MODIFIED;
        } else {
            return TYPE.NO_CONFLICT;
        }
    }

    /**
     * @param {Boolean} [solved] Default to toggle
     * @return {Conflict} Set the solved state. Does not alter previous
     * solved content/sha
     */
    toggleSolved(solved) {
        // toggle
        solved = (solved === undefined) ? !this.isSolved() : solved;
        return this.merge({
            solved
        });
    }

    /**
     * @param {String} sha
     * @return {Conflict}
     */
    solveWithSha(sha) {
        return this.merge({
            solved: true,
            solvedSha: sha,
            solvedContent: null
        });
    }

    /**
     * @param {Blob | String} content
     * @return {Conflict}
     */
    solveWithContent(content) {
        const blob = content instanceof Blob ? content : Blob.createFromString(content);
        return this.merge({
            solved: true,
            solvedSha: null,
            solvedContent: blob
        });
    }

    /**
     * @return {Conflict} Solved by removing the entry
     */
    solveByDeletion() {
        return this.merge({
            solved: true,
            solvedSha: null,
            solvedContent: null
        });
    }

    /**
     * @return {Conflict} Solved by keeping head's version
     */
    keepHead(content) {
        return this.solveWithSha(this.getHeadSha());
    }

    /**
     * @return {Conflict} Solved by keeping base's version
     */
    keepBase(content) {
        return this.solveWithSha(this.getBaseSha());
    }

    /**
     * @return {Conflict} Reset to unsolved state
     */
    resetUnsolved() {
        return this.merge({
            solved: false,
            solvedSha: null,
            solvedContent: null
        });
    }
}

// ---- Static ----

Conflict.create = function(parentSha, baseSha, headSha) {
    return new Conflict({
        parentSha,
        baseSha,
        headSha
    });
};

// ---- utils ----

/**
 * @param {SHA | Null} parent
 * @param {SHA | Null} sha
 * @return {Conflict.CHANGE}
 */
function getChange(parent, sha) {
    if (parent === sha) {
        return CHANGE.IDENTICAL;
    } else if (parent === null) {
        return CHANGE.ADDED;
    } else if (sha === null) {
        return CHANGE.DELETED;
    } else {
        // Both are not null but different
        return CHANGE.MODIFIED;
    }
}
Conflict.TYPE = TYPE;
Conflict.CHANGE = CHANGE;

module.exports = Conflict;


const Immutable = require('immutable');

const error = require('./error');
const TreeEntry = require('../models/treeEntry');
const CHANGES = require('../constants/changeType');

/**
 * Returns a Seq of tree mixing changes and the fetched tree
 * @param {WorkingState}
 * @return {Set<Path>}
 */
function getMergedFileSet(workingState) {
    return Immutable.Set.fromKeys(
        getMergedTreeEntries(workingState).filter(
            treeEntry => treeEntry.getType() === TreeEntry.TYPES.BLOB
        )
    );
}

/**
 * Returns a Map of TreeEntry, with sha null when the content is not available as sha.
 * @param {WorkingState}
 * @return {Map<TreeEntries>}
 */
function getMergedTreeEntries(workingState) {
    const removedOrModified = workingState.getChanges().groupBy((change, path) => {
        if (change.getType() === CHANGES.REMOVE) {
            return 'remove';
        } else {
            // Must be UDPATE or CREATE
            return 'modified';
        }
    });

    const setToRemove = Immutable.Set.fromKeys(removedOrModified.get('remove', []));

    const withoutRemoved = workingState.getTreeEntries().filter((treeEntry, path) => {
        return !setToRemove.contains(path);
    });

    const addedTreeEntries = removedOrModified.get('modified', []).map(
        function toTreeEntry(change) {
            return new TreeEntry({
                sha: change.hasSha() ? change.getSha() : null,
                mode: '100644'
            });
        }
    );

    return withoutRemoved.concat(addedTreeEntries);
}

/**
 * Returns true if the file differs from initial tree (including removed)
 * @return {Boolean}
 * @throws FILE_NOT_FOUND
 */
// TODO unused, remove
function hasPendingChanges(workingState, filepath) {
    // Lookup potential changes
    const change = workingState.getChanges().get(filepath);
    if (change) {
        return true;
    } else {
        // Else lookup tree
        const treeEntry = workingState.getTreeEntries().get(filepath);
        if (!treeEntry) {
            throw error.fileNotFound(filepath);
        } else {
            return false;
        }
    }
}

/**
 * Attempts to find a SHA if available for the  given file
 * @param {Path}
 * @return {Sha | Null} null if Sha cannot be retrieved (because of pending change)
 * @throws NOT_FOUND if the file does not exist or was removed
 */
function findSha(workingState, filepath) {
    // Lookup potential changes
    const change = workingState.getChanges().get(filepath);
    // Else lookup tree
    const treeEntry = workingState.getTreeEntries().get(filepath);

    if (change) {
        if (change.getType() == CHANGES.REMOVE) {
            throw error.fileNotFound(filepath);
        } else {
            return change.getSha();
        }
    } else if (treeEntry) {
        return treeEntry.getSha();
    } else {
        throw error.fileNotFound(filepath);
    }
}

/**
 * Fetch tree for a branch (using its SHA) and return an clean WorkingState for it
 * @param {Driver} driver
 * @param {Branch} branch
 */
function fetch(driver, branch) {
    // Fetch the tree
    return driver.fetchWorkingState(branch.getSha());
}

const WorkingUtils = {
    getMergedFileSet,
    getMergedTreeEntries,
    fetch,
    hasPendingChanges, // TODO remove, unused
    findSha
};
module.exports = WorkingUtils;


const Q = require('q');
const Immutable = require('immutable');

const ERRORS = require('../constants/errors');

const CommitBuilder = require('../models/commitBuilder');
const WorkingUtils = require('./working');
const RepoUtils = require('./repo');

/**
 * Create a commit builder from the changes on current branch
 * @param {RepositoryState}
 * @param {Author} opts.author
 * @param {String} [opts.message]
 * @return {CommitBuilder}
 */
function prepare(repoState, opts) {
    const workingState = repoState.getCurrentState();
    const changes = workingState.getChanges();

    // Is this an empty commit ?
    opts.empty = workingState.isClean();

    // Parent SHA
    opts.parents = new Immutable.List([
        workingState.getHead()
    ]);

    // Get merged tree (with applied changes)
    opts.treeEntries = WorkingUtils.getMergedTreeEntries(workingState);

    // Create map of blobs that needs to be created
    opts.blobs = changes.filter((change) => {
        return !change.hasSha();
    }).map((change) => {
        return change.getContent();
    });

    return CommitBuilder.create(opts);
}

/**
 * Flush a commit from the current branch using a driver
 * Then update the reference, and pull new workingState
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {CommitBuilder} commitBuilder
 * @param {Branch} [options.branch] Optional branch to use instead of
 * current branch
 * @param {Boolean} [options.ignoreEmpty=true] Empty commits are
 * ignored, unless they merge several branches.
 * @return {Promise<RepositoryState, ERRORS.NOT_FAST_FORWARD>} If the
 * branch cannot be fast forwarded to the created commit, fails with
 * NOT_FAST_FORWARD. The error will contains the created Commit.
 */
function flush(repoState, driver, commitBuilder, options = {}) {
    options = Object.assign({
        branch: repoState.getCurrentBranch(),
        ignoreEmpty: true
    }, options);

    if (options.ignoreEmpty
        && commitBuilder.isEmpty()
        && commitBuilder.getParents().count() < 2) {
        return Q(repoState);
    }

    // Create new commit
    return driver.flushCommit(commitBuilder)
    // Forward the branch
    .then((commit) => {
        return driver.forwardBranch(options.branch, commit.getSha())
        // Fetch new workingState and replace old one
        .then(function updateBranch() {
            const updated = options.branch.merge({ commit });
            return repoState.updateBranch(options.branch, updated);

        }, function nonFF(err) {
            if (err.code === ERRORS.NOT_FAST_FORWARD) {
                // Provide the created commit to allow merging it back.
                err.commit = commit;
            }
            throw err;
        });
    })
    .then(function updateWorkingState(forwardedRepoState) {
        const forwardedBranch = forwardedRepoState.getBranch(options.branch.getFullName());
        return RepoUtils.fetchTree(forwardedRepoState, driver, forwardedBranch);
    });
}

/**
 * @param {Driver} driver
 * @param {Branch} options.branch Branch to list commit on
 * @param {Ref} [options.ref] Use a ref or SHA instead of a branch
 * @param {Path} [options.path] Filter by file
 * @param {String} [options.author] Filter by author name
 * @param {Number} [options.per_page] Limits number of result
 * @return {Promise<List<Commit>>} The history of commits behind this
 * branch or ref. Recent first.
 */
function fetchList(driver, options) {
    // do we really need repoState as argument ?
    options = options || {};

    let ref;
    if (options.ref) {
        ref = options.ref;
    } else {
        ref = options.branch.getFullName();
    }

    options.ref = ref;

    return driver.listCommits(options);
}

/**
 * List all the commits reachable from head, but not from base. Most
 * recent first.
 * @param {Driver} driver
 * @param {Branch | SHA} base
 * @param {Branch | SHA} head
 * @return {Promise<List<Commit>>}
 */
function fetchOwnCommits(driver, base, head) {
    return driver.fetchOwnCommits(base, head);
}

/**
 * Get a single commit, with files patch
 * @param {Driver} driver
 * @param {SHA} sha
 * @return {Promise<Commit>}
 */
function fetch(driver, sha) {
    return driver.fetchCommit(sha);
}

/**
 * Compare two commits/branch
 * @param {Driver} driver
 * @param {SHA or Branch} base
 * @param {SHA or Branch} head
 * @return {Promise<Comparison>}
 */
function fetchComparison(driver, base, head) {
    return driver.fetchComparison(base, head);
}

const CommitUtils = {
    prepare,
    flush,
    fetchList,
    fetchOwnCommits,
    fetch,
    fetchComparison
};
module.exports = CommitUtils;


/* exported colors */
'use strict';

var colors = require('colors'),
    util   = require('util');

// writes info messages to the console
exports.info = function info() {
  console.log(format('green', arguments));
};

// writes warning messages to the console
exports.warn = function warn() {
  console.warn(format('yellow', arguments));
};

// writes error messages to the console
exports.error = function warn() {
  console.error(format('red', arguments));
};

// colorize strings and send to console.log
function format(color, messages) {
  var length = messages.length;

  if (length === 0 || typeof(color) !== 'string') {
    return;
  }

  return (util.format.apply(null, messages)[color]);
}

exports.banner = function banner() {
  util.puts([
    '',
    ' #####  ####   ####  #    #       #####  #####   ####  #    # #   #',
    '   #   #      #    # ##   #       #    # #    # #    #  #  #   # # ',
    '   #    ####  #    # # #  # ##### #    # #    # #    #   ##     #  ',
    '   #        # #    # #  # #       #####  #####  #    #   ##     #  ',
    '#  #        # #    # #   ##       #      #   #  #    #  #  #    #  ',
    '####   #####   ####  #    #       #      #    #  ####  #    #   #  ',
    ''
  ]
  .join('\n')
  .rainbow
  .bold
  );
};


const Promise = require('q');
const ERRORS = require('../constants/errors');
const RepoUtils = require('./repo');

/**
 * Push a local branch to a remote repository
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch} [opts.branch] Branch to push. Default to current
 * @param {String} [opts.remote.name=origin] Name of the remote
 * @param {String} [opts.remote.url] URL if the remote needs to be created
 * @param {Boolean} [opts.force=false] Ignore non fast forward
 * @param {String} [opts.auth.username] Authentication username
 * @param {String} [opts.auth.password] Authentication password
 * @return {Promise<RepositoryState>}
 * @throws {Promise<ERROR.NOT_FAST_FORWARD>}
 * @throws {Promise<ERROR.AUTHENTICATION_FAILED>}
 * @throws {Promise<ERROR.UNKNOWN_REMOTE>}
 */
function push(repoState, driver, opts = {}) {
    opts = Object.assign({
        branch: repoState.getCurrentBranch(),
        force: false,
        remote: {
            name: 'origin'
        }
    }, opts);

    return driver.push(opts) // Can fail with NOT_FAST_FORWARD
    // TODO update remote branch in repoState list of branches
    .thenResolve(repoState);
}

/**
 * Pulls changes for local branch, from remote repository. Loses any
 * pending changes on it.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch} [opts.branch] Branch to pull. Default to current
 * @param {String} [opts.remote.name=origin] Name of the remote
 * @param {String} [opts.remote.url] URL if the remote needs to be created
 * @param {Boolean} [opts.force=false] Ignore non fast forward
 * @param {String} [opts.auth.username] Authentication username
 * @param {String} [opts.auth.password] Authentication password
 * @return {Promise<RepositoryState>}
 * @throws {Promise<ERROR.NOT_FAST_FORWARD>}
 * @throws {Promise<ERROR.AUTHENTICATION_FAILED>}
 * @throws {Promise<ERROR.UNKNOWN_REMOTE>}
 */
function pull(repoState, driver, opts = {}) {
    opts = Object.assign({
        branch: repoState.getCurrentBranch(),
        force: false,
        remote: {
            name: 'origin'
        }
    }, opts);

    return driver.pull(opts)
    // Update branch SHA
    .then(() => {
        return driver.fetchBranches();
    })
    .then((branches) => {
        const updatedBranch = branches.find((br) => {
            return br.name === opts.branch.name;
        });
        repoState = repoState.updateBranch(opts.branch, updatedBranch);

        return RepoUtils.fetchTree(repoState, driver, updatedBranch);
    });
}

/**
 * List remotes on the repository.
 * @param {Driver} driver
 * @return {Promise<Array<{name, url}>>}
 */
function list(driver) {
    return driver.listRemotes();
}

/**
 * Edit or create a remote.
 * @param {Driver} driver
 * @param {String} name Name of the remote
 * @param {String} url New URL of the remote
 */
function edit(driver, name, url) {
    return driver.editRemotes(name, url);
}

/**
 * Sync repository with remote by pulling / pushing to the remote.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch} [opts.branch] Branch to push. Default to current
 * @param {String} [opts.remote.name=origin] Name of the remote
 * @param {String} [opts.remote.url] URL if the remote needs to be created
 * @param {Boolean} [opts.force=false] Ignore non fast forward
 * @param {String} [opts.auth.username] Authentication username
 * @param {String} [opts.auth.password] Authentication password
 * @return {Promise<RepositoryState>}
 * @throws {Promise<ERROR.NOT_FAST_FORWARD>}
 * @throws {Promise<ERROR.AUTHENTICATION_FAILED>}
 * @throws {Promise<ERROR.UNKNOWN_REMOTE>}
 */
function sync(repoState, driver, opts = {}) {
    opts = Object.assign({
        branch: repoState.getCurrentBranch(),
        force: false,
        remote: {
            name: 'origin'
        }
    }, opts);

    return pull(repoState, driver, opts)
        .fail((err) => {
            if (err.code === ERRORS.REF_NOT_FOUND) {
                return Promise(repoState);
            }

            return Promise.reject(err);
        })
        .then((newRepoState) => {
            return push(newRepoState, driver, opts);
        });
}

const RemoteUtils = {
    push,
    pull,
    list,
    edit,
    sync
};

module.exports = RemoteUtils;


const Immutable = require('immutable');
const Q = require('q');

const CommitBuilder = require('../models/commitBuilder');
const TreeEntry = require('../models/treeEntry');
const Branch = require('../models/branch');
const Conflict = require('../models/conflict');
const TreeConflict = require('../models/treeConflict');
const WorkingState = require('../models/workingState');

/**
 * Computes a TreeConflict between to tree references. Fetches the
 * trees from the repo. The list of conflicts is the minimal set of
 * conflicts.
 * @param {Driver} driver
 * @param {Branch | String} base A branch, branch name, or SHA
 * @param {Branch | String} head A branch, branch name, or SHA
 * @return {Promise<TreeConflict>}
 */
function compareRefs(driver, base, head) {
    const baseRef = base instanceof Branch ? base.getFullName() : base;
    const headRef = head instanceof Branch ? head.getFullName() : head;

    return driver.findParentCommit(baseRef, headRef)
    .then((parentCommit) => {
        // There can be no parent commit
        return Q.all([
            parentCommit ? parentCommit.getSha() : null,
            baseRef,
            headRef
        ].map((ref) => {
            return ref ? driver.fetchWorkingState(ref) : WorkingState.createEmpty();
        }));
    })
    .spread((parent, base, head) => {
        const conflicts = _compareTrees(parent.getTreeEntries(),
                                     base.getTreeEntries(),
                                     head.getTreeEntries());

        return new TreeConflict({
            base,
            head,
            parent,
            conflicts
        });
    });
}

/**
 * Merge solved Conflicts back into a TreeConflict. Unsolved conflicts
 * default to keep base.
 * @param {TreeConflict} treeConflict
 * @param {Map<Path, Conflict>} solved
 * @return {TreeConflict}
 */
function solveTree(treeConflict, solved) {
    solved = treeConflict.getConflicts()
    .merge(solved)
    // Solve unresolved conflicts
    .map(function defaultSolve(conflict) {
        if (!conflict.isSolved()) {
            return conflict.keepBase();
        } else {
            return conflict;
        }
    });
    return treeConflict.set('conflicts', solved);
}

/**
 * Create a merge commit builder
 * @param {TreeConflict} treeConflict The solved TreeConflict
 * @param {Array<SHA>} parents Parent commits
 * @param {Author} options.author
 * @param {String} [options.message='Merge commit']
 * @return {CommitBuilder}
 */
function mergeCommit(treeConflict, parents, options) {
    options = options || {};

    const opts = {};

    // Assume the commit is not empty
    opts.empty = false;

    // Parent SHAs
    opts.parents = new Immutable.List(parents);

    opts.author = options.author;
    opts.message = options.message || 'Merged commit';

    // Get the solved tree entries
    const solvedEntries = _getSolvedEntries(treeConflict);
    opts.treeEntries = solvedEntries;

    // Create map of blobs that needs to be created
    const solvedConflicts = treeConflict.getConflicts();
    opts.blobs = solvedEntries.filter((treeEntry) => {
        return !treeEntry.hasSha();
    }).map((treeEntry, path) => {
        return solvedConflicts.get(path).getSolvedContent();
    });

    return CommitBuilder.create(opts);
}

// ---- Auxiliaries ----

/**
 * @param {Map<Path, TreeEntry>} parentEntries
 * @param {Map<Path, TreeEntry>} baseEntries
 * @param {Map<Path, TreeEntry>} headEntries
 * @return {Map<Path, Conflict>} The minimal set of conflicts.
 */
function _compareTrees(parentEntries, baseEntries, headEntries) {
    const headDiff = _diffEntries(parentEntries, headEntries);
    const baseDiff = _diffEntries(parentEntries, baseEntries);

    // Conflicting paths are paths...
    // ... modified by both branches
    const headSet = Immutable.Set.fromKeys(headDiff);
    const baseSet = Immutable.Set.fromKeys(baseDiff);
    const conflictSet = headSet.intersect(baseSet).filter((filepath) => {
        // ...in different manners
        return !Immutable.is(headDiff.get(filepath), baseDiff.get(filepath));
    });

    // Create the map of Conflict
    return (new Immutable.Map()).withMutations((map) => {
        return conflictSet.reduce((map, filepath) => {
            const shas = [
                parentEntries,
                baseEntries,
                headEntries
            ].map(function getSha(entries) {
                if (!entries.has(filepath)) return null;
                return entries.get(filepath).getSha() || null;
            });

            return map.set(filepath, Conflict.create.apply(undefined, shas));
        }, map);
    });
}

/**
 * @param {Map<Path, TreeEntry>} parent
 * @param {Map<Path, TreeEntry>} child
 * @return {Map<Path, TreeEntry | Null>} The set of Path that have
 * been modified by the child. Null entries mean deletion.
 */
function _diffEntries(parent, child) {
    const parentKeys = Immutable.Set.fromKeys(parent);
    const childKeys = Immutable.Set.fromKeys(child);
    const all = parentKeys.union(childKeys);

    const changes = all.filter(function hasChanged(path) {
        // Removed unchanged
        return !Immutable.is(parent.get(path), child.get(path));
    });

    return (new Immutable.Map()).withMutations((map) => {
        return changes.reduce((map, path) => {
            // Add new TreeEntry or null when deleted
            const treeEntry = child.get(path) || null;
            return map.set(path, treeEntry);
        }, map);
    });
}

/**
 * Returns the final TreeEntries for a solved TreeConflict.
 * @param {TreeConflict} treeConflict
 * @return {Map<Path, TreeEntry>} Some TreeEntries have a null SHA
 * because of new solved content.
 */
function _getSolvedEntries(treeConflict) {
    const parentEntries = treeConflict.getParent().getTreeEntries();
    const baseEntries = treeConflict.getBase().getTreeEntries();
    const headEntries = treeConflict.getHead().getTreeEntries();

    const baseDiff = _diffEntries(parentEntries, baseEntries);
    const headDiff = _diffEntries(parentEntries, headEntries);

    const resolvedEntries = treeConflict.getConflicts().map((solvedConflict) => {
        // Convert to TreeEntries (or null for deletion)
        if (solvedConflict.isDeleted()) {
            return null;
        } else {
            return new TreeEntry({
                sha: solvedConflict.getSolvedSha() || null
            });
        }
    });

    return parentEntries.merge(baseDiff, headDiff, resolvedEntries)
    // Remove deleted entries
    .filter(function nonNull(entry) {
        return entry !== null;
    });
}

const ConflictUtils = {
    solveTree,
    mergeCommit,
    compareRefs,
    // Exposed for testing purpose
    _diffEntries,
    _getSolvedEntries,
    _compareTrees
};
module.exports = ConflictUtils;


/**
 * Add a new blob to a cache instance
 * @param {Cache} cache
 * @param {String} sha Used as key
 * @param {Blob} blob
 * @return {Cache}
 */
function addBlob(cache, sha, blob) {
    const blobs = cache.getBlobs();
    const newBlobs = blobs.set(sha, blob);

    const newCache = cache.set('blobs', newBlobs);
    return newCache;
}

const CacheUtils = {
    addBlob
};
module.exports = CacheUtils;


const Immutable = require('immutable');
const Path = require('path');

const File = require('../models/file');
const TreeNode = require('./treeNode');
const DirUtils = require('./directory');
const FileUtils = require('./file');
const WorkingUtils = require('./working');

/**
 * Utils to create tree structures for files.
 */

/**
 * Convert a filepath to a Seq<String> to use as key path
 * @param {Path} path
 * @return {Seq<String>}
 */
function pathToKeySeq(path) {
    // Remove trailing '/' etc.
    path = Path.join(path, '.');
    if (path === '.') {
        return Immutable.Seq([]);
    } else {
        return Immutable.Seq(path.split('/'));
    }
}

/**
 * Find a node at the given path in a TreeNode of Files.
 * @param {TreeNode<File>} tree
 * @param {Path} path
 * @return {TreeNode<File> | Null} null if no found
 */
function getInPath(tree, path) {
    return tree.getIn(pathToKeySeq(path));
}

/**
 * Generate a files tree from the current branch, taking pending changes into account.
 * @param {RepositoryState} repoState
 * @param {Path} [dir='.'] The directory to get the subtree from, default to root
 * @return {TreeNode<File>} The directory TreeNode with all its children
 */
function get(repoState, dirPath) {
    // Remove trailing '/' etc.
    const normDirPath = Path.join(dirPath, '.');
    const filepaths = DirUtils.readFilenamesRecursive(repoState, normDirPath);

    const tree = {
        value: File.createDir(normDirPath),
        children: {}
    };

    for (let i = 0; i < filepaths.length; i++) {
        const relativePath = Path.relative(normDirPath, filepaths[i]);
        const parts = relativePath.split('/');
        let node = tree;
        let prefix = normDirPath;
        for (let j = 0; j < parts.length; j++) {
            const head = parts[j];
            const isLeaf = (j === parts.length - 1);
            prefix = Path.join(prefix, head);

            // Create node if doesn't exist
            if (!node.children[head]) {
                if (isLeaf) {
                    node.children[head] = {
                        value: FileUtils.stat(repoState, filepaths[i])
                    };
                } else {
                    node.children[head] = {
                        value: File.createDir(prefix),
                        children: {}
                    };
                }
            }
            node = node.children[head];
        }
    }

    return TreeNode.fromJS(tree);
}

/**
 * @param {RepositoryState} previousRepo
 * @param {RepositoryState} newRepo
 * @return {Boolean} True if the files structure changed.
 */
function hasChanged(previousRepo, newRepo, dir) {
    const previousWorking = previousRepo.getCurrentState();
    const newWorking = newRepo.getCurrentState();

    const previousFiles = WorkingUtils.getMergedFileSet(previousWorking);
    const newFiles = WorkingUtils.getMergedFileSet(newWorking);

    return !Immutable.is(previousFiles, newFiles);
}

const TreeUtils = {
    TreeNode,
    get,
    getInPath,
    hasChanged
};
module.exports = TreeUtils;


const Q = require('q');
const { Record, List, Map } = require('immutable');
const axios = require('axios');
const urlJoin = require('urljoin.js');

const gravatar = require('../utils/gravatar');
const base64 = require('../utils/base64');
const Driver = require('./driver');
const ERRORS = require('../constants/errors');

const Blob = require('../models/blob');
const Branch = require('../models/branch');
const Commit = require('../models/commit');
const Author = require('../models/author');
const TreeEntry = require('../models/treeEntry');
const WorkingState = require('../models/workingState');
const LocalFile = require('../models/localFile');
const Reference = require('../models/reference');
const Comparison = require('../models/comparison');

/**
 * Options for the GitHub Driver
 */
const DEFAULTS = {
    // String: ID of the GitHub repository (ex: me/myrepo")
    repository: null,

    // API endpoint
    host: 'https://api.github.com',

    // Endpoint for RAW content
    rawhost: 'https://raw.githubusercontent.com',

    // Authentication for the API
    username: null,
    token: null,

    // Include auth token in raw url?
    includeTokenInRaw: false
};

class GitHubOptions extends Record(DEFAULTS) {}

// Driver for GitHub APIs and uHub
class GitHubDriver extends Driver {
    constructor(options) {
        super();
        this.options = new GitHubOptions(options);
    }

    // ---- Fetching ----

    fetchBlob(sha) {
        return this.get('git/blobs/' + sha)
        .then((r) => {
            return Blob.createFromBase64(r.content);
        });
    }

    // https://developer.github.com/v3/git/trees/
    fetchWorkingState(ref) {
        return this.get('git/trees/' + ref, {
            recursive: 1
        })
        .then((tree) => {
            const treeEntries = new Map().withMutations(
                function addEntries(map) {
                    tree.tree.map((entry) => {
                        // We ignore trees for now (we flatten the git tree)
                        if (entry.type === 'tree') return;

                        const treeEntry = new TreeEntry({
                            sha: entry.sha,
                            type: entry.type,
                            blobSize: entry.size,
                            mode: entry.mode
                        });
                        map.set(entry.path, treeEntry);
                    });
                }
            );
            return WorkingState.createWithTree(tree.sha, treeEntries);
        });
    }

    fetchBranches() {
        return this.get('branches')
        .then((branches) => {
            branches = branches.map((branch) => {
                // TODO properly detect remote
                return new Branch({
                    name: branch.name,
                    commit: normListedCommit(branch.commit),
                    // branch.is_local is undefined for GitHub
                    remote: branch.is_local === false ? 'origin' : ''
                });
            });

            return new List(branches);
        });
    }

    // ------ Flushing -----

    flushCommit(commitBuilder) {
        const that = this;

        // Create blobs required
        const blobPromises = commitBuilder.getBlobs().map((blob, filePath) => {
            return that.post('git/blobs', {
                content: blob.getAsBase64(),
                encoding: 'base64'
            })
            .then((ghBlob) => {
                return [filePath, ghBlob.sha];
            });
        });

        // Wait for all request to finish
        return Q.all(blobPromises.toArray())
        .then((result) => {
            // Recreate an object map from the list of [path, sha]
            return result.reduce((res, [key, value]) => {
                res[key] = value;
                return res;
            }, {});
        })
        // Create new tree
        .then((blobSHAs) => {
            const entries = commitBuilder.getTreeEntries().map((treeEntry, filePath) => {
                return {
                    path: filePath,
                    mode: treeEntry.getMode(),
                    type: treeEntry.getType(),
                    sha: blobSHAs[filePath] || treeEntry.getSha()
                };
            }).toArray();
            return that.post('git/trees', {
                tree: entries
            });
        })

        // Create the new commit
        .then((ghTree) => {
            const committer = commitBuilder.getCommitter();
            const author = commitBuilder.getAuthor();
            const payload = {
                committer: {
                    name: committer.getName(),
                    email: committer.getEmail()
                },
                author: {
                    name: author.getName(),
                    email: author.getEmail()
                },
                message: commitBuilder.getMessage(),
                parents: commitBuilder.getParents().toArray(),
                tree: ghTree.sha
            };
            return that.post('git/commits', payload);
        })
        .then(normCreatedCommit);
    }

    // https://developer.github.com/v3/repos/commits/#list-commits-on-a-repository
    listCommits(opts) {
        opts = opts || {};
        const apiOpts = {
            sha: opts.ref,
            path: opts.path,
            author: opts.author,
            per_page: opts.per_page
        };

        return this.get('commits', apiOpts)
        .then((commits) => {
            return new List(commits).map(normListedCommit);
        });
    }

    // https://developer.github.com/v3/repos/commits/#get-a-single-commit
    fetchCommit(sha) {
        return this.get('commits/' + sha)
        .then(normListedCommit);
    }

    /**
     * Compare two commits.
     * @param {Branch | SHA} base
     * @param {Branch | SHA} head
     * @return {Promise<Comparison>}
     */
    fetchComparison(base, head) {
        const refs = [base, head].map((x) => {
            return (x instanceof Branch) ? x.getFullName() : x;
        });

        return this.get('compare/' + refs[0] + '...' + refs[1])
        .then((res) => {
            return Comparison.create({
                commits: res.commits.map(normListedCommit),
                files: res.files,
                closest: normListedCommit(res.merge_base_commit),
                base,
                head
            });
        });
    }

    createRef(ref, sha) {
        return this.post('git/refs', {
            'ref': 'refs/heads/' + ref,
            sha
        });
    }

    forwardBranch(branch, sha) {
        return this.patch('git/refs/heads/' + branch.getName(), {
            sha
        })
        // Normalize cannot fast forward errors
        .fail(normNotFF);
    }

    createBranch(base, name) {
        return this.createRef(name, base.sha)
        .thenResolve(base.merge({ name }));
    }

    deleteBranch(branch) {
        return this.delete('git/refs/heads/' + branch.getName());
    }

    // https://developer.github.com/v3/repos/merging/
    merge(from, into, options) {
        const opts = options;
        const head = from instanceof Branch ? from.getFullName() : from;
        return this.post('merges', {
            base: into.getFullName(),
            head,
            commit_message: opts.message
        })
        .then((ghCommit) => {
            if (!ghCommit) {
                // No commit was needed
                return null;
            }
            // The format the same as in commit listing, without files
            ghCommit.files = [];
            return normListedCommit(ghCommit);
        })
        // Normalize merge conflict errors
        .fail(normConflict);
    }

    // ---- Only supported by uhub ----
    checkout(branch) {
        return this.post('local/checkout', {
            branch: branch ? branch.getFullName() : 'HEAD'
        });
    }

    listRemotes() {
        return this.get('local/remotes');
    }

    editRemotes(name, url) {
        return this.post('local/remotes', {
            name,
            url
        });
    }

    pull(opts = {}) {
        opts = Object.assign({
            force: false
        }, opts);

        // Convert to ref
        opts.branch = opts.branch.getName();

        return this.post('local/pull', opts)
        .fail(normNotFF)
        .fail(normAuth)
        .fail(normUnknownRemote)
        .fail(normRefNotFound);
    }

    push(opts = {}) {
        opts = Object.assign({
            force: false
        }, opts);

        // Convert to ref
        opts.branch = opts.branch.getName();

        return this.post('local/push', opts)
        .fail(normNotFF)
        .fail(normAuth)
        .fail(normUnknownRemote)
        .fail(normRefNotFound);
    }

    status(opts) {
        return this.get('local/status')
            .then((status) => {
                return {
                    files: new List(status.files).map((file) => {
                        return LocalFile.create(file);
                    }),

                    head: new Reference({
                        ref: status.head.ref,
                        sha: status.head.object.sha
                    })
                };
            });
    }

    track(opts) {
        const params = {
            message: opts.message,
            files: opts.files.map((file) => {
                return {
                    name: file.getFilename(),
                    status: file.getStatus()
                };
            }).toJS()
        };

        if (opts.author) {
            params.author = {
                name: opts.author.getName(),
                email: opts.author.getEmail(),
                date: opts.author.getDate()
            };
        }

        if (opts.committer) {
            params.committer = {
                name: opts.author.getName(),
                email: opts.author.getEmail(),
                date: opts.author.getDate()
            };
        }

        return this.post('local/track', params);
    }

    // API utilities

    /**
     * Execute an GitHub HTTP API request
     * @param {String} httpMethod 'get', 'post', etc.
     * @param {String} method name of the method
     * @param {Object} args Req. parameters for get, or json data for others
     */
    request(httpMethod, method, args) {
        const axiosOpts = {
            method: httpMethod,
            url: urlJoin(
                this.options.get('host'),
                '/repos/' + this.options.get('repository') + '/' + method
            ) + '?t=' + Date.now(),
            headers: {
                'Accept': 'application/vnd.github.v3+json',
                'Content-type': 'application/json;charset=UTF-8'
            }
        };

        const username = this.options.get('username');
        const token = this.options.get('token');

        if (username && token) {
            axiosOpts.headers['Authorization'] = 'Basic ' + base64.encode(username + ':' + token);
        } else if (token) {
            axiosOpts.headers['Authorization'] = 'Token ' + token;
        }

        if (httpMethod == 'get') axiosOpts.params = args;
        else axiosOpts.data = args;

        // console.log('API', httpMethod.toUpperCase(), method);
        return Q(axios(axiosOpts))
        .get('data')
        .fail((response) => {
            if (response instanceof Error) throw response;

            const e = new Error(response.data.message || 'Error ' + response.status + ': ' + response.data);
            e.statusCode = response.status;

            throw e;
        });
    }

    // Shortcuts for API requests
    get(method, args) {
        return this.request('get', method, args);
    }

    post(method, args) {
        return this.request('post', method, args);
    }

    patch(method, args) {
        return this.request('patch', method, args);
    }

    delete(method, args) {
        return this.request('delete', method, args);
    }

    put(method, args) {
        return this.request('put', method, args);
    }
}

function normNotFF(err) {
    const msg = err.message;
    if (/fast forward/.test(msg)) {
        err.code = ERRORS.NOT_FAST_FORWARD;
    }
    return Q.reject(err);
}

function normConflict(err) {
    const msg = err.message;
    if (/merge conflict/i.test(msg)) {
        err.code = ERRORS.CONFLICT;
    }
    return Q.reject(err);
}

function normAuth(err) {
    const msg = err.message;
    if (/Failed to authenticate/.test(msg)
        || /401/.test(msg)
        || /auth schemes/.test(msg)) {
        err.code = ERRORS.AUTHENTICATION_FAILED;
    }

    return Q.reject(err);
}

function normUnknownRemote(err) {
    const msg = err.message;
    if (/specify a URL/.test(msg)
        || /specify a remote/.test(msg)) {
        err.code = ERRORS.UNKNOWN_REMOTE;
    }

    return Q.reject(err);
}

function normRefNotFound(err) {
    const msg = err.message;
    if (/^Reference/.test(msg) && /not found$/.test(msg) || err.statusCode == 404) {
        err.code = ERRORS.REF_NOT_FOUND;
    }

    return Q.reject(err);
}

/**
 * Normalize a commit coming from the GitHub commit creation API
 * @param {JSON} ghCommit
 * @return {Commit}
 */
function normCreatedCommit(ghCommit) {
    const commit = Commit.create({
        sha: ghCommit.sha,
        message: ghCommit.message,
        author: getSimpleAuthor(ghCommit.author),
        date: ghCommit.author.date,
        parents: ghCommit.parents.map(function getSha(o) { return o.sha; })
    });

    return commit;
}

/**
 * Normalize a commit coming from the GitHub commit listing API
 * @param {JSON} ghCommit
 * @return {Commit}
 */
function normListedCommit(ghCommit) {
    const commit = Commit.create({
        sha: ghCommit.sha,
        message: ghCommit.commit.message,
        author: getCompleteAuthor(ghCommit),
        date: ghCommit.commit.author.date,
        files: ghCommit.files,
        parents: ghCommit.parents.map(c => c.sha)
    });

    return commit;
}

// Get author from created commit (no avatar)
function getSimpleAuthor(author) {
    return Author.create({
        name: author.name,
        email: author.email,
        date: author.date
    });
}

// Get author from a listed commit (with avatar)
function getCompleteAuthor(commit) {
    const author = getSimpleAuthor(commit.commit.author);
    const avatar = commit.author ?
            commit.author.avatar_url
            : gravatar.url(author.getEmail());
    return author.set('avatar', avatar);
}

module.exports = GitHubDriver;


const Q = require('q');
const bufferUtils = require('./arraybuffer');

const FILETYPE = require('../constants/filetype');
const Change = require('../models/change');
const File = require('../models/file');

const error = require('./error');
const ChangeUtils = require('./change');
const WorkingUtils = require('./working');
const BlobUtils = require('./blob');

/**
 * Fetch a file blob. Required for content access with
 * stat/read. No-op if the file is already fetched.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Path} filepath
 * @return {Promise<RepositoryState>}
 */
function fetch(repoState, driver, filepath) {
    if (isFetched(repoState, filepath)) {
        // No op if already fetched
        return Q(repoState);
    }

    const workingState = repoState.getCurrentState();
    const blobSha = WorkingUtils.findSha(workingState, filepath);

    return BlobUtils.fetch(repoState, driver, blobSha);
}

/**
 * @param {RepositoryState} repoState
 * @param {Path} filepath
 * @return {Boolean} True if the file's content is in cache
 */
function isFetched(repoState, filepath) {
    const workingState = repoState.getCurrentState();
    const blobSha = WorkingUtils.findSha(workingState, filepath);
    // If sha is null then there are changes (those which are stored
    // and need not be fetched)
    return (blobSha === null) || BlobUtils.isFetched(repoState, blobSha);
}

/**
 * Stat details about a file.
 * @param {RepositoryState} repoState
 * @param {Path} filepath
 * @return {File}
 */
function stat(repoState, filepath) {
    const workingState = repoState.getCurrentState();

    // Lookup potential changes
    const change = workingState.getChanges().get(filepath);
    // Lookup file entry
    const treeEntry = workingState.getTreeEntries().get(filepath);

    // Determine SHA of the blob
    let blobSHA;
    if (change) {
        blobSHA = change.getSha();
    } else {
        blobSHA = treeEntry.getSha();
    }

    // Get the blob from change or cache
    let blob;
    if (blobSHA) {
        // Get content from cache
        blob = repoState.getCache().getBlob(blobSHA);
    } else {
        // No sha, so it must be in changes
        blob = change.getContent();
    }

    let fileSize;
    if (blob) {
        fileSize = blob.getByteLength();
    } else {
        // It might have been moved (but not fetched)
        const originalEntry = workingState.getTreeEntries().find((entry) => {
            return entry.getSha() === blobSHA;
        });
        fileSize = originalEntry.getBlobSize();
    }

    return new File({
        type: FILETYPE.FILE,
        fileSize,
        path: filepath,
        content: blob
    });
}

/**
 * Read content of a file
 * @param {Path} filepath
 * @return {Blob}
 */
function read(repoState, filepath) {
    const file = stat(repoState, filepath);
    return file.getContent();
}

/**
 * Read content of a file, returns a String
 * @return {String}
 */
function readAsString(repoState, filepath, encoding) {
    const blob = read(repoState, filepath);
    return blob.getAsString(encoding);
}

/**
 * Return true if file exists in working tree, false otherwise
 */
function exists(repoState, filepath) {
    const workingState = repoState.getCurrentState();
    const mergedFileSet = WorkingUtils.getMergedTreeEntries(workingState);

    return mergedFileSet.has(filepath);
}

/**
 * Create a new file (must not exists already)
 * @param {RepositoryState} repoState
 * @param {Path} filepath
 * @param {String} [content='']
 * @return {RepositoryState}
 */
function create(repoState, filepath, content) {
    content = content || '';
    if (exists(repoState, filepath)) {
        throw error.fileAlreadyExist(filepath);
    }
    const change = Change.createCreate(content);
    return ChangeUtils.setChange(repoState, filepath, change);
}

/**
 * Write a file (must exists)
 * @return {RepositoryState}
 */
function write(repoState, filepath, content) {
    if (!exists(repoState, filepath)) {
        throw error.fileNotFound(filepath);
    }

    const change = Change.createUpdate(content);
    return ChangeUtils.setChange(repoState, filepath, change);
}

/**
 * Remove a file
 * @return {RepositoryState}
 */
function remove(repoState, filepath) {
    if (!exists(repoState, filepath)) {
        throw error.fileNotFound(filepath);
    }

    const change = Change.createRemove();
    return ChangeUtils.setChange(repoState, filepath, change);
}

/**
 * Rename a file
 * @return {RepositoryState}
 */
function move(repoState, filepath, newFilepath) {
    if (filepath === newFilepath) {
        return repoState;
    }

    const initialWorkingState = repoState.getCurrentState();

    // Create new file, with Sha if possible
    const sha = WorkingUtils.findSha(initialWorkingState, filepath);
    let changeNewFile;
    if (sha) {
        changeNewFile = Change.createCreateFromSha(sha);
    } else {
        // Content not available as blob
        const blob = read(repoState, filepath);
        const contentBuffer = blob.getAsBuffer();
        changeNewFile = Change.createCreate(contentBuffer);
    }

    // Remove old file
    const removedRepoState = remove(repoState, filepath);
    // Add new file
    return ChangeUtils.setChange(removedRepoState, newFilepath, changeNewFile);
}

/**
 * Returns true if the given file has the same content in both
 * RepositoryState's current working state, or is absent from both.
 * @param {RepositoryState} previousState
 * @param {RepositoryState} newState
 * @param {Path} filepath
 * @return {Boolean}
 */
function hasChanged(previousState, newState, filepath) {
    const previouslyExists = exists(previousState, filepath);
    const newExists = exists(newState, filepath);
    if (!previouslyExists && !newExists) {
        // Still non existing
        return false;
    } else if (exists(previousState, filepath) !== exists(newState, filepath)) {
        // The file is absent from one
        return true;
    } else {
        // Both files exist
        const prevWorking = previousState.getCurrentState();
        const newWorking = newState.getCurrentState();

        const prevSha = WorkingUtils.findSha(prevWorking, filepath);
        const newSha = WorkingUtils.findSha(newWorking, filepath);
        if (prevSha === null && newSha === null) {
            // Both have are in pending changes. We can compare their contents
            return read(previousState, filepath).getAsString() !==
                read(newState, filepath).getAsString();
        } else {
            // Content changed if Shas are different, or one of them is null
            return prevSha !== newSha;
        }
    }
}

const FileUtils = {
    stat,
    fetch,
    isFetched,
    exists,
    read,
    readAsString,
    create,
    write,
    remove,
    move,
    hasChanged
};
module.exports = FileUtils;


/**
 * Reusable code for setup and teardown of the reusable specs
 * for testing the proxy code.
 */
var fs      = require('fs'),
    path    = require('path'),
    http    = require('http'),
    tmp     = require('tmp'),
    nock    = require('nock'),
    async   = require('async'),
    express = require('express'),
    app     = express(),
    proxy   = require('../../lib/proxy');

// global variable to track handles to open ports to be closed
var handles;

// global variable to track nock rules to assert at the end of a suite
var rules;

/**
 * Initializes the testing infrastructure needed for
 * verifying the behavior of the core proxy library -
 * nock, express, and httpServer.
 *
 * @param  {String}   connection one of 'direct', 'proxy', or 'non-rfc-proxy'
 * @param  {Function} done    the async callback
 */
function setup(connection, done) {
  var config = createDefaultConfig(),
      options = {
        proxy: false,
        headers: {}
      };

  // reset the global variable with handles to port numbers
  handles = {};

  if (connection !== 'direct') {
    options.proxy = true;

    config.proxy.gateway = {
      protocol: 'http:',
      host: 'localhost',
      port: 0,
      auth: 'proxyuser:C0mp13x_!d0rd$$@P!'
    };

    // optionally test a non-RFC proxy that expects explicit values
    // for the  Via and/or Host request headers
    if (connection === 'non-rfc-proxy') {
      config.proxy.headers['Via'] = 'http://jedi.example.com';
      config.proxy.headers['Host'] = 'force.example.com';
    }

    // the config map will be mutated by the json-proxy library, so clone it
    options.headers = require('util')._extend(config.proxy.headers);

    configureLanProxy(options, config, function() {
      configureNock(options, config);
      configureExpress(config, done);
    })
  } else {
    configureNock(options, config);
    configureExpress(config, done);
  }
}

/**
 * Returns a default json-proxy config object for the reusable test suites.
 */
function createDefaultConfig() {
  /* HACK: mocha runs tests in parellel, so we have to force ignoring of env variables
  by explicity setting all config items to be non-null */
  return {
    server: {},
    proxy: {
      gateway: {},
      forward: {
        '/api/': 'http://api.example.com',
        '/rewrite/v1/(.*)': 'http://rewrite.example.com/$1',
        '/rewrite/v2/(.*)/unwanted_token/(.*)': 'http://rewrite.example.com/$1/$2',
        '/junction/': 'http://www.example.com/subapp',
        '/remote-api/(.*)': 'https://api.example.biz/$1',
        '/foo/\\d+/bar': 'http://www.example.com/',
        '/user/(\\d+)/email/(\\S+)\?(.*)': 'http://api.example.com/account?id=$1&email=$2&sort=asc',
        '/secure/': 'https://secure.example.com',
        '/dns-error/': 'https://notfound.example.com',
        '/pull/15/(.*)': 'https://authorization.example.com/$1'
      },
      headers: {
        'X-Test-Header': 'John Doe',
        'X-Test-Header-Function': function(req) {
          if (req.url === '/pull/15/token') {
            return 'Bearer 0123456789abcdef';
          }
        }
      }
    }
  };
}

/**
 * configures nock globally for a test run
 *
 * @param  {Object.<string, string>} options  the options for configuring this
 *                                            test suite
 * @param  {Object.<string, string>} config   the config of the json-proxy for
 *                                            this test suite
 * @returns {Array}  an array of configured nock instances
 */
function configureNock(options, config) {
  var result = {};
  // deny all real net connections except for localhost
  nock.disableNetConnect();
  nock.enableNetConnect('localhost');

  function createNock(url) {
    var instance = nock(url),
        expectedViaHeader = options.headers['Via'] ||
                            'http://localhost:' + config.proxy.gateway.port,
        expectedHostHeader = options.headers['Host'] || /.*/;

    if (options.proxy === true) {
      // verify that the request was actually proxied
      // optionally support downstream proxies with non-RFC expectations on
      // the Host and Via request headers
      instance.matchHeader('via', expectedViaHeader);
      instance.matchHeader('host', expectedHostHeader);
    }

    // verify the injected header
    instance.matchHeader('x-test-header', 'John Doe');

    return instance;
  }

  rules = [
    createNock('http://api.example.com')
    .get('/api/hello')
    .reply(200, '{ "hello": "world" }')
    .get('/account?id=1&email=2&sort=asc')
    .reply(200, '{ "email": "john.doe@example.com" }')
    .get('/api/notfound')
    .reply(404),

    createNock('http://rewrite.example.com')
    .get('/hello')
    .reply(200, '{ "hello": "world" }')
    .get('/foo/bar')
    .reply(200, '{ "foo": "bar" }'),

    createNock('http://www.example.com')
    .get('/foo/12345/bar')
    .reply(200, '{ "foo": "bar" }')
    .get('/subapp/junction/customer/1')
    .reply(200, '{ "id": 1 }'),

    createNock('https://api.example.biz')
    .get('/issue/8')
    .reply(200, '{ "reporter": "@heygrady" }'),

    createNock('https://secure.example.com')
    .get('/secure/api/hello')
    .reply(200, '{ "hello": "world" }')
    .get('/secure/api/notfound')
    .reply(404),

    createNock('https://authorization.example.com')
    .matchHeader('X-Test-Header-Function', 'Bearer 0123456789abcdef')
    .get('/token')
    .reply(200, '{ "author": "ehtb" }')
  ];
}

/**
 * Configures an express instance on a dynamically assigned port
 * for serving static files and proxying requests based on the config.
 *
 * @param  {Object.<string, string>} config   the config of the json-proxy for
 *                                            this test suite
 * @param  {Function} done callback
 */
function configureExpress(config, done) {
  var portfinder = require('portfinder');

  tmp.dir(function(err, filepath){
    handles.filepath = filepath;

    portfinder.getPort(function (err, port) {
      if (err) throw(err);

      handles.port = port;

      fs.writeFileSync(path.join(handles.filepath, 'index.txt'), 'hello, world');

      app.use(proxy.initialize(config));
      app.use(express.static(handles.filepath));

      handles.server = require('http').createServer(app);
      handles.server.listen(handles.port, function() {
        done(null, handles.port);
      });
    });
  });
}

/**
 * Creates a simple LAN proxy using a vanilla HTTP server
 * that verifies the state of the proxy credentials and the x-forwarded-url
 * are correct.
 *
 * @param  {Object.<string, string>} options  the options for configuring this
 *                                            test suite
 * @param  {Object.<string, string>} config   the config of the json-proxy for
 *                                            this test suite
 * @param  {Function} done
 */
function configureLanProxy(options, config, done) {
  var portfinder = require('portfinder'),
      request = require('request'),
      credentials = config.proxy.gateway.auth,
      gatewayPort,
      expectedAuthorizationHeader,
      requestViaHeader,
      responseViaHeader;

  handles = handles || {};

  handles.gatewayServer = require('http').createServer(function (req, res) {
    expectedAuthorizationHeader = 'Basic ' + new Buffer(credentials).toString('base64');

    // HACK: node 0.12.x appears to inject a slash at the front
    //       of absolute URLs
    //       ex., GET http://www.example.com --> GET /http://www.exampel.com
    if (req.url.charAt(0) === '/') {
      req.url = req.url.substr(1);
    }

    // validate the proxy target
    if (req.url !== req.headers['x-forwarded-url']) {
        res.writeHead(500);
        res.end('{ "error": 500, "message": "invalid proxy request, expected X-Forwarded-Url header ' + req.headers['x-forwarded-url'] + '" }');
        return;
    }

    // validate the proxy credentials
    if (req.headers['authorization'] !== expectedAuthorizationHeader) {
      res.writeHead(401);
      res.end('{ "error": 401, "message": "invalid proxy credentials, expected ' + expectedAuthorizationHeader + '" }');
      return;
    }

    // determine if we are using a proxy that is not RFC compliant
    requestViaHeader = options.headers['Via'] ||
                        '127.0.0.1:' + handles.port;

    responseHostHeader = options.headers['Host'] ||
                        req.headers['host'];

    responseViaHeader = options.headers['Via'] ||
                        'http://localhost:' + gatewayPort;

    // validate the via header was injected and points to 127.0.0.1 in either ipv4 or ipv6 format
    if (req.headers['via'] === undefined || req.headers['via'] === null || req.headers['via'].indexOf(requestViaHeader) === -1) {
      res.writeHead(400);
      res.end('{ "error": 400, "message": "invalid via header, expected ' + requestViaHeader + '" }');
      return;
    }

    // strip the proxy credentials header
    req.headers['authorization'] = null;
    // simulate the behavior of x-forwarded-for with multiple proxies
    req.headers['x-forwarded-for'] = [req.headers['x-forwarded-for'], req.headers['via']].join(', ');
    // change the via header to this server
    req.headers['via'] = responseViaHeader;
    req.headers['host'] = responseHostHeader;

    var errorCallback = function errorCallback(err, repsonse, body) {
      if (err) {
        res.writeHead(500);
        res.end(JSON.stringify({ "error": 500, "message": err.message  }));
        return;
      }
    }

    request(req, errorCallback).pipe(res);
  });

  portfinder.getPort(function (err, port) {
    if (err) done(err);

    config.proxy.gateway.port = port;
    gatewayPort = port;

    handles.gatewayServer.listen(port, function() {
      done(null);
    });
  });
}

/**
 * Teardown logic for the reusable test suites
 * @param  {Function} done the async callback
 */
function cleanup(done) {
  config = null;

  rules.forEach(function(rule){
    rule.done();
  });
  nock.cleanAll();

  handles.server.close();
  if (handles.gatewayServer !== undefined && handles.gatewayServer !== null) {
    handles.gatewayServer.close();
  }

  fs.unlinkSync(path.join(handles.filepath, '/index.txt'));

  handles = null;

  done();
};

exports.setup = setup;
exports.configureLanProxy = configureLanProxy;
exports.cleanup = cleanup;


const Immutable = require('immutable');

const CHANGE_TYPE = require('../constants/changeType');
const RepoUtils = require('./repo');
const PathUtils = require('./path');

/**
 * Returns the pending change of a file on the current branch
 * @param {RepositoryState} state
 * @param {Path} filepath
 * @return {Change | Null} Null if no change, or the file does not exist
 */
function getChange(state, filepath) {
    return state.getCurrentState().getChanges().get(filepath)
        || null;
}

/**
 * Set a new change to the current WorkingState.
 * Attempt to resolve some cases like removing a file that was added
 * in the first place.
 * @param {RepositoryState}
 * @param {String}
 * @param {Change}
 */
function setChange(repoState, filepath, change) {
    let workingState = repoState.getCurrentState();
    let changes = workingState.getChanges();
    const type = change.getType();

    // Simplify change when possible
    if (type === CHANGE_TYPE.REMOVE
        && !workingState.getTreeEntries().has(filepath)) {
        // Removing a file that did not exist before
        changes = changes.delete(filepath);

    } else if (type === CHANGE_TYPE.CREATE
               && workingState.getTreeEntries().has(filepath)) {
        // Adding back a file that existed already
        changes = changes.set(filepath, change.set('type', CHANGE_TYPE.UPDATE));

    } else {
        // Push changes to list
        changes = changes.set(filepath, change);
    }

    // Update workingState and repoState
    workingState = workingState.set('changes', changes);
    return RepoUtils.updateCurrentWorkingState(repoState, workingState);
}

/**
 * Revert all changes
 * @param {RepositoryState}
 * @return {RepositoryState}
 */
function revertAll(repoState) {
    let workingState = repoState.getCurrentState();

    // Create empty list of changes
    const changes = new Immutable.OrderedMap();

    // Update workingState and repoState
    workingState = workingState.set('changes', changes);
    return RepoUtils.updateCurrentWorkingState(repoState, workingState);
}

/**
 * Revert change for a specific file
 * @param {RepositoryState}
 * @param {Path}
 * @return {RepositoryState}
 */
function revertForFile(repoState, filePath) {
    let workingState = repoState.getCurrentState();

    // Remove file from changes map
    const changes = workingState.getChanges().delete(filePath);

    // Update workingState and repoState
    workingState = workingState.set('changes', changes);
    return RepoUtils.updateCurrentWorkingState(repoState, workingState);
}

/**
 * Revert changes for a specific directory
 * @param {RepositoryState}
 * @param {Path}
 * @return {RepositoryState}
 */
function revertForDir(repoState, dirPath) {
    let workingState = repoState.getCurrentState();
    let changes = workingState.getChanges();

    // Remove all changes that are in the directory
    changes = changes.filter((change, filePath) => {
        return !PathUtils.contains(dirPath, filePath);
    });

    // Update workingState and repoState
    workingState = workingState.set('changes', changes);
    return RepoUtils.updateCurrentWorkingState(repoState, workingState);
}

/**
 * Revert all removed files
 * @param {RepositoryState}
 * @return {RepositoryState}
 */
function revertAllRemoved(repoState) {
    let workingState = repoState.getCurrentState();
    const changes = workingState.getChanges().filter(
        // Remove all changes that are in the directory
        (change) => {
            return change.getType() === CHANGE_TYPE.REMOVE;
        }
    );

    // Update workingState and repoState
    workingState = workingState.set('changes', changes);
    return RepoUtils.updateCurrentWorkingState(repoState, workingState);
}

const ChangeUtils = {
    getChange,
    setChange,
    revertAll,
    revertForFile,
    revertForDir,
    revertAllRemoved
};
module.exports = ChangeUtils;


const path = require('path');

/**
 * Normalize a path
 */
function normPath(p) {
    p = path.normalize(p);
    if (p[0] == '/') p = p.slice(1);
    if (p[p.length - 1] == '/') p = p.slice(0, -1);
    if (p == '.') p = '';
    return p;
}

/**
 * Returns true if the path is under dir
 */
function pathContains(dir, path) {
    dir = dir ? normPath(dir) + '/' : dir;
    path = normPath(path);

    return path.indexOf(dir) === 0;
}

module.exports = {
    norm: normPath,
    contains: pathContains
};


const Path = require('path');
const flatten = require('array-flatten');
const uniqueBy = require('unique-by');

const FILETYPE = require('../constants/filetype');
const File = require('../models/file');
const TreeEntry = require('../models/treeEntry');

const PathUtils = require('./path');
const WorkingUtils = require('./working');
const FileUtils = require('./file');

/**
 * List files in a directory (shallow)
 * @param {RepositoryState} repoState
 * @param {Path} dirName
 * @return {Array<File>}
 */
function read(repoState, dirName) {
    dirName = PathUtils.norm(dirName);

    const workingState = repoState.getCurrentState();
    const changes = workingState.getChanges();
    const treeEntries = WorkingUtils.getMergedTreeEntries(workingState);

    const files = [];

    treeEntries.forEach((treeEntry, filepath) => {
        // Ignore git submodules
        if (treeEntry.getType() !== TreeEntry.TYPES.BLOB) return;
        if (!PathUtils.contains(dirName, filepath)) return;

        const innerPath = PathUtils.norm(filepath.replace(dirName, ''));
        const isDirectory = innerPath.indexOf('/') >= 0;
        // Make it shallow
        const name = innerPath.split('/')[0];

        const file = new File({
            path: Path.join(dirName, name),
            type: isDirectory ? FILETYPE.DIRECTORY : FILETYPE.FILE,
            change: changes.get(filepath),
            fileSize: treeEntry.blobSize
        });

        files.push(file);
    });

    // Remove duplicate from entries within directories
    return uniqueBy(files, (file) => {
        return file.getName();
    });
}

/**
 * List files and directories in a directory (recursive).
 * Warning: This recursive implementation is very costly.
 * @param {RepositoryState} repoState
 * @param {Path} dirName
 * @return {Array<File>}
 */
function readRecursive(repoState, dirName) {
    // TODO improve performance and don't use .read() directly
    const files = read(repoState, dirName);

    let filesInDirs = files
        .filter((file) => {
            return file.isDirectory();
        })
        .map((dir) => {
            return readRecursive(repoState, dir.path);
        });

    filesInDirs = flatten(filesInDirs);

    return Array.prototype.concat(files, filesInDirs);
}

/**
 * List files in a directory (shallow)
 * @param {RepositoryState} repoState
 * @param {Path} dirName
 * @return {Array<Path>}
 */
function readFilenames(repoState, dirName) {
    const files = read(repoState, dirName);

    return files.map((file) => {
        return file.getPath();
    });
}

/**
 * List files recursively in a directory
 * @param {RepositoryState} repoState
 * @param {Path} dirName
 * @return {Array<Path>}
 */
function readFilenamesRecursive(repoState, dirName) {
    dirName = PathUtils.norm(dirName);

    const workingState = repoState.getCurrentState();
    const fileSet = WorkingUtils.getMergedFileSet(workingState);

    return fileSet.filter((path) => {
        return PathUtils.contains(dirName, path);
    }).toArray();
}

/**
 * Rename a directory
 */
function move(repoState, dirName, newDirName) {
    // List entries to move
    const filesToMove = readFilenamesRecursive(repoState, dirName);

    // Push change to remove all entries
    return filesToMove.reduce((repoState, oldPath) => {
        const newPath = Path.join(
            newDirName,
            Path.relative(dirName, oldPath)
        );

        return FileUtils.move(repoState, oldPath, newPath);
    }, repoState);
}

/**
 * Remove a directory: push REMOVE changes for all entries in the directory
 */
function remove(repoState, dirName) {
    // List entries to move
    const filesToRemove = readFilenamesRecursive(repoState, dirName);

    // Push change to remove all entries
    return filesToRemove.reduce((repoState, path) => {
        return FileUtils.remove(repoState, path);
    }, repoState);
}

const DirUtils = {
    read,
    readRecursive,
    readFilenames,
    readFilenamesRecursive,
    remove,
    move
};
module.exports = DirUtils;


const Normalize = require('./normalize');
const RepoUtils = require('./repo');

/**
 * Create a new branch with the given name.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {String} name
 * @param {Branch} [opts.base] Base branch, default to current branch
 * @param {Boolean} [opts.checkout=false] Directly fetch and checkout the branch
 * @return {Promise<RepositoryState>}
 */
function create(repositoryState, driver, name, opts = {}) {
    const {
        // Base branch for the new branch
        base = repositoryState.getCurrentBranch(),
        // Fetch the working state and switch to it ?
        checkout = true,
        // Drop changes from base branch the new working state ?
        clean = true,
        // Drop changes from the base branch ?
        cleanBase = false
    } = opts;

    let createdBranch;

    return driver.createBranch(base, name)
    // Update list of branches
    .then((branch) => {
        createdBranch = branch;
        let branches = repositoryState.getBranches();
        branches = branches.push(createdBranch);
        return repositoryState.set('branches', branches);
    })

    // Update working state or fetch it if needed
    .then((repoState) => {
        let baseWk = repoState.getWorkingStateForBranch(base);

        if (!baseWk) {
            return checkout ? RepoUtils.fetchTree(repoState, driver, createdBranch) : repoState;
        }

        // Reuse base WorkingState clean
        const headWk = clean ? baseWk.asClean() : baseWk;
        repoState = RepoUtils.updateWorkingState(repoState, createdBranch, headWk);

        // Clean base WorkingState
        baseWk = cleanBase ? baseWk.asClean() : baseWk;
        repoState = RepoUtils.updateWorkingState(repoState, base, baseWk);

        return repoState;
    })

    // Checkout the branch
    .then((repoState) => {
        if (!checkout) {
            return repoState;
        }

        return RepoUtils.checkout(repoState, createdBranch);
    });
}

/**
 * Fetch the list of branches, and update the given branch only. Will update
 * the WorkingState of the branch (and discard previous
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch | String} branchName The branch to update
 * @return {Promise<RepositoryState>} with the branch updated
 */
function update(repoState, driver, branchName) {
    branchName = Normalize.branchName(branchName || repoState.getCurrentBranch());

    return driver.fetchBranches()
    .then((branches) => {
        const newBranch = branches.find((branch) => {
            return branch.getFullName() === branchName;
        });

        if (!newBranch) {
            return repoState;
        } else {
            return RepoUtils.fetchTree(repoState, driver, newBranch);
        }
    });
}

/**
 * Remove the given branch from the repository.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch} branch to remove
 * @return {Promise<RepositoryState>}
 */
function remove(repoState, driver, branch) {
    return driver.deleteBranch(branch)
    .then(() => {
        return repoState.updateBranch(branch, null);
    });
}

/**
 * Merge a branch/commit into a branch, and update that branch's tree.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {Branch | SHA} from The branch to merge from, or a commit SHA
 * @param {Branch} into The branch to merge into, receives the new
 * commit. Must be clean
 * @param {String} [options.message] Merge commit message
 * @param {Boolean} [options.fetch=true] Fetch the updated tree on `into` branch ?
 * @return {Promise<RepositoryState, ERRORS.CONFLICT>} Fails with
 * CONFLICT error if automatic merge is not possible
 */
function merge(repoState, driver, from, into, options = {}) {
    options = Object.assign({
        fetch: true
    }, options);

    let updatedInto; // closure

    return driver.merge(from, into, {
        message: options.message
    }) // Can fail here with ERRORS.CONFLICT
    .then(function updateInto(mergeCommit) {
        if (!mergeCommit) {
            // Was a no op
            return repoState;
        } else {
            updatedInto = into.merge({ commit: mergeCommit });
            repoState = repoState.updateBranch(into, updatedInto);
            // invalidate working state
            return RepoUtils.updateWorkingState(repoState, into, null);
        }
    })
    .then(function fetchTree(repositoryState) {
        if (!options.fetch) {
            return repositoryState;
        } else {
            return RepoUtils.fetchTree(repositoryState, driver, updatedInto);
        }
    });
}

const BranchUtils = {
    remove,
    create,
    update,
    merge
};
module.exports = BranchUtils;


const Q = require('q');

const CacheUtils = require('./cache');

/**
 * Get a blob from cache
 * @param {SHA} sha
 * @return {Blob}
 */
function read(repoState, sha) {
    return repoState.getCache().getBlob(sha);
}

/**
 * Fetch a blob from SHA.
 * @param {RepositoryState} repoState
 * @param {Driver} driver
 * @param {SHA} sha
 * @return {Promise<RepositoryState>}
 */
function fetch(repoState, driver, sha) {
    if (isFetched(repoState, sha)) {
        // No op if already fetched
        return Q(repoState);
    }

    const cache = repoState.getCache();
    // Fetch the blob
    return driver.fetchBlob(sha)
    // Then store it in the cache
    .then((blob) => {
        const newCache = CacheUtils.addBlob(cache, sha, blob);
        return repoState.set('cache', newCache);
    });
}

/**
 * @param {RepositoryState} repoState
 * @param {SHA} sha
 * @return {Boolean} True if a the corresponding blob is in cache.
 */
function isFetched(repoState, sha) {
    return repoState.getCache().getBlobs().has(sha);
}

const BlobUtils = {
    read,
    isFetched,
    fetch
};
module.exports = BlobUtils;


import {
  getTemplate,
  writeFile,
  readFile,
  removeFile,
} from './utils';
import { existsSync } from 'fs';
import { getExpression } from '../utils/index';
import { join, sep } from 'path';
import relative from 'relative';
import assert from 'assert';
import j from 'jscodeshift';

// TODO: check react-router version
// assume that router.js is already created
function findRouterNode(root) {
  return root.find(
    j.JSXElement, {
      openingElement: {
        name: {
          name: 'Router'
        }
      }
    }
  ).nodes()[0];
}

function findRouteById(root, id) {
  function find(node, parentPath = '', parentId) {
    const type = node.openingElement.name.name;
    const attributes = node.openingElement.attributes;
    let path;
    for (let i = 0; i < attributes.length; i++) {
      if (attributes[i].name.name === 'path') {
        path = attributes[i].value.value;
      }
    }

    let absolutePath;
    if (path) {
      absolutePath = path.charAt(0) === '/' ? path : `${parentPath}/${path}`;
    }

    let currentId;
    if (absolutePath) {
      currentId = `${type}-${absolutePath}`;
    } else if (parentId) {
      currentId = `${type}-parentId_${parentId}`;
    } else {
      currentId = `${type}-root`;
    }

    // found!
    if (currentId === id) return node;

    let found;
    if (node.children) {
      const childElements = node.children.filter(node => node.type === 'JSXElement');
      for (let i = 0; i < childElements.length; i++) {
        found = find(childElements[i], path, currentId);
        if (found) break;
      }
    }
    return found;
  }

  return find(findRouterNode(root), id);
}

// TODO: id 规则需要跟 collection 中复用
function findParentRoute(root, id) {
  if (!id) {
    return findRouterNode(root);
  } else {
    return findRouteById(root, id);
  }
}

function createElement(root, el, attributes = [], parentId) {
  const parentRoute = findParentRoute(root, parentId);
  if (!parentRoute) {
    throw new Error('createRoute, no element find by parentId');
  }
  parentRoute.children.push(
    j.jsxElement(
      j.jsxOpeningElement(
        j.jsxIdentifier(el),
        attributes.map(attr => {
          if (attr.isExpression) {
            return j.jsxAttribute(
              j.jsxIdentifier(attr.key),
              j.jsxExpressionContainer(
                j.identifier(attr.value)
              )
            )
          } else {
            return j.jsxAttribute(
              j.jsxIdentifier(attr.key),
              j.literal(attr.value)
            );
          }
        }),
        true
      ),
      null,
      [],
    )
  );
  parentRoute.children.push(j.jsxText('\n'));
}

function __createRoute(payload, type) {
  const { path, component = {}, parentId } = payload;
  const filePath = join(payload.sourcePath, payload.filePath);
  const source = readFile(filePath);
  const root = j(source);
  const parentRoute = findParentRoute(root);

  // append Route
  // TODO: support additonal attributes like components
  const attributes = [];
  if (path) {
    attributes.push({ key: 'path', value: path });
  }
  if (component.componentName) {
    attributes.push({ key: 'component', value: component.componentName, isExpression: true });
  }
  createElement(root, type, attributes, parentId);

  if (!component.componentName) return writeFile(filePath, root.toSource());
  assert(
    component.filePath,
    'api/router/create: payload.component should have filePath'
  );

  // create & import component
  let relativePath;
  const componentFilePath = join(payload.sourcePath, component.filePath);
  if (existsSync(componentFilePath)) {
    relativePath = relative(filePath, componentFilePath);
    if (relativePath.charAt(0) !== '.') {
      relativePath = './' + relativePath;
    }
    relativePath = relativePath.split(sep).join('/');  // workaround for windows
  }

  const imports = root.find(j.ImportDeclaration);
  const lastImport = imports.at(imports.size() - 1);
  lastImport.insertAfter(
    j.importDeclaration(
      [j.importDefaultSpecifier(
        j.identifier(component.componentName)
      )],
      j.literal(relativePath)
    )
  );
  writeFile(filePath, root.toSource());
}

export function createRoute(payload) {
  const { path, component = {}, parentId } = payload;
  assert(
    payload.path || (payload.component && payload.component.componentName),
    'api/router/createRoute: payload should at least have path or compnent'
  );
  __createRoute(payload, 'Route');
}

export function createIndexRoute(payload) {
  const { component = {}, parentId } = payload;
  assert(
    payload.component && payload.component.componentName,
    'api/router/createIndexRoute: payload should at have compnent'
  );
  __createRoute(payload, 'IndexRoute');
}

export function createRedirect(payload) {
  assert(
    payload.from && payload.to,
    'api/router/createRedirect: payload should have from or to'
  );
  const filePath = join(payload.sourcePath, payload.filePath);
  const source = readFile(filePath);
  const root = j(source);

  createElement(
    root,
    'Redirect',
    [
      { key: 'from', value: payload.from },
      { key: 'to', value: payload.to },
    ],
    payload.parentId
  );

  writeFile(filePath, root.toSource());
}

export function createIndexRedirect(payload) {
  assert(
    payload.to,
    'api/router/createIndexRedirect: payload should have to'
  );
  const filePath = join(payload.sourcePath, payload.filePath);
  const source = readFile(filePath);
  const root = j(source);

  createElement(
    root,
    'IndexRedirect',
    [
      { key: 'to', value: payload.to },
    ],
    payload.parentId
  );

  writeFile(filePath, root.toSource());
}

export function remove(payload) {
  assert(
    payload.id,
    'api/router/remove: payload should have id'
  );
  const filePath = join(payload.sourcePath, payload.filePath);
  const source = readFile(filePath);
  const root = j(source);
  const route = findRouteById(root, payload.id);
  if (!route) {
    throw new Error(`api/router/remove: didn\'t find route by id: ${id}`);
  }

  // don't know why j(route).remove dosen't work
  // here use a workaround, find it again and then remove it.
  // TODO: need to remove the empty line left behind
  root.find(j.JSXElement, {
    start: route.start,
    end: route.end,
  }).at(0).remove();

  writeFile(filePath, root.toSource());
}

export function moveTo(payload) {
  assert(
    payload.id,
    'api/router/moveTo: payload should have id & parentId'
  );
  const filePath = join(payload.sourcePath, payload.filePath);
  const source = readFile(filePath);
  const root = j(source);
  const route = findRouteById(root, payload.id);
  if (!route) {
    throw new Error(`api/router/moveTo: didn\'t find route by id: ${id}`);
  }

  let parentRoute;
  if (payload.parentId) {
    parentRoute = findRouteById(root, payload.parentId);
    if (!parentRoute) {
      throw new Error(`api/router/moveTo: didn\'t find parent route by id: ${parentId}`);
    }
  } else {
    parentRoute = findRouterNode(root);
  }

  root.find(j.JSXElement, {
    start: route.start,
    end: route.end,
  }).at(0).remove();

  if (parentRoute.openingElement.selfClosing) {
    parentRoute.openingElement.selfClosing = false;
    parentRoute.closingElement = j.jsxClosingElement(
      j.jsxIdentifier(parentRoute.openingElement.name.name)
    );
  }

  parentRoute.children.push(j.jsxText('\n'));
  parentRoute.children.push(route);

  writeFile(filePath, root.toSource());
}