var fs = require('../util/fs');
var path = require('path');
var mout = require('mout');
var Q = require('q');
var mkdirp = require('mkdirp');
var rimraf = require('../util/rimraf');
var LRU = require('lru-cache');
var lockFile = require('lockfile');
var md5 = require('md5-hex');
var semver = require('../util/semver');
var readJson = require('../util/readJson');
var copy = require('../util/copy');

function ResolveCache(config) {
    // TODO: Make some config entries, such as:
    //       - Max MB
    //       - Max versions per source
    //       - Max MB per source
    //       - etc..
    this._config = config;
    this._dir = this._config.storage.packages;
    this._lockDir = this._config.storage.packages;

    mkdirp.sync(this._lockDir);

    // Cache is stored/retrieved statically to ensure singularity
    // among instances
    this._cache = this.constructor._cache.get(this._dir);
    if (!this._cache) {
        this._cache = new LRU({
            max: 100,
            maxAge: 60 * 5 * 1000 // 5 minutes
        });
        this.constructor._cache.set(this._dir, this._cache);
    }

    // Ensure dir is created
    mkdirp.sync(this._dir);
}

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

ResolveCache.prototype.retrieve = function(source, target) {
    var sourceId = md5(source);
    var dir = path.join(this._dir, sourceId);
    var that = this;

    target = target || '*';

    return this._getVersions(sourceId)
        .spread(function(versions) {
            var suitable;

            // If target is a semver, find a suitable version
            if (semver.validRange(target)) {
                suitable = semver.maxSatisfying(versions, target, true);

                if (suitable) {
                    return suitable;
                }
            }

            // If target is '*' check if there's a cached '_wildcard'
            if (target === '*') {
                return mout.array.find(versions, function(version) {
                    return version === '_wildcard';
                });
            }

            // Otherwise check if there's an exact match
            return mout.array.find(versions, function(version) {
                return version === target;
            });
        })
        .then(function(version) {
            var canonicalDir;

            if (!version) {
                return [];
            }

            // Resolve with canonical dir and package meta
            canonicalDir = path.join(dir, encodeURIComponent(version));
            return that._readPkgMeta(canonicalDir).then(
                function(pkgMeta) {
                    return [canonicalDir, pkgMeta];
                },
                function() {
                    // If there was an error, invalidate the in-memory cache,
                    // delete the cached package and try again
                    that._cache.del(sourceId);

                    return Q.nfcall(rimraf, canonicalDir).then(function() {
                        return that.retrieve(source, target);
                    });
                }
            );
        });
};

ResolveCache.prototype.store = function(canonicalDir, pkgMeta) {
    var sourceId;
    var release;
    var dir;
    var pkgLock;
    var promise;
    var that = this;

    promise = pkgMeta ? Q.resolve(pkgMeta) : this._readPkgMeta(canonicalDir);

    return promise
        .then(function(pkgMeta) {
            sourceId = md5(pkgMeta._source);
            release = that._getPkgRelease(pkgMeta);
            dir = path.join(that._dir, sourceId, release);
            pkgLock = path.join(
                that._lockDir,
                sourceId + '-' + release + '.lock'
            );

            // Check if destination directory exists to prevent issuing lock at all times
            return Q.nfcall(fs.stat, dir)
                .fail(function(err) {
                    var lockParams = { wait: 250, retries: 25, stale: 60000 };
                    return Q.nfcall(lockFile.lock, pkgLock, lockParams)
                        .then(function() {
                            // Ensure other process didn't start copying files before lock was created
                            return Q.nfcall(fs.stat, dir).fail(function(err) {
                                // If stat fails, it is expected to return ENOENT
                                if (err.code !== 'ENOENT') {
                                    throw err;
                                }

                                // Create missing directory and copy files there
                                return Q.nfcall(mkdirp, path.dirname(dir)).then(
                                    function() {
                                        return Q.nfcall(
                                            fs.rename,
                                            canonicalDir,
                                            dir
                                        ).fail(function(err) {
                                            // If error is EXDEV it means that we are trying to rename
                                            // across different drives, so we copy and remove it instead
                                            if (err.code !== 'EXDEV') {
                                                throw err;
                                            }

                                            return copy.copyDir(
                                                canonicalDir,
                                                dir
                                            );
                                        });
                                    }
                                );
                            });
                        })
                        .finally(function() {
                            lockFile.unlockSync(pkgLock);
                        });
                })
                .finally(function() {
                    // Ensure no tmp dir is left on disk.
                    return Q.nfcall(rimraf, canonicalDir);
                });
        })
        .then(function() {
            var versions = that._cache.get(sourceId);

            // Add it to the in memory cache
            // and sort the versions afterwards
            if (versions && versions.indexOf(release) === -1) {
                versions.push(release);
                that._sortVersions(versions);
            }

            // Resolve with the final location
            return dir;
        });
};

ResolveCache.prototype.eliminate = function(pkgMeta) {
    var sourceId = md5(pkgMeta._source);
    var release = this._getPkgRelease(pkgMeta);
    var dir = path.join(this._dir, sourceId, release);
    var that = this;

    return Q.nfcall(rimraf, dir).then(function() {
        var versions = that._cache.get(sourceId) || [];
        mout.array.remove(versions, release);

        // If this was the last package in the cache,
        // delete the parent folder (source)
        // For extra security, check against the file system
        // if this was really the last package
        if (!versions.length) {
            that._cache.del(sourceId);

            return that._getVersions(sourceId).spread(function(versions) {
                if (!versions.length) {
                    // Do not keep in-memory cache if it's completely
                    // empty
                    that._cache.del(sourceId);

                    return Q.nfcall(rimraf, path.dirname(dir));
                }
            });
        }
    });
};

