// noinspection SpellCheckingInspection

import { Group, InvalidTokenError } from '../common';
import { numberToExponential } from '../exponent';

const DECIMAL_POINT = '.' as const;

const GROUPING_SYMBOL = ',' as const;

const NEGATIVE = 'negative' as const;

const NEGATIVE_SYMBOL = '-' as const;

const POSITIVE_SYMBOL = '+' as const;

const SHORT_MILLIA_DELIMITER = '^' as const;

const EXPONENT_DELIMITER = 'e' as const;

const EMPTY_GROUP_DIGITS = '000' as const;

const EMPTY_PLACE: Group = [EMPTY_GROUP_DIGITS, 0];

/**
 * Ones number names.
 */
const ONES = [
	'zero',
	'one',
	'two',
	'three',
	'four',
	'five',
	'six',
	'seven',
	'eight',
	'nine',
] as const;

type OnesName = typeof ONES[number];

/**
 * Ten plus ones number names.
 */
const TEN_PLUS_ONES = [
	'ten',
	'eleven',
	'twelve',
	'thirteen',
	'fourteen',
	'fifteen',
	'sixteen',
	'seventeen',
	'eighteen',
	'nineteen',
] as const;

type TenPlusOnesName = typeof TEN_PLUS_ONES[number];

/**
 * Tens number names.
 */
const TENS = [
	'zero',
	TEN_PLUS_ONES[0],
	'twenty',
	'thirty',
	'forty',
	'fifty',
	'sixty',
	'seventy',
	'eighty',
	'ninety',
] as const;

type TensName = typeof TENS[number];

/**
 * Hundreds name.
 */
const HUNDRED = 'hundred' as const;

/**
 * Thousands name.
 */
const THOUSAND = 'thousand' as const;

// const ILLION_ORDINAL_SUFFIX = 'illionth' as const;

// const THOUSAND_ORDINAL = 'thousandth' as const;

/**
 * Special millions name.
 */
const MILLIONS_SPECIAL_PREFIXES = [
	'',
	'm',
	'b',
	'tr',
	'quadr',
	'quint',
	'sext',
	'sept',
	'oct',
	'non',
] as const;

type MillionsSpecialPrefix = Exclude<typeof MILLIONS_SPECIAL_PREFIXES[number], ''>;

/**
 * Millions name.
 */
const MILLIONS_PREFIXES = [
	'',
	'un',
	'duo',
	'tre',
	'quattuor',
	'quin',
	'sex',
	'septen',
	'octo',
	'novem',
] as const;

type MillionsPrefix = Exclude<typeof MILLIONS_PREFIXES[number], ''>;

/**
 * Decillions name.
 */
const DECILLIONS_PREFIXES = [
	'',
	'dec',
	'vigin',
	'trigin',
	'quadragin',
	'quinquagin',
	'sexagin',
	'septuagin',
	'octogin',
	'nonagin',
] as const;

type DecillionsPrefix = Exclude<typeof DECILLIONS_PREFIXES[number], ''>;

/**
 * Centillions name.
 */
const CENTILLIONS_PREFIXES = [
	'',
	'cen',
	'duocen',
	'trecen',
	'quadringen',
	'quingen',
	'sescen',
	'septingen',
	'octingen',
	'nongen',
] as const;

type CentillionsPrefix = Exclude<typeof CENTILLIONS_PREFIXES[number], ''>;

/**
 * Prefix for millia- number names.
 */
const MILLIA_PREFIX = 'millia' as const;

/**
 * Suffix for -illion number names.
 */
const ILLION_SUFFIX = 'illion' as const;

/**
 * Builds a name for numbers in tens and ones.
 * @param tens - Tens digit.
 * @param ones - Ones digit.
 * @returns The name for the number.
 */
const makeTensName = (tens: number, ones: number) => {
	if (tens === 0) {
		return ONES[ones];
	}

	if (tens === 1) {
		return TEN_PLUS_ONES[ones] as unknown as TenPlusOnesName;
	}

	if (ones === 0) {
		return TENS[tens];
	}

	return `${TENS[tens] as Exclude<TensName, 'zero' | 'ten'>} ${ONES[ones] as Exclude<OnesName, 'zero'>}` as const;
};

