import memoizee from 'memoizee';
import * as semver from 'semver';

type VersionInput = string | null | undefined;
type Release =
	| 'premajor'
	| 'preminor'
	| 'prepatch'
	| 'prerelease'
	| 'major'
	| 'minor'
	| 'patch';

const trimOsText = (version: string) => {
	// Remove "Resin OS" and "Balena OS" text
	return (
		version
			.replace(/(resin|balena)\s*os\s*/gi, '')
			// Remove optional versioning, eg "(prod)", "(dev)"
			.replace(/\s+\(\w+\)$/, '')
			// Remove "v" prefix
			.replace(/^v/, '')
	);
};

const safeSemver = (version: string) => {
	// fix major.minor.patch.rev to use rev as build metadata
	return (
		version
			// Replace any underscores with plusses, as they should
			// have the same semantics
			.replace(/([0-9]+\.[0-9]+\.[0-9]+)_(\w+)/, '$1+$2')
			.replace(/(\.[0-9]+)\.rev/, '$1+rev')
			// fix major.minor.patch.prod to be treat .dev & .prod as build metadata
			.replace(/([0-9]+\.[0-9]+\.[0-9]+)\.(dev|prod)\b/i, '$1+$2')
			// if there are no build metadata, then treat the parenthesized value as one
			.replace(
				/([0-9]+\.[0-9]+\.[0-9]+(?:[-.][0-9a-z]+)*) \(([0-9a-z]+)\)/i,
				'$1+$2',
			)
			// if there are build metadata, then treat the parenthesized value as point value
			.replace(
				/([0-9]+\.[0-9]+\.[0-9]+(?:[-+.][0-9a-z]+)*) \(([0-9a-z]+)\)/i,
				'$1.$2',
			)
			// remove leading zeros
			.replace(/([^0-9])0+([1-9]+)/i, '$1$2')
	);
};

const normalize = (version: string): string =>
	trimOsText(safeSemver(version.trim()));

const REVISION_REGEXP = /^rev(\d+)$/;

/**
 * @summary Get the version from a SemVer object
 * @name getRevision
 * @public
 * @function
 *
 * @description Returns the integer revision extracted from the build parts of the provided semver bject. Returns null
 * when no revN part is found, which consumers might want to coalesce to 0.
 *
 * @param {string} version
 *
 * @returns {SemVer|null} - The integer revision detected on the revN build part,
 * or null if no matching revN part was found.
 */
export const getRevision = (parsedVersion: semver.SemVer) => {
	for (const metadataPart of parsedVersion.build) {
		const matches = REVISION_REGEXP.exec(metadataPart);
		const rev = matches?.[1];
		if (rev != null) {
			return parseInt(rev, 10);
		}
	}
	return null;
};

const isDevelopmentVersion = (parsedVersion: semver.SemVer | null) => {
	if (parsedVersion === null) {
		return false;
	}

	return parsedVersion.build.includes('dev');
};

const compareValues = <T>(valueA: T, valueB: T) => {
	if (valueA > valueB) {
		return 1;
	}
	if (valueA < valueB) {
		return -1;
	}
	return 0;
};

/**
 * @summary Compare order of versions
 * @name compare
 * @public
 * @function
 *
 * @description Accepts string or null values and compares them, returning a number
 * indicating sort order. Values are parsed for valid semver strings. Sorts an array
 * of versions in ascending order if passed to `Array.sort()`.
 *
 *
 * @param {string|null|undefined} versionA - The first version to compare
 * @param {string|null|undefined} versionB - The second version to compare
 *
 * @returns {number} Returns `0` if `versionA == versionB`,
 * or `1` if `versionA` is greater, or `-1` if `versionB` is greater.
 * Null values are sorted before invalid semver values, and invalid semver values
 * are sorted before valid semver values
 * If both values are invalid semver values, then the values are compared alphabetically.
 */
