Source: core/Liqueur.js

import { Measure } from '../data/Measure.js';
import { CaloricContent } from '../data/CaloricContent.js';

import { Ingredient } from './Ingredient.js';
import { Water } from './Water.js';
import { Alcohol } from './Alcohol.js';
import { Syrup } from './Syrup.js';

import { Component } from './Component.js';
import { Composition } from './Composition.js';
import { Conversion } from './Conversion.js';

import { CalculationError } from './CalculationError.js';

/**
 * A union of syrup and alcohol solution.
 * Takes "sugar content" from syrup and "alcohol content" from alcohol
 * and creates a liqueur with both characteristics combined.
 * 
 * @extends {Ingredient}
 */
export class Liqueur extends Ingredient {
	composition = null;
	density = null;
	type = 'liqueur';
	/**
	 * Make a liqueur
	 *
	 * @constructor
	 * @param {(Alcohol|null)} [alcohol=null]
	 * @param {(Syrup|null)} [syrup=null]
	 *
	 * @example
	 * let CremeDeCassis = new Liqueur(
	 * 	new Alcohol(0.20, Measure.VV),
	 * 	new Syrup(0.400, Measure.WV),
	 * );
	 */
	constructor(alcohol, syrup) {
		super();
		let volume_left = 1000;
		this.composition = new Composition();

		if (syrup) {
			let sugar = new Component(
				new Syrup(1, Measure.WW),
				syrup.get(Measure.WV) * volume_left,
				Measure.G
			);
			volume_left -= sugar.get(Measure.ML);
			if (volume_left < 0)
				throw new CalculationError('IMPOSSIBLE_COMBINATION');
			this.composition.add('sugar', sugar);
		}

		if (alcohol) {
			let target_abv = alcohol.get(Measure.VV) * (1000 / volume_left);
			if (target_abv > 1) {
				throw new CalculationError('IMPOSSIBLE_COMBINATION');
			}

			let real_alcohol = new Alcohol(target_abv * 100, Measure.ABV);

			this.composition.add('alcohol', new Component(
				real_alcohol,
				volume_left,
				Measure.ML
			));

		} else {
			this.composition.add('buffer', new Component(
				new Water(),
				volume_left,
				Measure.ML
			));
		}

		this.density = this.composition.total(Measure.G) / 1000;
	}
	/**
	 * Create from composition
	 * 
	 * @static 
	 * @param {Composition} composition
	 * 
	 * @returns {Liqueur}
	 */
	static fromComposition(composition) {
		let info = composition.info();
		let syrup = info.sugar_content > 0 ? new Syrup(info.sugar_content, Measure.WV) : null,
			alcohol = info.abv > 0 ? new Alcohol(info.abv, Measure.ABV) : null;
		return new Liqueur(
			alcohol, 
			syrup
		);
	}
	/**
	 * Get liqueur measurement
	 * 
	 * Available measures:
	 * - Measure.BRIX
	 * - Measure.DENSITY
	 * - Measure.ABV
	 * - Measure.CW
	 * 
	 * Other measurements can be derived from Liqueur.composition
	 * 
	 * @override
	 */
	get(measure) {
		switch (measure) {
			case Measure.DENSITY:
				return this.density;
				break;
			case Measure.ABV:
				return this.composition.info().abv;
				break;
			case Measure.BRIX:
				return Conversion.convert('syrup', Measure.WV, Measure.BRIX, this.composition.info().sugar_content);
				break;
			case Measure.CW:
				let sugar = this.composition.component('sugar'),
					alcohol = this.composition.component('alcohol');
				let sugar_kcal = sugar ? sugar.get(Measure.CW) * sugar.get(Measure.G) * 0.001 / this.density : 0,
					alcohol_kcal = alcohol ? alcohol.get(Measure.CW) * alcohol.get(Measure.G) * 0.001 / this.density : 0;
				return 	sugar_kcal + alcohol_kcal;
				break;
		}
		return null;
	}

