'use strict';

const Bounce = require('@hapi/bounce');
const Hoek = require('@hapi/hoek');


const internals = {
    maxLength: 256,
    wildcards: ['x', 'X', '*'],
    any: Symbol('any')
};

//                              1:major         2:minor         3:patch          4:prerelease  5:build
//                              A         aB    C         cD    E         eF     G     gf H    I  ih d b
internals.versionRx = /^\s*[vV]?(\d+|[xX*])(?:\.(\d+|[xX*])(?:\.(\d+|[xX*])(?:\-?([^+]+))?(?:\+(.+))?)?)?\s*$/;

internals.strict = {
    tokenRx: /^[-\dA-Za-z]+(?:\.[-\dA-Za-z]+)*$/,
    numberRx: /^((?:0)|(?:[1-9]\d*))$/
};


exports.version = function (version, options) {

    return new internals.Version(version, options);
};


exports.range = function (range) {

    return new internals.Range(range);
};


exports.match = function (version, range, options) {

    try {
        return exports.range(range).match(version, options);
    }
    catch (err) {
        Bounce.rethrow(err, 'system');
        return false;
    }
};


exports.compare = function (a, b, options = {}) {

    let aFirst = -1;
    let bFirst = 1;

    a = exports.version(a, options);
    b = exports.version(b, options);

    // Mark incompatible prereleases

    if (options.range && !options.includePrerelease &&
        a.prerelease.length &&
        (a.major !== b.major || a.minor !== b.minor || a.patch !== b.patch || !b.prerelease.length)) {

        aFirst = -2;
        bFirst = 2;
    }

    // Compare versions

    for (let i = 0; i < 3; ++i) {
        const av = a.dots[i];
        const bv = b.dots[i];

        if (av === bv ||
            av === internals.any ||                             // Wildcard is equal to everything
            bv === internals.any) {

            continue;
        }

        return av - bv < 0 ? aFirst : bFirst;
    }

    // Compare prerelease

    // With includePrerelease patch wildcard encompasses prereleases, otherwise prerelease < none

    if (!a.prerelease.length && !b.prerelease.length) {
        return 0;
    }
    else if (!b.prerelease.length) {
        return options.includePrerelease && b.patch === internals.any ? 0 : aFirst;
    }
    else if (!a.prerelease.length) {
        return options.includePrerelease && a.patch === internals.any ? 0 : bFirst;
    }

    for (let i = 0; ; ++i) {
        const ai = a.prerelease[i];
        const bi = b.prerelease[i];

        if (ai === undefined &&
            bi === undefined) {

            return 0;
        }

        if (ai === bi) {
            continue;
        }

        if (ai === undefined) {
            return aFirst;
        }

        if (bi === undefined) {
            return bFirst;
        }

        const an = Number.isFinite(ai);
        const bn = Number.isFinite(bi);

        if (an !== bn) {
            return an ? aFirst : bFirst;
        }

        return (ai < bi ? aFirst : bFirst);
    }
};