/**
 * Builds a name for numbers in hundreds, tens, and ones.
 * @param hundreds - Hundreds digit.
 * @param tens - Tens digit.
 * @param ones - Ones digit.
 * @returns The name for the number.
 */
const makeHundredsName = (hundreds: number, tens: number, ones: number) => {
	if (hundreds === 0) {
		return makeTensName(tens, ones);
	}

	if (tens === 0 && ones === 0) {
		return `${ONES[hundreds]} ${HUNDRED}` as const;
	}

	return `${ONES[hundreds]} ${HUNDRED} ${makeTensName(tens, ones)}` as const;
};

/**
 * Builds a name for numbers in the millions.
 * @param millions - Millions digit.
 * @param milliaCount - Number of millia- groups.
 * @returns The millions prefix.
 */
const makeMillionsPrefix = (millions: number, milliaCount: number) => {
	if (milliaCount > 0) {
		return MILLIONS_PREFIXES[millions] as MillionsPrefix;
	}

	return MILLIONS_SPECIAL_PREFIXES[millions] as MillionsSpecialPrefix;
};

/**
 * Builds a name for numbers in the decillions.
 * @param decillions - Decillions digit.
 * @param millions - Millions digit.
 * @param milliaCount - Number of millia- groups.
 * @returns The decillions prefix.
 */
const makeDecillionsPrefix = (decillions: number, millions: number, milliaCount: number) => {
	if (decillions === 0) {
		return makeMillionsPrefix(millions, milliaCount);
	}

	const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
	const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
	return `${onesPrefix}${tensName}` as const;
};

/**
 * Builds a name for numbers in the centillions.
 * @param centillions - Centillions digit.
 * @param decillions - Decillions digit.
 * @param millions - Millions digit.
 * @param milliaCount - Number of millia- groups.
 * @returns The centillions prefix.
 */
const makeCentillionsPrefix = (
	centillions: number,
	decillions: number,
	millions: number,
	milliaCount: number,
) => {
	if (centillions === 0) {
		return makeDecillionsPrefix(decillions, millions, milliaCount);
	}

	const onesPrefix = MILLIONS_PREFIXES[millions] as MillionsPrefix;
	const tensName = DECILLIONS_PREFIXES[decillions] as DecillionsPrefix;
	const hundredsName = CENTILLIONS_PREFIXES[centillions] as CentillionsPrefix;
	return `${hundredsName}${onesPrefix}${decillions > 0 ? tensName : ''}` as const;
};

const getGroupName = (place: number, shortenMillia: boolean) => {
	if (place === 0) {
		return '' as const;
	}

	if (place === 1) {
		return THOUSAND;
	}

	const bigGroupPlace = place - 1;
	const groupGroups = bigGroupPlace
		.toString()
		.split('')
		.reduceRight<Group[]>(
			(acc, c, i, cc) => {
				const firstGroup = acc.at(0);
				const currentPlace = Math.floor((cc.length - i - 1) / 3);
				if (typeof firstGroup === 'undefined') {
					return [[c, currentPlace]];
				}

				if (firstGroup[0].length > 2) {
					return [[c, currentPlace], ...acc];
				}

				return [
					[c + firstGroup[0], currentPlace],
					...acc.slice(1),
				];
			},
			[],
		)
		.map(([groupDigits, groupPlace]) => [groupDigits.padStart(3, '0'), groupPlace] as const)
		.filter(([groupDigits]) => groupDigits !== EMPTY_GROUP_DIGITS)
		.map(([groupDigits, groupPlace]) => {
			const [hundreds, tens, ones] = groupDigits.split('').map(Number);
			if (groupPlace < 1) {
				return makeCentillionsPrefix(hundreds, tens, ones, groupPlace);
			}

			const milliaSuffix = (
				shortenMillia && groupPlace > 1
					? `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${groupPlace}`
					: MILLIA_PREFIX.repeat(groupPlace)
			);

			if (groupDigits === '001') {
				return milliaSuffix;
			}

			return makeCentillionsPrefix(hundreds, tens, ones, groupPlace) + milliaSuffix;
		})
		.join('');

	if (groupGroups.endsWith(DECILLIONS_PREFIXES[1])) {
		return `${groupGroups}${ILLION_SUFFIX}` as const;
	}

	if (bigGroupPlace > 10) {
		return `${groupGroups}t${ILLION_SUFFIX}` as const;
	}

	return `${groupGroups}${ILLION_SUFFIX}` as const;
};

