import { GetEntriesAsArray } from "../util/misc";
import { ThrowIf } from "../util/throw-if";
import { integer } from "../util/types";

export type SDVXGrades = "D" | "C" | "B" | "A" | "A+" | "AA" | "AA+" | "AAA" | "AAA+" | "S";
export type SDVXLamps =
	| "FAILED"
	| "CLEAR"
	| "EXCESSIVE CLEAR"
	| "MAXXIVE CLEAR"
	| "ULTIMATE CHAIN"
	| "PERFECT ULTIMATE CHAIN";

const VF4GradeCoefficients: Record<SDVXGrades, number> = {
	S: 1.0,
	"AAA+": 0.99,
	AAA: 0.98,
	"AA+": 0.97,
	AA: 0.96,
	"A+": 0.95,
	A: 0.94,
	B: 0.93,
	C: 0.92,
	D: 0.91,
};

const VF5GradeCoefficients: Record<SDVXGrades, number> = {
	S: 105,
	"AAA+": 102,
	AAA: 100,
	"AA+": 97,
	AA: 94,
	// everything below this point is marked with a (?)
	// in bemaniwiki, so maybe it can't be trusted?
	"A+": 91,
	A: 88,
	B: 85,
	C: 82,
	D: 80,
};

const VF5LampCoefficients: Record<SDVXLamps, number> = {
	"PERFECT ULTIMATE CHAIN": 110,
	"ULTIMATE CHAIN": 105,
	"MAXXIVE CLEAR": 104,
	"EXCESSIVE CLEAR": 102,
	CLEAR: 100,
	FAILED: 50,
};

/**
 * Calculate VOLFORCE as it's defined in SDVX4.
 *
 * @param score - The user's score. Between 0 and 10million.
 * @param level - The level of the chart. Between 0 and 20, but this is not enforced.
 */
export function calculateVF4(score: integer, level: integer) {
	AssertProvidedScore(score);

	const grade = SDVXScoreToGrade(score);

	const gradeCoefficient = VF4GradeCoefficients[grade];

	return Math.floor(25 * (level + 1) * (score / 10_000_000) * gradeCoefficient);
}

/**
 * Given a VF4 value and a chart level, return what score is needed to get that
 * VF4.
 *
 * If the score needed is greater than 10million, this function will throw.
 **
 * @param vf4 - The VF4 to invert.
 * @param level - The level of the chart you're inverting for.
 */
export function inverseVF4(vf4: integer, level: integer) {
	const scoreTimesGradeCoef = (10_000_000 * vf4) / (25 * (level + 1));

	const score = AttemptGradeCoefficientDivide(scoreTimesGradeCoef, VF4GradeCoefficients);

	ThrowIf(score === null, `A VF4 of ${vf4} is not possible on a chart with level ${level}`, {
		vf4,
		level,
	});

	return score;
}

/**
 * Calculate VOLFORCE as it's defined in SDVX5.
 *
 * @param score - The user's score. Between 0 and 10million.
 * @param level - The level of the chart. Between 0 and 20, but this is not enforced.
 */
export function calculateVF5(score: integer, lamp: SDVXLamps, level: integer) {
	AssertProvidedScore(score);

	const unroundedVF5 = CalculateWholeVF5(score, lamp, level);

	return Math.floor(unroundedVF5 / 10000) / 100;
}

/**
 * Given a VF5 value and a chart level, return what score is needed to get that
 * VF5.
 *
 * If the score needed is greater than 10million, this function will throw.
 *
 * @param vf5 - The VF5 to invert.
 * @param lamp - The lamp for this score. This is necessary to know, as lampCoefficient
 * plays a part in VF5.
 * @param level - The level of the chart you're inverting for.
 */
export function inverseVF5(
	vf5: number,
	// Exclude PUC as input. It doesn't make sense as input, since the answer would
	// always be 10million.
	lamp: Exclude<SDVXLamps, "PERFECT ULTIMATE CHAIN">,
	level: integer
) {
	const score = InvertUnroundedVF5(vf5, lamp, level);

	ThrowIf(score === null, `A VF5 of ${vf5} is not possible on a chart with level ${level}.`, {
		vf5,
		level,
	});

	return score;
}

/**
 * Calculate VOLFORCE as it's defined in SDVX6.
 *
 * @param score - The user's score. Between 0 and 10million.
 * @param level - The level of the chart. Between 0 and 20, but this is not enforced.
 */
export function calculateVF6(score: integer, lamp: SDVXLamps, level: integer) {
	AssertProvidedScore(score);

	const unroundedVF5 = CalculateWholeVF5(score, lamp, level);

	// VF6 is just unroundedVF5 to 3 decimal places instead of 2.
	return Math.floor(unroundedVF5 / 1000) / 1000;
}

/**
 * Given a VF6 value and a chart level, return what score is needed to get that
 * VF5.
 *
 * If the score needed is greater than 10million, this function will throw.
 **
 * @param vf6 - The VF6 to invert.
 * @param lamp - The lamp for this score. This is necessary to know, as lampCoefficient
 * plays a part in VF6. Passing "PERFECT ULTIMATE CHAIN" as a lamp is invalid, as inverting
 * it into a score makes no sense.
 * @param level - The level of the chart you're inverting for.
 */
