// Validates a science draft and converts it to the wire-stable
// fraction form. Phase 21 mirrors the engine's
// `pkg/calc/validator.go.ValidateScienceValues` plus the
// `validateEntityName` rules and a UX-only duplicate-name check.
//
// The designer composes percentages (each value in `[0, 100]`, sum
// equal to `100` within float tolerance) so the user can type and
// reason about whole-number proportions; the validator converts the
// percentages to fractions (`value / 100`) on success so the
// `OrderCommand` payload always carries the canonical `[0, 1]`
// summing to `1.0` shape the FBS encoder ships on the wire.
//
// Engine rules (from `pkg/calc/validator.go` and `game/rules.txt`):
//
//   - drive, weapons, shields, cargo: float in `[0, 1]`;
//   - the four values sum to `1.0` (the engine accepts a small
//     tolerance to absorb float rounding);
//   - name must satisfy `validateEntityName`
//     (`pkg/util/string.go.ValidateTypeName`).
//
// The designer's UI gate is stricter than the engine's: the four
// percent inputs use `step="0.1"` and the validator refuses anything
// outside `Math.abs(sum - 100) < SUM_EPSILON_PERCENT`. Snapping to one
// decimal at input time makes the float drift small enough that the
// epsilon below comfortably covers normal arithmetic without ever
// admitting a draft the engine would reject.
//
// The duplicate-name check is a UX-only addition — the engine raises
// `EntityDuplicateIdentifierError` for a duplicate `Create` and the
// auto-sync pipeline would surface that as a `rejected` row, but
// catching it locally keeps the Save button disabled with a clear
// hint instead of a red badge after a wire round-trip.

import {
	validateEntityName,
	type EntityNameInvalidReason,
} from "./entity-name";

/**
 * SUM_EPSILON_PERCENT is the tolerance applied when checking that the
 * four science percentages sum to exactly `100`. With one-decimal
 * inputs (`step="0.1"`) the maximum cumulative float drift across four
 * additions is well under `1e-6`; `1e-3` keeps the check robust to
 * any float arithmetic the browser might do without ever accepting a
 * sum that rounds to a value the engine would refuse.
 */
export const SUM_EPSILON_PERCENT = 1e-3;

/**
 * ScienceInvalidReason enumerates every reason `validateScience` can
 * refuse a draft. Name-derived reasons are the same identifiers
 * `validateEntityName` returns, so the designer's `aria-describedby`
 * mapping reuses the existing translation keys for those branches and
 * adds new keys only for the value-derived ones.
 */
export type ScienceInvalidReason =
	| EntityNameInvalidReason
	| "duplicate_name"
	| "drive_value"
	| "weapons_value"
	| "shields_value"
	| "cargo_value"
	| "sum_not_hundred";

/**
 * ScienceDraft is the structural shape the designer composes. The
 * four numeric fields carry the player-typed percentages verbatim
 * (each in `[0, 100]`); `validateScience` is responsible for refusing
 * non-finite or out-of-range entries and the off-by-sum case.
 */
export interface ScienceDraft {
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

/**
 * ScienceValue is the canonical form a `createScience` order carries
 * on the wire: `drive`, `weapons`, `shields`, and `cargo` are
 * fractions in `[0, 1]` summing to `1.0`. Convert back to percentages
 * with `fractionsToPercent`.
 */
export interface ScienceValue {
	name: string;
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}

export type ScienceValidation =
	| { ok: true; value: ScienceValue }
	| { ok: false; reason: ScienceInvalidReason };

/**
 * validateScience runs the entity-name rules, the per-percent range
 * check, and the sum-equals-100 gate. `existingNames` is the
 * optimistic projection of already-defined sciences (from
 * `localScience` after `applyOrderOverlay`). When the trimmed name
 * matches any entry, the validator returns `duplicate_name` so the
 * designer's Save button stays disabled. Pass an empty array (or
 * omit the option) to skip the duplicate check — useful for unit
 * tests of the value-only rules, and for the order-draft store's
 * post-add status recompute (which sees rolling-up duplicates as
 * normal because the engine arbitrates them at submit time).
 */
export function validateScience(
	draft: ScienceDraft,
	options: { existingNames?: readonly string[] } = {},
): ScienceValidation {
	const nameResult = validateEntityName(draft.name);
	if (!nameResult.ok) {
		return { ok: false, reason: nameResult.reason };
	}
	const trimmedName = nameResult.value;

	if (!isValidPercent(draft.drive)) {
		return { ok: false, reason: "drive_value" };
	}
	if (!isValidPercent(draft.weapons)) {
		return { ok: false, reason: "weapons_value" };
	}
	if (!isValidPercent(draft.shields)) {
		return { ok: false, reason: "shields_value" };
	}
	if (!isValidPercent(draft.cargo)) {
		return { ok: false, reason: "cargo_value" };
	}

	const sum = draft.drive + draft.weapons + draft.shields + draft.cargo;
	if (Math.abs(sum - 100) >= SUM_EPSILON_PERCENT) {
		return { ok: false, reason: "sum_not_hundred" };
	}

	const existing = options.existingNames ?? [];
	if (existing.some((existingName) => existingName === trimmedName)) {
		return { ok: false, reason: "duplicate_name" };
	}

	return {
		ok: true,
		value: {
			name: trimmedName,
			drive: draft.drive / 100,
			weapons: draft.weapons / 100,
			shields: draft.shields / 100,
			cargo: draft.cargo / 100,
		},
	};
}

/**
 * fractionsToPercent inverts `validateScience`'s percent→fraction
 * conversion: it takes a wire-stable `[0, 1]` quartet and returns a
 * `[0, 100]` percentage quartet. The view-mode designer uses it to
 * render an existing science back as the same percent values the
 * player originally typed.
 */
export function fractionsToPercent(value: {
	drive: number;
	weapons: number;
	shields: number;
	cargo: number;
}): { drive: number; weapons: number; shields: number; cargo: number } {
	return {
		drive: value.drive * 100,
		weapons: value.weapons * 100,
		shields: value.shields * 100,
		cargo: value.cargo * 100,
	};
}

/**
 * isValidPercent guards a single percent input. Each value must be a
 * finite number in `[0, 100]`; NaN, infinity, and negative or
 * over-100 entries are rejected. The designer's `step="0.1"` input
 * keeps users on the one-decimal grid, but the validator does not
 * round here — sub-decimal precision is harmless because the
 * sum-equals-100 gate already absorbs any float drift the
 * `SUM_EPSILON_PERCENT` allows.
 */
function isValidPercent(value: number): boolean {
	if (!Number.isFinite(value)) return false;
	return value >= 0 && value <= 100;
}