export const makeGroup = (
	group: string,
	place: number,
	options?: Record<string, unknown>,
): string => {
	const makeHundredsArgs = group
		.padStart(3, '0')
		.split('')
		.map((s) => Number(s)) as [number, number, number];

	const groupDigitsName = makeHundredsName(...makeHundredsArgs);
	const groupName = getGroupName(place, options?.shortenMillia as boolean ?? false);
	if (groupName.length > 0) {
		return `${groupDigitsName} ${groupName}` as const;
	}
	return groupDigitsName;
};

/**
 * Group a number string into groups of three digits, starting from the decimal point.
 * @param value - The number string to group.
 */
export const group = (value: string): Group[] => {
	const [significand, exponentString] = numberToExponential(
		value,
		{
			decimalPoint: DECIMAL_POINT,
			groupingSymbol: GROUPING_SYMBOL,
			exponentDelimiter: EXPONENT_DELIMITER,
		},
	)
		.split(EXPONENT_DELIMITER);
	const exponent = Number(exponentString);
	const significantDigits = significand.replace(DECIMAL_POINT, '');
	return significantDigits.split('').reduce<Group[]>(
		(acc, c, i) => {
			const currentPlace = Math.floor((exponent - i) / 3);
			const lastGroup = acc.at(-1) ?? [EMPTY_GROUP_DIGITS, currentPlace];
			const currentPlaceInGroup = 2 - ((exponent - i) % 3);
			if (lastGroup[1] === currentPlace) {
				const lastGroupDigits = lastGroup[0].split('');
				lastGroupDigits[currentPlaceInGroup] = c;
				return [...acc.slice(0, -1) ?? [], [
					lastGroupDigits.join(''),
					currentPlace,
				]];
			}
			return [...acc, [c.padEnd(3, '0'), currentPlace]];
		},
		[],
	);
};

/**
 * Formats the final tokenized string.
 * @param tokens - The tokens to finalize.
 */
export const finalize = (tokens: string[]) => (
	tokens
		.map((t) => t.trim())
		.join(' ')
		.trim()
);

/**
 * Makes a negative string.
 * @param s - The string to make negative.
 */
export const makeNegative = (s: string) => (
	`${NEGATIVE} ${s}`
);

export const tokenize = (stringValue: string) => (
	stringValue.split(' ').filter((maybeToken) => maybeToken.length > 0)
);

const FINAL_TOKEN = '' as const;

interface DoParseState {
	groupNameCurrent: string;
	millias: number[];
	milliaIndex: number;
	done: boolean;
}

