var glob = require('glob');
var path = require('path');
var fs = require('../util/fs');
var Q = require('q');
var mout = require('mout');
var rimraf = require('../util/rimraf');
var endpointParser = require('bower-endpoint-parser');
var Logger = require('bower-logger');
var md5 = require('md5-hex');
var Manager = require('./Manager');
var semver = require('../util/semver');
var createError = require('../util/createError');
var readJson = require('../util/readJson');
var validLink = require('../util/validLink');
var scripts = require('./scripts');
var relativeToBaseDir = require('../util/relativeToBaseDir');

function Project(config, logger) {
    this._config = config;
    this._logger = logger || new Logger();
    this._manager = new Manager(this._config, this._logger);

    this._options = {};
}

// -----------------

Project.prototype.install = function (decEndpoints, options, config) {
    var that = this;
    var targets = [];
    var resolved = {};
    var incompatibles = [];

    // If already working, error out
    if (this._working) {
        return Q.reject(createError('Already working', 'EWORKING'));
    }

    this._options = options || {};
    this._config = config || {};
    this._working = true;

    // Analyse the project
    return this._analyse()
    .spread(function (json, tree) {
        // It shows an error when issuing `bower install`
        // and no bower.json is present in current directory
        if (!that._jsonFile && decEndpoints.length === 0 ) {
            throw createError('No bower.json present', 'ENOENT');
        }

        // Recover tree
        that.walkTree(tree, function (node, name) {
            if (node.incompatible) {
                incompatibles.push(node);
            } else if (node.missing || node.different || that._config.force) {
                targets.push(node);
            } else {
                resolved[name] = node;
            }
        }, true);

        // Add decomposed endpoints as targets
        decEndpoints = decEndpoints || [];
        decEndpoints.forEach(function (decEndpoint) {
            // Mark as new so that a conflict for this target
            // always require a choice
            // Also allows for the target to be converted in case
            // of being *
            decEndpoint.newly = true;
            targets.push(decEndpoint);
        });

        // Bootstrap the process
        return that._bootstrap(targets, resolved, incompatibles);
    })
    .then(function () {
        return that._manager.preinstall(that._json);
    })
    .then(function () {
        return that._manager.install(that._json);
    })
    .then(function (installed) {
        // Handle save and saveDev options
        if (that._options.save || that._options.saveDev || that._options.saveExact || that._config.save || that._config.saveExact) {
            // Cycle through the specified endpoints
            decEndpoints.forEach(function (decEndpoint) {
                var jsonEndpoint;

                jsonEndpoint = endpointParser.decomposed2json(decEndpoint);

                if (that._options.saveExact || that._config.saveExact) {
                    if (decEndpoint.name !== decEndpoint.source) {
                        jsonEndpoint[decEndpoint.name] = decEndpoint.source + '#' + decEndpoint.pkgMeta.version;
                    } else {
                        jsonEndpoint[decEndpoint.name] = decEndpoint.pkgMeta.version;
                    }
                }

                if (that._options.saveDev) {
                    that._json.devDependencies = mout.object.mixIn(that._json.devDependencies || {}, jsonEndpoint);
                } else {
                    that._json.dependencies = mout.object.mixIn(that._json.dependencies || {}, jsonEndpoint);
                }
            });
        }

        // Save JSON, might contain changes to dependencies and resolutions
        return that.saveJson()
        .then(function () {
            return that._manager.postinstall(that._json).then(function () {
                return installed;
            });
        });
    })
    .fin(function () {
        that._installed = null;
        that._working = false;
    });
};