	#composeSugar(composition, data, KV) {
		let sugar = this.composition.info().sugar;
		if (sugar > 0) {
			// solution contains sugar
			if (data.syrup) {
				if (KV.syrup < KV.max_syrup) {
					// main ingredient ok
					composition.add(
						'syrup',
						new Component(data.syrup, KV.syrup * 1000, Measure.ML)
					);
					KV.max_alcohol = 1 - KV.syrup;
				} else {
					// mixing
					if (data.fallback.syrup) {
						let mild_k = Math.max(
							KV.min_syrup,
							KV.max_syrup * Conversion.correction
						);

						let goal_sugar = (0.001 * sugar * 1) / mild_k,
							wv1 = data.syrup.get(Measure.WV),
							wv2 = data.fallback.syrup.get(Measure.WV);

						let k_main =
								mild_k *
								Conversion.binary_search(
									(k) => Syrup.mix(k, wv1, wv2),
									goal_sugar,
									0,
									1,
									0.00000001,
									wv1 < wv2
								),
							k_fallback = mild_k - k_main;

						composition.add(
							'syrup',
							new Component(data.syrup, 1000 * k_main, Measure.ML)
						);

						composition.add(
							'fallback_syrup',
							new Component(
								data.fallback.syrup,
								1000 * k_fallback,
								Measure.ML
							)
						);
						KV.max_syrup = 1 - mild_k;
					} else throw new CalculationError('INSUFFICIENT_SUGAR');
				}
			} else throw new CalculationError('INSUFFICIENT_SUGAR');
		}
	}
	#composeAlcohol(composition, data, KV) {
		let goal_abv = this.composition.info().abv;
		if (goal_abv > 0) {
			// solution contains alcohol
			if (data.alcohol) {
				if (KV.alcohol < KV.max_alcohol) {
					// main ingredient ok
					composition.add(
						'alcohol',
						new Component(
							data.alcohol,
							KV.alcohol * 1000,
							Measure.ML
						)
					);
					KV.max_syrup = 1 - KV.alcohol;
				} else {
					// mixing
					if (data.fallback.alcohol) {
						let mild_k = Math.max(
							KV.min_alcohol,
							KV.max_alcohol * Conversion.correction
						);
						let abv = {
							main: data.alcohol.get(Measure.VV),
							fallback: data.fallback.alcohol.get(Measure.VV),
							target_mild: (goal_abv * 0.01) / mild_k,
						};
						let k_main =
								mild_k *
								Conversion.binary_search(
									(k) =>
										Alcohol.mix(k, abv.main, abv.fallback),
									abv.target_mild,
									0,
									1,
									0.0000001,
									true
								),
							k_fallback = mild_k - k_main;

						composition.add(
							'alcohol',
							new Component(
								data.alcohol,
								1000 * k_main,
								Measure.ML
							)
						);

						composition.add(
							'fallback_alcohol',
							new Component(
								data.fallback.alcohol,
								1000 * k_fallback,
								Measure.ML
							)
						);
						KV.max_syrup = 1 - mild_k;
					} else throw new CalculationError('INSUFFICIENT_ALCOHOL');
				}
			} else throw new CalculationError('INSUFFICIENT_ALCOHOL');
		}
	}
	/**
	 * Make a composition
	 *
	 * @param {MakeFrom} data
	 * @returns {Composition}
	 *
	 * @example
	 * let HomemadeCremeDeCassis = CremeDeCassis.make({
	 * 	alcohol: new Alcohol(0.4, Measure.WV)
	 * 	syrup: new Syrup(1, Measure.WV)
	 * })
	 */
	make(data) {
		let composition = new Composition();

		let KV = {
			alcohol: 0,
			fallback_alcohol: 0,
			min_alcohol: 0,
			max_alcohol: 0,
			syrup: 0,
			fallback_syrup: 0,
			min_syrup: 0,
			max_syrup: 0,
		};

		if (!data.alcohol && data.fallback.alcohol) {
			data.alcohol = data.fallback.alcohol;
			data.fallback.alcohol = null;
		}

		if (!data.syrup && data.fallback.syrup) {
			data.syrup = data.fallback.syrup;
			data.fallback.syrup = null;
		}

		let info = this.composition.info();

		if (info.abv > 0) {
			let target_abv = info.abv;

			if (data.alcohol) {
				KV.alcohol = target_abv / data.alcohol.get(Measure.ABV);
			}
			if (data.fallback?.alcohol) {
				KV.fallback_alcohol =
					target_abv / data.fallback.alcohol.get(Measure.ABV);
			}
			if (KV.alcohol > 0) {
				KV.min_alcohol = KV.alcohol;
				if (
					KV.fallback_alcohol > 0 &&
					KV.fallback_alcohol < KV.min_alcohol
				)
					KV.min_alcohol = KV.fallback_alcohol;
			} else if (KV.fallback_alcohol > 0) {
				KV.min_alcohol = KV.fallback_alcohol;
			}
		}

		if (info.sugar > 0) {
			let target_sugar = info.sugar * 0.001;

			if (data.syrup) {
				KV.syrup = target_sugar / data.syrup.get(Measure.WV);
			}
			if (data.fallback?.syrup) {
				KV.fallback_syrup =
					target_sugar / data.fallback.syrup.get(Measure.WV);
			}
			if (KV.syrup > 0) {
				KV.min_syrup = KV.syrup;
				if (KV.fallback_syrup > 0 && KV.fallback_syrup < KV.min_syrup)
					KV.min_syrup = KV.fallback_syrup;
			} else if (KV.fallback_syrup > 0) {
				KV.min_syrup = KV.fallback_syrup;
			}
		}

		if (KV.min_alcohol + KV.min_syrup > 1)
			throw new CalculationError('IMPOSSIBLE_COMBINATION');

		KV.max_alcohol = 1 - KV.min_syrup;
		KV.max_syrup = 1 - KV.min_alcohol;

		if (data.priority && data.priority == 'syrup') {
			this.#composeSugar(composition, data, KV);
			this.#composeAlcohol(composition, data, KV);
		} else {
			this.#composeAlcohol(composition, data, KV);
			this.#composeSugar(composition, data, KV);
		}

		let volume_left = 1000;
		volume_left -=
			(composition.component('alcohol')?.get(Measure.ML) || 0) +
			(composition.component('fallback_alcohol')?.get(Measure.ML) || 0) +
			(composition.component('syrup')?.get(Measure.ML) || 0) +
			(composition.component('fallback_syrup')?.get(Measure.ML) || 0);
		
		let buffer = data.buffer || new Water;
		composition.add(
			'buffer',
			new Component(
				buffer,
				volume_left,
				Measure.ML
			)
		);

		let reference = composition.info();
		let current = 0;
		switch (data.basis.source) {
			case 'alcohol':
                if (!composition.component('alcohol'))
                    throw new CalculationError('BASIS_ALCOHOL_WITHOUT_ALCOHOL');
				current = composition.component('alcohol').get(data.basis.measure);
				break;
			case 'syrup':
                if (!composition.component('syrup'))
                    throw new CalculationError('BASIS_SUGAR_WITHOUT_SUGAR');
				current = composition.component('syrup').get(data.basis.measure);
				break;
			default:
				current = composition.total(data.basis.measure);
				break;
		}
		let k = data.basis.value / current;
		
		composition.scale(k);

		return composition;
	}
}

/**
 * @typedef {Object} MakeFrom
 * @property {Alcohol} [alcohol] - main alcohol
 * @property {Syrup} [syrup] - main syrup/sugar
 * @property {('alcohol'|'syrup')} [priority='alcohol'] - priority in constructions
 * @property {Object} [basis] - calculation basis
 * @property {('alcohol'|'syrup'|'total')} basis.source
 * @property {number} basis.value - value
 * @property {MeasureVariant} basis.measure - Measure.*
 * @property {Object} [fallback] - fallback ingredients
 * @property {Alcohol} [fallback.alcohol] - fallback alcohol
 * @property {Syrup} [fallback.syrup] - fallback syrup/sugar
 */