const doParseGroupName = (result: DoParseState): DoParseState => {
	if (result.groupNameCurrent.length < 1) {
		return {
			...result,
			done: true,
		};
	}

	if (result.groupNameCurrent === 't') {
		// If the current group name is "t", then we're done.
		// We use the -t- affix to attach the group prefix to the -illion suffix, except for decillion.
		return {
			...result,
			done: true,
		};
	}

	const centillions = CENTILLIONS_PREFIXES.findIndex((p) => (
		p.length > 0 && result.groupNameCurrent.startsWith(p)
	));
	if (centillions > -1) {
		return {
			milliaIndex: 0,
			millias: result.millias.map((m, i) => (
				i === 0
					? m + (centillions * 100)
					: m
			)),
			groupNameCurrent: result.groupNameCurrent.slice(
				CENTILLIONS_PREFIXES[centillions].length,
			),
			done: false,
		};
	}

	const decillions = DECILLIONS_PREFIXES.findIndex((p) => (
		p.length > 0 && result.groupNameCurrent.startsWith(p)
	));
	if (decillions > -1) {
		return {
			milliaIndex: 0,
			millias: result.millias.map((m, i) => (
				i === 0
					? m + (decillions * 10)
					: m
			)),
			groupNameCurrent: result.groupNameCurrent.slice(
				DECILLIONS_PREFIXES[decillions].length,
			),
			done: false,
		};
	}

	const millions = MILLIONS_PREFIXES.findIndex((p) => (
		p.length > 0 && result.groupNameCurrent.startsWith(p)
	));
	if (millions > -1) {
		return {
			milliaIndex: 0,
			millias: result.millias.map((m, i) => (
				i === 0
					? m + millions
					: m
			)),
			groupNameCurrent: result.groupNameCurrent.slice(
				MILLIONS_PREFIXES[millions].length,
			),
			done: false,
		};
	}

	if (result.groupNameCurrent.startsWith(`${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}`)) {
		// short millia
		const matchedMilliaArray = result.groupNameCurrent.match(/^\d+/);
		if (!matchedMilliaArray) {
			throw new InvalidTokenError(result.groupNameCurrent);
		}
		const matchedMillia = matchedMilliaArray[0];
		const newMillia = Number(matchedMillia);
		const oldMillia = result.milliaIndex;
		const newMillias = [...result.millias];
		newMillias[newMillia] = newMillias[oldMillia] || 1;
		newMillias[oldMillia] = 0;
		return {
			milliaIndex: newMillia,
			millias: newMillias,
			groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
			done: false,
		};
	}

	if (result.groupNameCurrent.startsWith(MILLIA_PREFIX)) {
		const newMillia = result.milliaIndex + 1;
		const oldMillia = result.milliaIndex;
		const newMillias = [...result.millias];
		newMillias[newMillia] = newMillias[oldMillia] || 1;
		newMillias[oldMillia] = 0;
		return {
			milliaIndex: newMillia,
			millias: newMillias,
			groupNameCurrent: result.groupNameCurrent.slice(MILLIA_PREFIX.length),
			done: false,
		};
	}

	throw new InvalidTokenError(result.groupNameCurrent);
};

const getGroupPlaceFromGroupName = (groupName: string) => {
	if (groupName === THOUSAND) {
		return 1;
	}

	const groupNameBase = groupName.replace(ILLION_SUFFIX, '');
	const specialMillions = MILLIONS_SPECIAL_PREFIXES.findIndex((p) => groupNameBase === p);

	if (specialMillions > -1) {
		return specialMillions + 1;
	}

	let result: DoParseState = {
		groupNameCurrent: groupNameBase,
		millias: [0],
		milliaIndex: 0,
		done: false,
	};

	do {
		result = doParseGroupName(result);
	} while (!result.done);

	const bigGroupPlace = Number(
		result.millias
			.map((s) => s.toString().padStart(3, '0'))
			.reverse()
			.join(''),
	);

	return bigGroupPlace + 1;
};

enum ParseGroupsMode {
	INITIAL = 'unknown',
	ONES_MODE = 'ones',
	TENS_MODE = 'tens',
	TEN_PLUS_ONES_MODE = 'tenPlusOnes',
	HUNDRED_MODE = 'hundred',
	THOUSAND_MODE = 'thousand',
	DONE = 'done',
}

interface ParserState {
	lastToken?: string;
	groups: Group[];
	mode: ParseGroupsMode;
}