internals.Version = class {

    constructor(version, options = {}) {

        Hoek.assert(version, 'Missing version argument');

        if (version instanceof internals.Version) {
            return version;
        }

        if (typeof version === 'object') {
            this._copy(version);
        }
        else {
            this._parse(version, options);
        }

        this.format();
    }

    _copy(version) {

        this.major = version.major === undefined ? internals.any : version.major;
        this.minor = version.minor === undefined ? internals.any : version.minor;
        this.patch = version.patch === undefined ? internals.any : version.patch;
        this.prerelease = version.prerelease ?? [];
        this.build = version.build ?? [];
    }

    _parse(version, options) {

        Hoek.assert(typeof version === 'string', 'Version argument must be a string');
        Hoek.assert(version.length <= internals.maxLength, 'Version string too long');

        const match = version.match(internals.versionRx);
        if (!match) {
            throw new Error(`Invalid version string format: ${version}`);
        }

        this.major = internals.Version._number(match[1], 'major', options);
        this.minor = internals.Version._number(match[2] || 'x', 'minor', options);
        this.patch = internals.Version._number(match[3] || 'x', 'patch', options);

        this.prerelease = internals.Version._sub(match[4], 'prerelease', options);
        this.build = internals.Version._sub(match[5], 'build', options);
    }

    static _number(string, source, options) {

        if (internals.wildcards.includes(string)) {
            return internals.any;
        }

        if (options.strict) {
            Hoek.assert(string.match(internals.strict.numberRx), 'Value must be 0 or a number without a leading zero:', source);
        }

        const value = parseInt(string, 10);
        Hoek.assert(value <= Number.MAX_SAFE_INTEGER, 'Value must be positive and less than max safe integer:', source);
        return value;
    }

    static _sub(string, source, options) {

        if (!string) {
            return [];
        }

        if (options.strict) {
            Hoek.assert(string.match(internals.strict.tokenRx), 'Value can only contain dot-separated hyphens, digits, a-z or A-Z:', source);
        }

        const subs = [];
        const parts = string.split('.');
        for (const part of parts) {
            if (!part) {
                throw new Error(`Invalid empty ${source} segment`);
            }

            subs.push(part.match(/^\d+$/) ? internals.Version._number(part, source, { strict: options.strict }) : part);
        }

        return subs;
    }

    format() {

        this.version = `${internals.dot(this.major)}.${internals.dot(this.minor)}.${internals.dot(this.patch)}${internals.token(this.prerelease, '-')}${internals.token(this.build, '+')}`;
        this.dots = [this.major, this.minor, this.patch];
        this.wildcard = this.major === internals.any && this.minor === internals.any && this.patch === internals.any && !this.prerelease.length;
    }

    toString() {

        return this.version;
    }

    compare(to, options) {

        return internals.Version.compare(this, to, options);
    }

    static compare(a, b, options = {}) {

        return exports.compare(a, b, options);
    }
};


internals.dot = (v) => {

    return (v === internals.any ? 'x' : v);
};


internals.token = (v, prefix) => {

    if (!v.length) {
        return '';
    }

    return `${prefix}${v.join('.')}`;
};