Project.prototype.update = function (names, options) {
    var that = this;
    var targets = [];
    var resolved = {};
    var incompatibles = [];

    // If already working, error out
    if (this._working) {
        return Q.reject(createError('Already working', 'EWORKING'));
    }

    this._options = options || {};
    this._working = true;

    // Analyse the project
    return this._analyse()
    .spread(function (json, tree, flattened) {
        // If no names were specified, update every package
        if (!names) {
            // Mark each root dependency as targets
            that.walkTree(tree, function (node) {
                // We don't know the real source of linked packages
                // Instead we read its dependencies
                if (node.linked) {
                    targets.push.apply(targets, mout.object.values(node.dependencies));
                } else {
                    targets.push(node);
                }

                return false;
            }, true);
        // Otherwise, selectively update the specified ones
        } else {
            // Error out if some of the specified names
            // are not installed
            names.forEach(function (name) {
                if (!flattened[name]) {
                    throw createError('Package ' + name + ' is not installed', 'ENOTINS', {
                        name: name
                    });
                }
            });

            // Add packages whose names are specified to be updated
            that.walkTree(tree, function (node, name) {
                if (names.indexOf(name) !== -1) {
                    // We don't know the real source of linked packages
                    // Instead we read its dependencies
                    if (node.linked) {
                        targets.push.apply(targets, mout.object.values(node.dependencies));
                    } else {
                        targets.push(node);
                    }

                    return false;
                }
            }, true);

            // Recover tree
            that.walkTree(tree, function (node, name) {
                if (node.missing || node.different) {
                    targets.push(node);
                } else if (node.incompatible) {
                    incompatibles.push(node);
                } else {
                    resolved[name] = node;
                }
            }, true);
        }

        // Bootstrap the process
        return that._bootstrap(targets, resolved, incompatibles)
        .then(function () {
            return that._manager.preinstall(that._json);
        })
        .then(function () {
            return that._manager.install(that._json);
        })
        .then(function (installed) {
            // Save JSON, might contain changes to resolutions
            return that.saveJson()
            .then(function () {
                return that._manager.postinstall(that._json).then(function () {
                    return installed;
                });
            });
        });
    })
    .fin(function () {
        that._installed = null;
        that._working = false;
    });
};

Project.prototype.uninstall = function (names, options) {
    var that = this;
    var packages = {};

    // If already working, error out
    if (this._working) {
        return Q.reject(createError('Already working', 'EWORKING'));
    }

    this._options = options || {};
    this._working = true;

    // Analyse the project
    return this._analyse()
    // Fill in the packages to be uninstalled
    .spread(function (json, tree, flattened) {
        var promise = Q.resolve();

        names.forEach(function (name) {
            var decEndpoint = flattened[name];

            // Check if it is not installed
            if (!decEndpoint || decEndpoint.missing) {
                packages[name] = null;
                return;
            }

            promise = promise
            .then(function () {
                var message;
                var data;
                var dependantsNames;
                var dependants = [];

                // Walk the down the tree, gathering dependants of the package
                that.walkTree(tree, function (node, nodeName) {
                    if (name === nodeName) {
                        dependants.push.apply(dependants, mout.object.values(node.dependants));
                    }
                }, true);

                // Remove duplicates
                dependants = mout.array.unique(dependants);

                // Note that the root is filtered from the dependants
                // as well as other dependants marked to be uninstalled
                dependants = dependants.filter(function (dependant) {
                    return !dependant.root && names.indexOf(dependant.name) === -1;
                });

                // If the package has no dependants or the force config is enabled,
                // mark it to be removed
                if (!dependants.length || that._config.force) {
                    packages[name] = decEndpoint.canonicalDir;
                    return;
                }

                // Otherwise we need to figure it out if the user really wants to remove it,
                // even with dependants
                // As such we need to prompt the user with a meaningful message
                dependantsNames = dependants.map(function (dep) { return dep.name; });
                dependantsNames.sort(function (name1, name2) { return name1.localeCompare(name2); });
                dependantsNames = mout.array.unique(dependantsNames);
                dependants = dependants.map(function (dependant) { return that._manager.toData(dependant); });
                message = dependantsNames.join(', ') + ' depends on ' + decEndpoint.name;
                data = {
                    name: decEndpoint.name,
                    dependants: dependants
                };

                // If interactive is disabled, error out
                if (!that._config.interactive) {
                    throw createError(message, 'ECONFLICT', {
                        data: data
                    });
                }

                that._logger.conflict('mutual', message, data);

                // Prompt the user
                return Q.nfcall(that._logger.prompt.bind(that._logger), {
                    type: 'confirm',
                    message: 'Continue anyway?',
                    default: true
                })
                .then(function (confirmed) {
                    // If the user decided to skip it, remove from the array so that it won't
                    // influence subsequent dependants
                    if (!confirmed) {
                        mout.array.remove(names, name);
                    } else {
                        packages[name] = decEndpoint.canonicalDir;
                    }
                });
            });
        });

        return promise;
    })
    // Remove packages
    .then(function () {
        return that._removePackages(packages);
    })
    .fin(function () {
        that._installed = null;
        that._working = false;
    });
};