ResolveCache.prototype.clear = function() {
    return Q.nfcall(rimraf, this._dir)
        .then(
            function() {
                return Q.nfcall(fs.mkdir, this._dir);
            }.bind(this)
        )
        .then(
            function() {
                this._cache.reset();
            }.bind(this)
        );
};

ResolveCache.prototype.reset = function() {
    this._cache.reset();
    return this;
};

ResolveCache.prototype.versions = function(source) {
    var sourceId = md5(source);

    return this._getVersions(sourceId).spread(function(versions) {
        return versions.filter(function(version) {
            return semver.valid(version);
        });
    });
};

ResolveCache.prototype.list = function() {
    var promises;
    var dirs = [];
    var that = this;

    // Get the list of directories
    return (
        Q.nfcall(fs.readdir, this._dir)
            .then(function(sourceIds) {
                promises = sourceIds.map(function(sourceId) {
                    return Q.nfcall(
                        fs.readdir,
                        path.join(that._dir, sourceId)
                    ).then(
                        function(versions) {
                            versions.forEach(function(version) {
                                var dir = path.join(
                                    that._dir,
                                    sourceId,
                                    version
                                );
                                dirs.push(dir);
                            });
                        },
                        function(err) {
                            // Ignore lurking files, e.g.: .DS_Store if the user
                            // has navigated throughout the cache
                            if (err.code === 'ENOTDIR' && err.path) {
                                return Q.nfcall(rimraf, err.path);
                            }

                            throw err;
                        }
                    );
                });

                return Q.all(promises);
            })
            // Read every package meta
            .then(function() {
                promises = dirs.map(function(dir) {
                    return that._readPkgMeta(dir).then(
                        function(pkgMeta) {
                            return {
                                canonicalDir: dir,
                                pkgMeta: pkgMeta
                            };
                        },
                        function() {
                            // If it fails to read, invalidate the in memory
                            // cache for the source and delete the entry directory
                            var sourceId = path.basename(path.dirname(dir));
                            that._cache.del(sourceId);

                            return Q.nfcall(rimraf, dir);
                        }
                    );
                });

                return Q.all(promises);
            })
            // Sort by name ASC & release ASC
            .then(function(entries) {
                // Ignore falsy entries due to errors reading
                // package metas
                entries = entries.filter(function(entry) {
                    return !!entry;
                });

                return entries.sort(function(entry1, entry2) {
                    var pkgMeta1 = entry1.pkgMeta;
                    var pkgMeta2 = entry2.pkgMeta;
                    var comp = pkgMeta1.name.localeCompare(pkgMeta2.name);

                    // Sort by name
                    if (comp) {
                        return comp;
                    }

                    // Sort by version
                    if (pkgMeta1.version && pkgMeta2.version) {
                        return semver.compare(
                            pkgMeta1.version,
                            pkgMeta2.version
                        );
                    }
                    if (pkgMeta1.version) {
                        return -1;
                    }
                    if (pkgMeta2.version) {
                        return 1;
                    }

                    // Sort by target
                    return pkgMeta1._target.localeCompare(pkgMeta2._target);
                });
            })
    );
};

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

ResolveCache.clearRuntimeCache = function() {
    // Note that _cache refers to the static _cache variable
    // that holds other caches per dir!
    // Do not confuse it with the instance cache

    // Clear cache of each directory
    this._cache.forEach(function(cache) {
        cache.reset();
    });

    // Clear root cache
    this._cache.reset();
};

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

ResolveCache.prototype._getPkgRelease = function(pkgMeta) {
    var release =
        pkgMeta.version ||
        (pkgMeta._target === '*' ? '_wildcard' : pkgMeta._target);

    // Encode some dangerous chars such as / and \
    release = encodeURIComponent(release);

    return release;
};

ResolveCache.prototype._readPkgMeta = function(dir) {
    var filename = path.join(dir, '.bower.json');

    return readJson(filename).spread(function(json) {
        return json;
    });
};

ResolveCache.prototype._getVersions = function(sourceId) {
    var dir;
    var versions = this._cache.get(sourceId);
    var that = this;

    if (versions) {
        return Q.resolve([versions, true]);
    }

    dir = path.join(this._dir, sourceId);
    return Q.nfcall(fs.readdir, dir).then(
        function(versions) {
            // Sort and cache in memory
            that._sortVersions(versions);
            versions = versions.map(decodeURIComponent);
            that._cache.set(sourceId, versions);
            return [versions, false];
        },
        function(err) {
            // If the directory does not exists, resolve
            // as an empty array
            if (err.code === 'ENOENT') {
                versions = [];
                that._cache.set(sourceId, versions);
                return [versions, false];
            }

            throw err;
        }
    );
};

ResolveCache.prototype._sortVersions = function(versions) {
    // Sort DESC
    versions.sort(function(version1, version2) {
        var validSemver1 = semver.valid(version1);
        var validSemver2 = semver.valid(version2);

        // If both are semvers, compare them
        if (validSemver1 && validSemver2) {
            return semver.rcompare(version1, version2);
        }

        // If one of them are semvers, give higher priority
        if (validSemver1) {
            return -1;
        }
        if (validSemver2) {
            return 1;
        }

        // Otherwise they are considered equal
        return 0;
    });
};

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

ResolveCache._cache = new LRU({
    max: 5,
    maxAge: 60 * 30 * 1000 // 30 minutes
});

module.exports = ResolveCache;