export const compare = memoizee(
	(versionA: VersionInput, versionB: VersionInput): number => {
		if (versionA == null) {
			return versionB == null ? 0 : -1;
		}
		if (versionB == null) {
			return 1;
		}

		versionA = normalize(versionA);
		versionB = normalize(versionB);

		const semverA = semver.parse(versionA);
		const semverB = semver.parse(versionB);
		if (!semverA || !semverB) {
			if (semverA) {
				// !semverB
				return 1;
			}
			if (semverB) {
				// !semverA
				return -1;
			}
			return compareValues(versionA, versionB);
		}

		const semverResult = semver.compare(semverA, semverB);
		if (semverResult !== 0) {
			return semverResult;
		}

		const revResult = compareValues(
			getRevision(semverA) ?? 0,
			getRevision(semverB) ?? 0,
		);
		if (revResult !== 0) {
			return revResult;
		}

		const devResult = compareValues(
			isDevelopmentVersion(semverA),
			isDevelopmentVersion(semverB),
		);
		if (devResult !== 0) {
			return devResult * -1;
		}

		return versionA.localeCompare(versionB);
	},
	{ primitive: true, normalizer: ([a, b]) => `${a} && ${b}` },
);

/**
 * @summary Compare order of versions in reverse
 * @name rcompare
 * @public
 * @function
 *
 * @description The reverse of `.compare()`. Accepts string or null values and compares
 * them, returning a number indicating sort order. Values are parsed for valid semver
 * strings. Sorts an array of versions in descending order when passed to `Array.sort()`.
 *
 * @param {string|null|undefined} versionA - The first version to compare
 * @param {string|null|undefined} versionB - The second version to compare
 *
 * @returns {number} Returns `0` if `versionA == versionB`,
 * or `-1` if `versionA` is greater, or `1` if `versionB` is greater.
 * Valid semver values are sorted before invalid semver values, and invalid semver values are
 * sorted before null values.
 * If both values are non-null invalid semver values, then the values are compared alphabetically.
 */
export const rcompare = (
	versionA: VersionInput,
	versionB: VersionInput,
): number => {
	return 0 - compare(versionA, versionB);
};

/**
 * @summary Return the major version number
 * @name major
 * @public
 * @function
 *
 * @description Returns the major version number in a semver string.
 * If the version is not a valid semver string, or a valid semver string cannot be
 * found, it returns null.
 *
 * @param {string|null|undefine} version - The version string to evaluate
 *
 * @returns {number|null} - The major version number
 */
export const major = (version: VersionInput): number | null => {
	if (!version) {
		return null;
	}

	version = normalize(version);

	if (semver.valid(version)) {
		return semver.major(version);
	}

	return null;
};

/**
 * @summary Return prerelease components
 * @name prerelease
 * @public
 * @function
 *
 * @description Returns an array of prerelease components, or null if none exist
 *
 * @param {string|null|undefined} version - The version string to evaluate
 *
 * @returns {Array.<string|number>|null} - An array of prerelease component, or null if none exist
 */
export const prerelease = (version: VersionInput) => {
	if (!version) {
		return null;
	}

	version = normalize(version);

	return semver.prerelease(version);
};

/**
 * @summary Check if a version is greater than or equal to another
 * @name gte
 * @public
 * @function
 *
 * @description Returns true if versionA is greater than or equal to versionB.
 * Valid semver versions are always weighted above non semver strings.
 * Non-semver strings are compared alphabetically.
 *
 * @param {string|null|undefined} versionA - The version string to compare against
 * @param {string|null|undefined} versionB - The version string to compare to versionA
 *
 * @returns {boolean} - true if versionA is greater than or equal to versionB, otherwise false.
 */
export const gte = (
	versionA: VersionInput,
	versionB: VersionInput,
): boolean => {
	return compare(versionA, versionB) >= 0;
};

/**
 * @summary Check if a version is greater than another
 * @name gt
 * @public
 * @function
 *
 * @description Returns true if versionA is greater than versionB.
 * Valid semver versions are always weighted above non semver strings.
 * Non-semver strings are compared alphabetically.
 *
 * @param {string|null|undefined} versionA - The version string to compare against
 * @param {string|null|undefined} versionB - The version string to compare to versionA
 *
 *
 * @returns {boolean} - true if versionA is greater than versionB, otherwise false.
 */
export const gt = (versionA: VersionInput, versionB: VersionInput): boolean => {
	return compare(versionA, versionB) > 0;
};

/**
 * @summary Check if a version is less than or equal to another
 * @name lte
 * @public
 * @function
 *
 * @description Returns true if versionA is less than or equal to versionB.
 * Valid semver versions are always weighted above non semver strings.
 * Non-semver strings are compared alphabetically.
 *
 * @param {string|null|undefined} versionA - The version string to compare against
 * @param {string|null|undefined} versionB - The version string to compare to versionA
 *
 * @returns {boolean} - true if versionA is greater than or equal to versionB, otherwise false.
 */