Project.prototype.getTree = function (options) {
    this._options = options || {};

    return this._analyse()
    .spread(function (json, tree, flattened) {
        var extraneous = [];
        var additionalKeys = ['missing', 'extraneous', 'different', 'linked'];

        // Convert tree
        tree = this._manager.toData(tree, additionalKeys);

        // Mark incompatibles
        this.walkTree(tree, function (node) {
            var version;
            var target = node.endpoint.target;

            if (node.pkgMeta && semver.validRange(target)) {
                version = node.pkgMeta.version;

                // Ignore if target is '*' and resolved to a non-semver release
                if (!version && target === '*') {
                    return;
                }

                if (!version || !semver.satisfies(version, target)) {
                    node.incompatible = true;
                }
            }
        }, true);

        // Convert extraneous
        mout.object.forOwn(flattened, function (pkg) {
            if (pkg.extraneous) {
                extraneous.push(this._manager.toData(pkg, additionalKeys));
            }
        }, this);

        // Convert flattened
        flattened = mout.object.map(flattened, function (node) {
            return this._manager.toData(node, additionalKeys);
        }, this);

        return [tree, flattened, extraneous];
    }.bind(this));
};

Project.prototype.walkTree = function (node, fn, onlyOnce) {
    var result;
    var dependencies;
    var queue = mout.object.values(node.dependencies);

    if (onlyOnce === true) {
        onlyOnce = [];
    }

    while (queue.length) {
        node = queue.shift();
        result = fn(node, node.endpoint ? node.endpoint.name : node.name);

        // Abort traversal if result is false
        if (result === false) {
            continue;
        }

        // Add dependencies to the queue
        dependencies = mout.object.values(node.dependencies);

        // If onlyOnce was true, do not add if already traversed
        if (onlyOnce) {
            dependencies = dependencies.filter(function (dependency) {
                return !mout.array.find(onlyOnce, function (stacked) {
                    if (dependency.endpoint) {
                        return mout.object.equals(dependency.endpoint, stacked.endpoint);
                    }

                    return dependency.name === stacked.name &&
                           dependency.source === stacked.source &&
                           dependency.target === stacked.target;
                });
            });

            onlyOnce.push.apply(onlyOnce, dependencies);
        }

        queue.unshift.apply(queue, dependencies);
    }
};

Project.prototype.saveJson = function (forceCreate) {
    var file;
    var jsonStr = JSON.stringify(this._json, null, '  ') + '\n';
    var jsonHash = md5(jsonStr);

    // Save only if there's something different
    if (jsonHash === this._jsonHash) {
        return Q.resolve();
    }

    // Error out if the json file does not exist, unless force create
    // is true
    if (!this._jsonFile && !forceCreate) {
        this._logger.warn('no-json', 'No bower.json file to save to, use bower init to create one');
        return Q.resolve();
    }

    file = this._jsonFile || path.join(this._config.cwd, 'bower.json');
    return Q.nfcall(fs.writeFile, file, jsonStr)
    .then(function () {
        this._jsonHash = jsonHash;
        this._jsonFile = file;
        return this._json;
    }.bind(this));
};

Project.prototype.hasJson = function () {
    return this._readJson()
    .then(function (json) {
        return json ? this._jsonFile : false;
    }.bind(this));
};

Project.prototype.getJson = function () {
    return this._readJson();
};

Project.prototype.getManager = function () {
    return this._manager;
};

Project.prototype.getPackageRepository = function () {
    return this._manager.getPackageRepository();
};

// -----------------

Project.prototype._analyse = function () {
    return Q.all([
        this._readJson(),
        this._readInstalled(),
        this._readLinks()
    ])
    .spread(function (json, installed, links) {
        var root;
        var jsonCopy = mout.lang.deepClone(json);

        root = {
            name: json.name,
            source: this._config.cwd,
            target: json.version || '*',
            pkgMeta: jsonCopy,
            canonicalDir: this._config.cwd,
            root: true
        };

        mout.object.mixIn(installed, links);

        // Mix direct extraneous as dependencies
        // (dependencies installed without --save/--save-dev)
        jsonCopy.dependencies = jsonCopy.dependencies || {};
        jsonCopy.devDependencies = jsonCopy.devDependencies || {};
        mout.object.forOwn(installed, function (decEndpoint, key) {
            var pkgMeta = decEndpoint.pkgMeta;
            var isSaved = jsonCopy.dependencies[key] || jsonCopy.devDependencies[key];

            // The _direct propery is saved by the manager when .newly is specified
            // It may happen pkgMeta is undefined if package is uninstalled
            if (!isSaved && pkgMeta && pkgMeta._direct) {
                decEndpoint.extraneous = true;

                if (decEndpoint.linked) {
                    jsonCopy.dependencies[key] = pkgMeta.version || '*';
                } else {
                    jsonCopy.dependencies[key] = (pkgMeta._originalSource || pkgMeta._source) + '#' + pkgMeta._target;
                }
            }
        });

        // Restore the original dependencies cross-references,
        // that is, the parent-child relationships
        this._restoreNode(root, installed, 'dependencies');
        // Do the same for the dev dependencies
        if (!this._options.production) {
            this._restoreNode(root, installed, 'devDependencies');
        }

        // Restore the rest of the extraneous (not installed directly)
        mout.object.forOwn(installed, function (decEndpoint, name) {
            if (!decEndpoint.dependants) {
                decEndpoint.extraneous = true;
                this._restoreNode(decEndpoint, installed, 'dependencies');
                // Note that it has no dependants, just dependencies!
                root.dependencies[name] = decEndpoint;
            }
        }, this);

        // Remove root from the flattened tree
        delete installed[json.name];

        return [json, root, installed];
    }.bind(this));
};