export const parseGroups = (tokens: string[]) => {
	const { groups } = [...tokens, FINAL_TOKEN].reduce<ParserState>(
		(acc, token) => {
			const lastGroup = acc.groups.at(-1) ?? [...EMPTY_PLACE];

			if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) {
				if (acc.mode === ParseGroupsMode.ONES_MODE) {
					const ones = ONES.findIndex((o) => o === acc.lastToken);
					if (ones > -1) {
						lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
					}
				}

				lastGroup[1] = getGroupPlaceFromGroupName(token);

				return {
					...acc,
					groups: [...acc.groups.slice(0, -1), lastGroup],
					lastToken: token,
					mode: ParseGroupsMode.THOUSAND_MODE,
				};
			}

			if (token === HUNDRED) {
				if (acc.mode === ParseGroupsMode.ONES_MODE) {
					const hundreds = ONES.findIndex((o) => o === acc.lastToken);
					lastGroup[0] = `${hundreds}${lastGroup[0].slice(1)}`;
					return {
						...acc,
						groups: [...acc.groups.slice(0, -1), lastGroup],
						mode: ParseGroupsMode.HUNDRED_MODE,
					};
				}
			}

			if (token === FINAL_TOKEN) {
				if (acc.mode === ParseGroupsMode.ONES_MODE) {
					const ones = ONES.findIndex((o) => o === acc.lastToken);
					lastGroup[0] = `${lastGroup[0].slice(0, 2)}${ones}`;
					lastGroup[1] = 0;
					return {
						...acc,
						groups: [...acc.groups.slice(0, -1), lastGroup],
						mode: ParseGroupsMode.DONE,
					};
				}
			}

			if (ONES.includes(token as OnesName)) {
				if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
					return {
						...acc,
						lastToken: token,
						mode: ParseGroupsMode.ONES_MODE,
						groups: [...acc.groups, [...EMPTY_PLACE]],
					};
				}
				return {
					...acc,
					lastToken: token,
					mode: ParseGroupsMode.ONES_MODE,
				};
			}

			const tenPlusOnes = TEN_PLUS_ONES.findIndex((t) => t === token);
			if (tenPlusOnes > -1) {
				if (acc.mode === ParseGroupsMode.THOUSAND_MODE) {
					return {
						...acc,
						lastToken: token,
						mode: ParseGroupsMode.ONES_MODE,
						groups: [...acc.groups, [`01${tenPlusOnes}`, lastGroup[1] - 1]],
					};
				}

				lastGroup[0] = `${lastGroup[0].slice(0, 1)}1${tenPlusOnes}`;
				return {
					...acc,
					lastToken: token,
					mode: ParseGroupsMode.TEN_PLUS_ONES_MODE,
					groups: [...acc.groups.slice(0, -1), lastGroup],
				};
			}

			const tens = TENS.findIndex((t) => t === token);
			if (tens > -1) {
				lastGroup[0] = `${lastGroup[0].slice(0, 1)}${tens}${lastGroup[0].slice(2)}`;
				return {
					...acc,
					lastToken: token,
					mode: ParseGroupsMode.TENS_MODE,
					groups: [...acc.groups.slice(0, -1), lastGroup],
				};
			}

			return {
				...acc,
				lastToken: token,
			};
		},
		{
			lastToken: undefined,
			groups: [],
			mode: ParseGroupsMode.INITIAL,
		},
	);

	return groups;
};

export const combineGroups = (groups: Group[]) => {
	const groupsSorted = groups.sort((a, b) => b[1] - a[1]); // sort by place
	const firstGroup = groupsSorted[0];
	const firstGroupPlace = firstGroup[1];
	const digits = groupsSorted.reduce(
		(previousDigits, thisGroup) => {
			const [groupDigits] = thisGroup;
			return `${previousDigits}${groupDigits}`;
		},
		'',
	).replace(/^0+/, '') || '0';
	const firstGroupDigits = firstGroup[0];
	const firstGroupDigitsWithoutZeroes = firstGroupDigits.replace(/^0+/, '');
	const exponentExtra = firstGroupDigits.length - firstGroupDigitsWithoutZeroes.length;
	const exponentValue = BigInt((firstGroupPlace * 3) + (2 - exponentExtra));
	const isExponentNegative = exponentValue < 0;
	const exponentValueAbs = isExponentNegative ? -exponentValue : exponentValue;
	const exponentSign = isExponentNegative ? NEGATIVE_SYMBOL : POSITIVE_SYMBOL;
	const exponent = `${exponentSign}${exponentValueAbs}`;
	const significandInteger = digits.slice(0, 1);
	const significandFraction = digits.slice(1).replace(/0+$/, '');
	if (significandFraction.length > 0) {
		return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`;
	}
	return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`;
};