export const lte = (
	versionA: VersionInput,
	versionB: VersionInput,
): boolean => {
	return compare(versionA, versionB) <= 0;
};

/**
 * @summary Check if a version is less than another
 * @name lt
 * @public
 * @function
 *
 * @description Returns true if versionA is less than versionB.
 * Valid semver versions are always weighted above non semver strings.
 * Non-semver strings are compared alphabetically.
 *
 * @param {string|null|undefined} versionA - The version string to compare against
 * @param {string|null|undefined} versionB - The version string to compare to versionA
 *
 * @returns {boolean} - true if versionA is less than versionB, otherwise false.
 */
export const lt = (versionA: VersionInput, versionB: VersionInput): boolean => {
	return compare(versionA, versionB) < 0;
};

/**
 * @summary Check if a version satisfies a range
 * @name satisfies
 * @public
 * @function
 *
 * @description Return true if the parsed version satisfies the range.
 * This method will always return false if the provided version doesn't contain a valid semver string.
 *
 * @param {string|null|undefined} version - The version to evaluate
 * @param {string} range - A semver range string, see the [node-semver](https://github.com/npm/node-semver#ranges)
 * docs for details
 *
 * @returns {boolean} - True if the parsed version satisfies the range, false otherwise
 *
 */
export const satisfies = (version: VersionInput, range: string) => {
	if (version == null) {
		return false;
	}

	version = normalize(version);

	if (!semver.valid(version)) {
		return false;
	}

	return semver.satisfies(version, range, {
		loose: true,
	});
};

/**
 * @summary Return the highest version in the list that satisfies the range
 * @name maxSatisfying
 * @public
 * @function
 *
 * @description Return the highest version in the list that satisfies the range, or null if none of them do.
 * If multiple versions are found that have equally high values, the last one in the array is returned.
 * Note that only version that contain a valid semver string can satisfy a range.
 *
 * @param {Array.<string|null|undefined>} versions - An array of versions to evaluate
 * @param {string} range - A semver range string, see the [node-semver](https://github.com/npm/node-semver#ranges)
 * docs for details
 *
 * @returns {string|null} - The highest matching version string, or null.
 *
 */
export const maxSatisfying = (versions: VersionInput[], range: string) => {
	let max: VersionInput = null;

	const normalizedRange = normalize(range);

	versions.forEach((version) => {
		if (satisfies(version, normalizedRange) && gt(version, max)) {
			max = version;
		}
	});

	return max;
};

/*
 * @typedef {Object} SemverObject
 * @property {string} raw - The original version string
 * @property {number} major - The major version number
 * @property {number} minor - The minor version number
 * @property {number} patch - The patch version number
 * @property {Array.<string|number>} prerelease - An array of prerelease values
 * @property {Array.<string|number>} build - An array of build values
 * @property {string} version - The version containing just major, minor, patch
 * and prerelease information
 */

/**
 * @summary Parse a version into an object
 * @name parse
 * @public
 * @function
 *
 * @description Returns an object representing the semver version. Returns null
 * if a valid semver string can't be found.
 *
 * @param {string|null|undefined} version
 *
 * @returns {SemverObject|null} - An object representing the version string, or
 * null if a valid semver string could not be found
 */
export const parse = (version: VersionInput) => {
	if (version == null) {
		return null;
	}
	const parsed = semver.parse(normalize(version));

	if (parsed) {
		parsed.raw = version;
	}

	return parsed;
};

/**
 * @summary Check if a version string is valid
 * @name valid
 * @public
 * @function
 *
 * @description Return the parsed version, or null if it's not valid.
 *
 * @param {string|null|undefined} version
 *
 * @returns {string|null} - The parsed version string, or
 * null if a valid semver string could not be found
 */
export const valid = (version: VersionInput) => {
	if (version == null) {
		return null;
	}

	return semver.valid(normalize(version));
};

/**
 * @summary Return an incremented version
 * @name inc
 * @public
 * @function
 *
 * @description Return the version incremented by the release type
 * (major, premajor, minor, preminor, patch, prepatch, or prerelease), or null
 * if it's not valid.
 *
 * @param {string|null|undefined} version
 * @param {string} release
 *
 * @returns {string|null} - The incremented version string, or
 * null if a valid semver string could not be found
 */
export const inc = (version: VersionInput, release: Release) => {
	if (version == null) {
		return null;
	}
	return semver.inc(normalize(version), release);
};