Project.prototype._bootstrap = function (targets, resolved, incompatibles) {
    var installed = mout.object.map(this._installed, function (decEndpoint) {
        return decEndpoint.pkgMeta;
    });

    this._json.resolutions = this._json.resolutions || {};

    // Configure the manager and kick in the resolve process
    return this._manager
    .configure({
        targets: targets,
        resolved: resolved,
        incompatibles: incompatibles,
        resolutions: this._json.resolutions,
        installed: installed,
        forceLatest: this._options.forceLatest
    })
    .resolve()
    .then(function () {
        // If the resolutions is empty, delete key
        if (!mout.object.size(this._json.resolutions)) {
            delete this._json.resolutions;
        }
    }.bind(this));
};

Project.prototype._readJson = function () {
    var that = this;

    if (this._json) {
        return Q.resolve(this._json);
    }

    return Q.fcall(function () {
        // This will throw if package.json does not exist
        return fs.readFileSync(path.join(that._config.cwd, 'package.json'));
    })
    .then(function (buffer) {
        // If package.json exists, use it's values as defaults
        var defaults = {}, npm = JSON.parse(buffer.toString());

        defaults.name        = npm.name || path.basename(that._config.cwd) || 'root';
        defaults.description = npm.description;
        defaults.main        = npm.main;
        defaults.authors     = npm.contributors || npm.author;
        defaults.license     = npm.license;
        defaults.keywords    = npm.keywords;

        return defaults;
    })
    .catch(function (err) {
        // Most likely no package.json so just set default name
        return { name: path.basename(that._config.cwd) || 'root' };
    })
    .then(function (defaults) {
        return that._json = readJson(that._config.cwd, { assume: defaults, logger: that._logger });
    })
    .spread(function (json, deprecated, assumed) {
        var jsonStr;

        if (deprecated) {
            that._logger.warn('deprecated', 'You are using the deprecated ' + deprecated + ' file');
        }

        if (!assumed) {
            that._jsonFile = path.join(that._config.cwd, deprecated ? deprecated : 'bower.json');
        }

        jsonStr = JSON.stringify(json, null, '  ') + '\n';
        that._jsonHash = md5(jsonStr);
        return that._json = json;
    });
};

Project.prototype._readInstalled = function () {
    var componentsDir;
    var that = this;

    if (this._installed) {
        return Q.resolve(this._installed);
    }

    // Gather all folders that are actual packages by
    // looking for the package metadata file
    componentsDir = relativeToBaseDir(this._config.cwd)(this._config.directory);
    return this._installed = Q.nfcall(glob, '*/.bower.json', {
        cwd: componentsDir,
        dot: true
    })
    .then(function (filenames) {
        var promises;
        var decEndpoints = {};

        // Foreach bower.json found
        promises = filenames.map(function (filename) {
            var name = path.dirname(filename);
            var metaFile = path.join(componentsDir, filename);

            // Read package metadata
            return readJson(metaFile)
            .spread(function (pkgMeta) {
                decEndpoints[name] = {
                    name: name,
                    source: pkgMeta._originalSource || pkgMeta._source,
                    target: pkgMeta._target,
                    canonicalDir: path.dirname(metaFile),
                    pkgMeta: pkgMeta
                };
            });
        });

        // Wait until all files have been read
        // and resolve with the decomposed endpoints
        return Q.all(promises)
        .then(function () {
            return that._installed = decEndpoints;
        });
    });
};