internals.Range = class {

    constructor(range, options) {

        this._settings = Object.assign({}, options);    // Shallow cloned
        this._anything = false;
        this._or = [];                                  // [and, and, ..., active]
        this._active = null;

        if (range !== undefined) {
            this.pattern(range);
        }

        this._another();
    }

    _another() {

        if (!this._active ||
            this._active.rules.length) {

            this._active = { rules: [] };
            this._or.push(this._active);
        }

        return this;
    }

    _rule(operator, version) {

        version = exports.version(version, this._settings);

        const compare = internals.operator(operator);
        this._active.rules.push({ compare, version, operator });

        return this;
    }

    get or() {

        return this._another();
    }

    equal(version) {

        return this._rule('=', version);
    }

    above(version) {

        return this._rule('>', version);
    }

    below(version) {

        return this._rule('<', version);
    }

    between(from, to) {

        this._rule('>=', from);
        this._rule('<=', to);
        return this;
    }

    minor(version) {        // ~1.2.3

        // minor(2.5.7)     ->      2.5.7 <= X < 2.6.0
        // minor(2.5.x)     ->      2.5.0 <= X < 2.6.0
        // minor(2.x.x)     ->      2.0.0 <= X < 3.0.0

        version = exports.version(version, this._settings);

        if (version.major === internals.any) {
            this._rule('=', version);
            return this;
        }

        this._rule('>=', version);

        if (version.minor === internals.any) {
            this._rule('<', { major: version.major + 1, minor: 0, patch: 0, prerelease: [0] });
        }
        else {
            this._rule('<', { major: version.major, minor: version.minor + 1, patch: 0, prerelease: [0] });
        }

        return this;
    }

    compatible(version) {       // ^1.2.3

        // compatible(2.5.7)     ->      2.5.7 <= X < 3.0.0
        // compatible(2.x.x)     ->      2.0.0 <= X < 3.0.0
        // compatible(0.1.x)     ->      0.1.0 <= X < 0.2.0

        version = exports.version(version, this._settings);

        if (version.major === internals.any) {
            this._rule('=', version);
            return this;
        }

        this._rule('>=', version);

        if (version.major === 0 &&
            version.minor !== internals.any) {

            if (version.minor === 0) {
                this._rule('<', { major: 0, minor: 0, patch: version.patch + 1, prerelease: [0] });
            }
            else {
                this._rule('<', { major: 0, minor: version.minor + 1, patch: 0, prerelease: [0] });
            }
        }
        else {
            this._rule('<', { major: version.major + 1, minor: 0, patch: 0, prerelease: [0] });
        }

        return this;
    }

    pattern(range) {

        try {
            this._pattern(range);
            return this;
        }
        catch (err) {
            throw new Error(`Invalid range: "${range}" because: ${err.message}`);
        }
    }

    _pattern(range) {

        if (range === '') {
            this._anything = true;
            return;
        }

        const normalized = internals.normalize(range);
        const ors = normalized.split(/\s*\|\|\s*/);
        for (const condition of ors) {
            if (!condition) {
                this._anything = true;
                return;
            }

            this._another();

            const ands = condition.split(/\s+/);
            for (const and of ands) {

                // Hyphen range

                const hyphen = and.indexOf('@');            // Originally " - "
                if (hyphen !== -1) {
                    const from = and.slice(0, hyphen);
                    const to = and.slice(hyphen + 1);
                    this.between(from, to);
                    continue;
                }

                // Prefix

                const parts = and.match(/^(\^|~|<\=|>\=|<|>|\=)?(.+)$/);
                const operator = parts[1];
                const version = exports.version(parts[2], this._settings);

                if (version.wildcard) {
                    this._anything = true;
                    return;
                }

                // Tilde

                if (operator === '~') {
                    this.minor(version);
                    continue;
                }

                // Caret

                if (operator === '^') {
                    this.compatible(version);
                    continue;
                }

                // One sided range

                if (operator) {
                    this._rule(operator, version);
                    continue;
                }

                // Version

                this.equal(version);
            }
        }
    }

    match(version, options = {}) {

        version = exports.version(version, this._settings);       // Always parse to validate

        if (this._anything) {
            return !!options.includePrerelease || !version.prerelease.length;
        }

        for (const { rules } of this._or) {
            if (!rules.length) {
                continue;
            }

            let matches = 0;
            let excludes = 0;

            for (const rule of rules) {
                const compare = version.compare(rule.version, Object.assign(this._settings, options, { range: true }));
                const exclude = Math.abs(compare) === 2;

                if (rule.compare.includes(compare / (exclude ? 2 : 1))) {
                    ++matches;
                    if (exclude) {
                        ++excludes;
                    }
                }
                else {
                    break;
                }
            }

            if (matches === rules.length &&
                excludes < matches) {

                return true;
            }
        }

        return false;
    }

    toString() {

        if (this._anything) {
            return '*';
        }

        let string = '';
        for (const { rules } of this._or) {
            if (!rules.length) {
                continue;
            }

            const conditions = [];
            for (const rule of rules) {
                conditions.push(`${rule.operator !== '=' ? rule.operator : ''}${rule.version.version}`);
            }

            string += (string ? '||' : '') + conditions.join(' ');
        }

        return string;
    }
};


internals.operator = function (compare) {

    switch (compare) {
        case '=': return [0];
        case '>': return [1];
        case '>=': return [0, 1];
        case '<': return [-1];
        case '<=': return [0, -1];
    }
};


internals.normalize = function (range) {

    return range
        .replace(/ \- /g, '@')                                                          // Range to excluded symbol
        .replace(/~>/g, '~')                                                            // Legacy npm operator
        .replace(/(\^|~|<\=|>\=|<|>|\=)\s*([^\s]+)/g, ($0, $1, $2) => `${$1}${$2}`);    // Space between operator and version
};