export function inverseVF6(
	vf6: number,
	// Exclude PUC as input. It doesn't make sense as input, since the answer would
	// always be 10million.
	lamp: Exclude<SDVXLamps, "PERFECT ULTIMATE CHAIN">,
	level: integer
) {
	// note: this function is actually identical to inverseVF5, but with the caveat
	// that the error message is different.

	const score = InvertUnroundedVF5(vf6, lamp, level);

	ThrowIf(score === null, `A VF6 of ${vf6} is not possible on a chart with level ${level}.`, {
		vf6,
		level,
	});

	// guaranteed to not be null
	return score!;
}

/**
 * Calculate VF5 without performing any rounding. This is useful because VF5
 * is floored to 2 decimal places, wherease VF6 is floored to 3. This lets us
 * reuse the same algorithm.
 */
function CalculateWholeVF5(score: integer, lamp: SDVXLamps, level: integer) {
	const grade = SDVXScoreToGrade(score);

	const gradeCoefficient = VF5GradeCoefficients[grade];
	const lampCoefficient = VF5LampCoefficients[lamp];

	return level * 2 * (score / 10_000_000) * gradeCoefficient * lampCoefficient;
}

/**
 * Attempt to invert VF5 into a score.
 *
 * @returns The score if it was possible to be achieved. Else, it returns null.
 */
function InvertUnroundedVF5(vf5: number, lamp: SDVXLamps, level: integer) {
	// Note: PERFECT ULTIMATE CHAIN is never passed into this function from typescript
	// as the calling functions Exclude<T> it from the lamps.
	// However, a JS caller may call it like this anyway, so we mayaswell throw.
	ThrowIf(
		lamp === "PERFECT ULTIMATE CHAIN",
		"PERFECT ULTIMATE CHAIN as a lampCoefficient does not make sense for an inversion, since the answer would always be 10million.",
		{ lamp }
	);

	const lampCoefficient = VF5LampCoefficients[lamp];

	const scoreTimesGradeCoef = (1_000_000 * 10_000_000 * vf5) / (2 * level * lampCoefficient);

	const score = AttemptGradeCoefficientDivide(scoreTimesGradeCoef, VF5GradeCoefficients);

	return score;
}

/**
 * Convert a SDVX percent to the grade it represents.
 * @param score - The score to convert - between 0 and 10million.
 * @returns A string representing a grade.
 */
function SDVXScoreToGrade(score: integer): SDVXGrades {
	if (score < 7_000_000) {
		return "D";
	} else if (score < 8_000_000) {
		return "C";
	} else if (score < 8_700_000) {
		return "B";
	} else if (score < 9_000_000) {
		return "A";
	} else if (score < 9_300_000) {
		return "A+";
	} else if (score < 9_500_000) {
		return "AA";
	} else if (score < 9_700_000) {
		return "AA+";
	} else if (score < 9_800_000) {
		return "AAA";
	} else if (score < 9_900_000) {
		return "AAA+";
	}

	return "S";
}

/**
 * Given a SDVX grade, return the lower and upper bounds for scoring in this grade.
 * This is used to invert the gradeCoefficient function in volforce.
 *
 * Bounds are returned as lower <= k < upper.
 */
function SDVXGetGradeBoundaries(grade: SDVXGrades): { lower: integer; upper: integer } {
	if (grade === "S") {
		return { lower: 9_900_000, upper: 10_000_000 };
	} else if (grade === "AAA+") {
		return { lower: 9_800_000, upper: 9_900_000 };
	} else if (grade === "AAA") {
		return { lower: 9_700_000, upper: 9_800_000 };
	} else if (grade === "AA+") {
		return { lower: 9_500_000, upper: 9_700_000 };
	} else if (grade === "AA") {
		return { lower: 9_300_000, upper: 9_500_000 };
	} else if (grade === "A+") {
		return { lower: 9_000_000, upper: 9_300_000 };
	} else if (grade === "A") {
		return { lower: 8_700_000, upper: 9_000_000 };
	} else if (grade === "B") {
		return { lower: 8_000_000, upper: 8_700_000 };
	} else if (grade === "C") {
		return { lower: 7_000_000, upper: 8_000_000 };
	}

	return { lower: 0, upper: 7_000_000 };
}

/**
 * Assert necessary things about a provided score.
 */
function AssertProvidedScore(score: integer) {
	ThrowIf(score > 10_000_000, "Score cannot be greater than 10million", { score });
	ThrowIf.negative(score, "Score cannot be negative", { score });
}

/**
 * Go through all of the gradeBoundaries for a game and use them as guesses for score
 * values.
 *
 * This means we try dividing by all the gradeCoefficients until we find one
 * where the resulting score would have the same grade as the given coefficient.
 *
 * Used for inverting VF.
 *
 * @param scoreTimesGradeCoef - The expected score multiplied by the gradeCoefficient.
 * @param coefficients - A record of SDVXGrade -> gradeCoefficient
 * @returns The score divided by the gradeCoefficient. If not possible, this returns
 * null.
 */
function AttemptGradeCoefficientDivide(
	scoreTimesGradeCoef: number,
	coefficients: Record<SDVXGrades, number>
) {
	for (const [grade, gradeCoef] of GetEntriesAsArray(coefficients).reverse()) {
		const maybeScore = scoreTimesGradeCoef / gradeCoef;

		const { lower, upper } = SDVXGetGradeBoundaries(grade);

		if (maybeScore <= lower) {
			return lower;
		} else if (maybeScore < upper || (maybeScore === upper && upper === 10_000_000)) {
			return Math.round(maybeScore);
		}
	}

	return null;
}