Project.prototype._readLinks = function () {
    var componentsDir;
    var that = this;

    // Read directory, looking for links
    componentsDir = relativeToBaseDir(this._config.cwd)(this._config.directory);
    return Q.nfcall(fs.readdir, componentsDir)
    .then(function (filenames) {
        var promises;
        var decEndpoints = {};

        promises = filenames.map(function (filename) {
            var dir = path.join(componentsDir, filename);

            // Filter only those that are valid links
            return validLink(dir)
            .spread(function (valid, err) {
                var name;

                if (!valid) {
                    if (err) {
                        that._logger.debug('read-link', 'Link ' + dir + ' is invalid', {
                            filename: dir,
                            error: err
                        });
                    }
                    return;
                }

                // Skip links to files (see #783)
                if (!valid.isDirectory()) {
                    return;
                }

                name = path.basename(dir);
                return readJson(dir, {
                    assume: { name: name }
                })
                .spread(function (json, deprecated) {
                    if (deprecated) {
                        that._logger.warn('deprecated', 'Package ' + name + ' is using the deprecated ' + deprecated);
                    }

                    json._direct = true;  // Mark as a direct dep of root
                    decEndpoints[name] = {
                        name: name,
                        source: dir,
                        target: '*',
                        canonicalDir: dir,
                        pkgMeta: json,
                        linked: true
                    };
                });
            });
        });

        // Wait until all links have been read
        // and resolve with the decomposed endpoints
        return Q.all(promises)
        .then(function () {
            return decEndpoints;
        });
    // Ignore if folder does not exist
    }, function (err) {
        if (err.code !== 'ENOENT') {
            throw err;
        }

        return {};
    });
};

Project.prototype._removePackages = function (packages) {
    var that = this;
    var promises = [];

    return scripts.preuninstall(that._config, that._logger, packages, that._installed, that._json)
    .then(function () {

        mout.object.forOwn(packages, function (dir, name) {
            var promise;

            // Delete directory
            if (!dir) {
                promise = Q.resolve();
                that._logger.warn('not-installed', '\'' + name + '\'' + ' cannot be uninstalled as it is not currently installed', {
                    name: name
                });
            } else {
                promise = Q.nfcall(rimraf, dir);
                that._logger.action('uninstall', name, {
                    name: name,
                    dir: dir
                });
            }

            // Remove from json only if successfully deleted
            if ((that._options.save || that._config.save) && that._json.dependencies) {
                promise = promise
                .then(function () {
                    delete that._json.dependencies[name];
                });
            }

            if (that._options.saveDev && that._json.devDependencies) {
                promise = promise
                .then(function () {
                    delete that._json.devDependencies[name];
                });
            }

            promises.push(promise);
        });

        return Q.all(promises);

    })
    .then(function () {
        return that.saveJson();
    })
    // Resolve with removed packages
    .then(function () {
        return mout.object.filter(packages, function (dir) {
            return !!dir;
        });
    });
};

Project.prototype._restoreNode = function (node, flattened, jsonKey, processed) {
    var deps;

    // Do not restore if the node is missing
    if (node.missing) {
        return;
    }

    node.dependencies = node.dependencies || {};
    node.dependants = node.dependants || {};
    processed = processed || {};

    // Only process deps that are not yet processed
    deps = mout.object.filter(node.pkgMeta[jsonKey], function (value, key) {
        return !processed[node.name + ':' + key];
    });

    mout.object.forOwn(deps, function (value, key) {
        var local = flattened[key];
        var json = endpointParser.json2decomposed(key, value);
        var restored;
        var compatible;
        var originalSource;

        // Check if the dependency is not installed
        if (!local) {
            flattened[key] = restored = json;
            restored.missing = true;
        // Even if it is installed, check if it's compatible
        // Note that linked packages are interpreted as compatible
        // This might change in the future: #673
        } else {
            compatible = local.linked || (!local.missing && json.target === local.pkgMeta._target);

            if (!compatible) {
                restored = json;

                if (!local.missing) {
                    restored.pkgMeta = local.pkgMeta;
                    restored.canonicalDir = local.canonicalDir;
                    restored.incompatible = true;
                } else {
                    restored.missing = true;
                }
            } else {
                restored = local;
                mout.object.mixIn(local, json);
            }

            // Check if source changed, marking as different if it did
            // We only do this for direct root dependencies that are compatible
            if (node.root && compatible) {
                originalSource = mout.object.get(local, 'pkgMeta._originalSource');
                if (originalSource && originalSource !== json.source) {
                    restored.different = true;
                }
            }
        }

        // Cross reference
        node.dependencies[key] = restored;
        processed[node.name + ':' + key] = true;

        restored.dependants = restored.dependants || {};
        restored.dependants[node.name] = mout.object.mixIn({}, node);  // We need to clone due to shared objects in the manager!

        // Call restore for this dependency
        this._restoreNode(restored, flattened, 'dependencies', processed);

        // Do the same for the incompatible local package
        if (local && restored !== local) {
            this._restoreNode(local, flattened, 'dependencies', processed);
        }
    }, this);
};

module.exports = Project;
