<!--
Phase 30 ship-class calculator. Replaces the Phase 17/18 standalone
designer: it fuses the ship design blocks, the live derived results
(mass, speed, attack, defence, bombing), and a planet build-rate readout
into one sidebar tool, and adds single-target goal-seek — the player pins
one result and the model back-solves the single input it claims (see
`lib/calculator/calc-model.ts`). A second mode reuses the design area to
price ship-class modernization. All math comes from `pkg/calc` through
the `Core` WASM bridge; this component only renders + orchestrates.

Input state lives in the long-lived `calculatorState` singleton, not in
the component, so it survives the sidebar unmounting this tab on a tab
switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
	import { getContext, tick } from "svelte";
	import { appScreen } from "$lib/app-nav.svelte";

	import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
	import {
		RENDERED_REPORT_CONTEXT_KEY,
		type RenderedReportSource,
	} from "$lib/rendered-report.svelte";
	import {
		ORDER_DRAFT_CONTEXT_KEY,
		OrderDraftStore,
	} from "../../sync/order-draft.svelte";
	import { CORE_CONTEXT_KEY, type CoreHandle } from "$lib/core-context.svelte";
	import {
		SELECTION_CONTEXT_KEY,
		type SelectionStore,
	} from "$lib/selection.svelte";
	import type { ReportPlanet, ShipClassSummary } from "../../api/game-state";
	import {
		validateShipClass,
		type ShipClassInvalidReason,
	} from "$lib/util/ship-class-validation";
	import {
		computeCalculator,
		computePlanetBuild,
		type LockableOutputId,
		type LoadMode,
	} from "$lib/calculator/calc-model";
	import ShipDesignArea, {
		type TechKey,
	} from "$lib/calculator/ship-design-area.svelte";
	import { reachStore } from "$lib/calculator/reach.svelte";
	import { calculatorLoadRequest } from "$lib/calculator/load-request.svelte";
	import { calculatorState } from "$lib/calculator/calc-state.svelte";

	const LOAD_MODES: LoadMode[] = ["empty", "full", "custom"];

	const rendered = getContext<RenderedReportSource | undefined>(
		RENDERED_REPORT_CONTEXT_KEY,
	);
	const draft = getContext<OrderDraftStore | undefined>(
		ORDER_DRAFT_CONTEXT_KEY,
	);
	const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
	const selection = getContext<SelectionStore | undefined>(
		SELECTION_CONTEXT_KEY,
	);

	// The long-lived input state (survives tab unmount/remount).
	const cs = calculatorState;
	// Reset the design when the active game changes; a no-op otherwise, so
	// the design persists across tab switches within a game.
	$effect(() => {
		cs.ensureGame(appScreen.gameId ?? "");
	});

	const core = $derived(coreHandle?.core ?? null);
	const report = $derived(rendered?.report ?? null);
	const localShipClass = $derived<ShipClassSummary[]>(
		report?.localShipClass ?? [],
	);
	const existingNames = $derived(localShipClass.map((c) => c.name));

	const playerTech = $derived({
		drive: report?.localPlayerDrive ?? 0,
		weapons: report?.localPlayerWeapons ?? 0,
		shields: report?.localPlayerShields ?? 0,
		cargo: report?.localPlayerCargo ?? 0,
	});

	const techKeys: TechKey[] = ["drive", "weapons", "shields", "cargo"];

	// Non-overridden tech levels track the player's current tech; the
	// effect resets them whenever the report (history snapshot included)
	// changes, so the calculator reflects the right turn's tech.
	$effect(() => {
		for (const k of techKeys) {
			if (!cs.techOverridden[k]) cs.techValues[k] = playerTech[k];
		}
	});
	// Seed the modernization target with the player's current tech once
	// the report has loaded; afterwards it is the player's to edit.
	$effect(() => {
		if (cs.targetSeeded) return;
		if (
			playerTech.drive ||
			playerTech.weapons ||
			playerTech.shields ||
			playerTech.cargo
		) {
			cs.targetTech = { ...playerTech };
			cs.targetSeeded = true;
		}
	});

	const result = $derived(
		computeCalculator(
			{
				blocks: cs.blocks,
				driveTech: cs.techValues.drive,
				weaponsTech: cs.techValues.weapons,
				shieldsTech: cs.techValues.shields,
				cargoTech: cs.techValues.cargo,
				loadMode: cs.loadMode,
				customLoad: cs.customLoad,
				lock: cs.lock === null ? null : { output: cs.lock, value: cs.lockValue },
			},
			core,
		),
	);

	// Selected own planet (MVP: own planets only).
	const selectedPlanet = $derived.by<ReportPlanet | null>(() => {
		const sel = selection?.selected;
		if (sel === undefined || sel === null || sel.kind !== "planet") return null;
		const planet = report?.planets.find((p) => p.number === sel.id) ?? null;
		if (planet === null || planet.kind !== "local") return null;
		return planet;
	});

	$effect(() => {
		if (!cs.matOverridden) {
			cs.matValue = selectedPlanet?.materialsStockpile ?? 0;
		}
	});

	// With no cargo block there is no hold to load: pin the load to empty
	// and disable the toggle.
	const cargoEmpty = $derived(cs.blocks.cargo === 0);
	$effect(() => {
		if (cargoEmpty && cs.loadMode !== "empty") cs.loadMode = "empty";
	});

	const planetBuild = $derived.by(() => {
		if (selectedPlanet === null) return null;
		const emptyMass = result.outputs?.emptyMass;
		if (emptyMass === undefined) return null;
		return computePlanetBuild(
			{
				shipMass: emptyMass,
				freeIndustry: selectedPlanet.freeIndustry ?? 0,
				material: cs.matValue,
				resources: selectedPlanet.resources ?? 0,
			},
			core,
		);
	});

	// Publish the selected planet's reach (loaded speed) so the map view
	// can draw 1–3 reach circles. Cleared when the design is invalid, no
	// own planet is selected, or the calculator is in modernization mode.
	$effect(() => {
		const out = result.outputs;
		if (cs.mode === "ship" && selectedPlanet !== null && out !== null) {
			reachStore.set(
				{ x: selectedPlanet.x, y: selectedPlanet.y },
				out.speedLoaded,
			);
		} else {
			reachStore.clear();
		}
		return () => reachStore.clear();
	});

	const nameInvalidKeyMap: Record<ShipClassInvalidReason, TranslationKey> = {
		empty: "game.calculator.invalid.empty",
		too_long: "game.calculator.invalid.too_long",
		starts_with_special: "game.calculator.invalid.starts_with_special",
		ends_with_special: "game.calculator.invalid.ends_with_special",
		consecutive_specials: "game.calculator.invalid.consecutive_specials",
		whitespace: "game.calculator.invalid.whitespace",
		disallowed_character: "game.calculator.invalid.disallowed_character",
		duplicate_name: "game.calculator.invalid.duplicate_name",
		drive_value: "game.calculator.invalid.drive_value",
		armament_value: "game.calculator.invalid.armament_value",
		armament_not_integer: "game.calculator.invalid.armament_not_integer",
		weapons_value: "game.calculator.invalid.weapons_value",
		shields_value: "game.calculator.invalid.shields_value",
		cargo_value: "game.calculator.invalid.cargo_value",
		armament_weapons_pair: "game.calculator.invalid.armament_weapons_pair",
		all_zero: "game.calculator.invalid.all_zero",
	};

	const nameValidation = $derived(
		validateShipClass({ name: cs.name, ...result.blocks }, { existingNames }),
	);
	const createMessage = $derived(
		nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
	);
	const canCreate = $derived(nameValidation.ok && draft !== undefined);

	// Per-block modernization upgrade cost (current tech → target tech).
	const modernCosts = $derived.by(() => {
		if (core === null) return null;
		const weaponsMass = core.weaponsBlockMass({
			weapons: cs.blocks.weapons,
			armament: cs.blocks.armament,
		});
		const rows: { key: TechKey; mass: number }[] = [
			{ key: "drive", mass: cs.blocks.drive },
			{ key: "weapons", mass: weaponsMass ?? 0 },
			{ key: "shields", mass: cs.blocks.shields },
			{ key: "cargo", mass: cs.blocks.cargo },
		];
		const perBlock = rows.map((r) => ({
			key: r.key,
			cost: core.blockUpgradeCost({
				blockMass: r.mass,
				currentTech: cs.techValues[r.key],
				targetTech: cs.targetTech[r.key],
			}),
		}));
		const total = perBlock.reduce((sum, r) => sum + r.cost, 0);
		return { perBlock, total };
	});

	// Display every computed number rounded up to three decimals via the
	// shared `Ceil3` bridge, so a value is never shown lower than it is.
	// Always three decimals (`1` → `1.000`) for column-aligned readability,
	// and without thousands grouping so the same string also embeds in the
	// read-only goal-seek `<input type="number">` cell.
	function fmt(value: number | null | undefined): string {
		if (value === null || value === undefined) {
			return i18n.t("game.calculator.unavailable");
		}
		const rounded = core !== null ? core.ceil3({ value }) : value;
		return rounded.toLocaleString(undefined, {
			minimumFractionDigits: 3,
			maximumFractionDigits: 3,
			useGrouping: false,
		});
	}

	// Cap typed precision at three decimal digits. Number inputs use
	// `step="any"`, which lets the browser accept arbitrary precision; the
	// owner asked us to refuse a fourth decimal as typing happens so the
	// calculator never displays a longer-than-three-digit fraction. Pairs
	// with `bind:value`: if Svelte's bind handler has already read the
	// over-precise number, `apply` overwrites the state with the truncated
	// value so the next reactive flush does not undo our truncation.
	function capDecimals(event: Event, apply: (next: number) => void): void {
		const el = event.currentTarget as HTMLInputElement;
		const txt = el.value;
		const dot = txt.indexOf(".");
		if (dot < 0 || txt.length - dot - 1 <= 3) return;
		el.value = txt.slice(0, dot + 4);
		apply(el.valueAsNumber);
	}

	// The goal-seek back-solved block, shown in its read-only cell, is
	// ceiled the same way (only the claimed block's cell is displayed).
	const resolvedCeil = $derived.by(() => {
		if (core === null) return result.blocks;
		const c = (v: number) => core.ceil3({ value: v });
		return {
			drive: c(result.blocks.drive),
			armament: result.blocks.armament,
			weapons: c(result.blocks.weapons),
			shields: c(result.blocks.shields),
			cargo: c(result.blocks.cargo),
		};
	});

	// A custom load must stay within [0, cargo capacity]; beyond that the
	// ship cannot hold it.
	const customLoadError = $derived.by(() => {
		if (cs.loadMode !== "custom") return "";
		if (cs.customLoad < 0) return i18n.t("game.calculator.invalid.negative");
		if (cs.customLoad > result.cargoCapacity) {
			return i18n.t("game.calculator.invalid.load_over_capacity");
		}
		return "";
	});
	const matError = $derived(
		cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
	);

	// Modernization target tech mirrors the design-area floor: a target
	// below the player's current tech on this turn is meaningless (no
	// upgrade), so flag it the same way.
	function targetTechError(key: TechKey): string {
		const value = cs.targetTech[key];
		if (value < 0) return i18n.t("game.calculator.invalid.negative");
		if (value < playerTech[key]) {
			return i18n.t("game.calculator.invalid.tech_below_current");
		}
		return "";
	}

	// Locking a speed back-solves the drive block; with a zero drive the
	// ship is deliberately immobile, so disallow it.
	function lockDisabledReason(output: LockableOutputId): string {
		if (
			(output === "speedEmpty" || output === "speedLoaded") &&
			cs.blocks.drive === 0
		) {
			return i18n.t("game.calculator.lock.no_drive");
		}
		return "";
	}

	function onTechInput(key: TechKey): void {
		cs.techOverridden[key] = true;
	}
	function onResetTech(key: TechKey): void {
		cs.techOverridden[key] = false;
	}
	const matInputRef: { el?: HTMLInputElement } = {};
	async function activateMatOverride(): Promise<void> {
		cs.matOverridden = true;
		await tick();
		matInputRef.el?.focus();
		matInputRef.el?.select();
	}
	function resetMat(): void {
		cs.matOverridden = false;
	}

	function lockOutput(output: LockableOutputId): void {
		if (cs.lock !== null) return;
		cs.lockValue = result.outputs?.[output] ?? 0;
		cs.lock = output;
	}
	function unlock(): void {
		cs.lock = null;
	}

	// Generic ±step keyboard handler for the calculator's free-form
	// number inputs (MAT, custom-load, lock value, modernization
	// target tech). Pairs with `class="no-spin"` so the native spinner
	// is hidden everywhere and the column width is stable; ArrowUp /
	// ArrowDown is the only step affordance. The smart 0↔1 jump on
	// the ship-class blocks lives in `ship-design-area.svelte` —
	// these other inputs accept any non-negative number.
	function onStepKey(
		event: KeyboardEvent,
		current: number,
		step: number,
		min: number,
		apply: (next: number) => void,
	): void {
		const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
		if (dir === 0) return;
		event.preventDefault();
		// Snap to the same fractional grid as `step` so 0.001 stays
		// at three decimals instead of drifting via float math.
		const inv = 1 / step;
		const next = Math.round((current + dir * step) * inv) / inv;
		apply(next < min ? min : next);
	}

	function loadExisting(clsName: string): void {
		const cls = localShipClass.find((c) => c.name === clsName);
		if (cls === undefined) return;
		cs.blocks = {
			drive: cls.drive,
			armament: cls.armament,
			weapons: cls.weapons,
			shields: cls.shields,
			cargo: cls.cargo,
		};
		cs.name = cls.name;
		cs.loadedExisting = cls.name;
		cs.lock = null;
	}

	// Compare the live blocks to the baseline they were last loaded
	// from — or to the empty defaults if no class has been loaded. The
	// dropdown selection flow uses this to ask before discarding manual
	// edits. Tech overrides are independent of class loading, so they
	// don't count as "dirty" here.
	function baselineBlocks(): {
		drive: number;
		armament: number;
		weapons: number;
		shields: number;
		cargo: number;
	} {
		if (cs.loadedExisting !== null) {
			const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
			if (cls !== undefined) {
				return {
					drive: cls.drive,
					armament: cls.armament,
					weapons: cls.weapons,
					shields: cls.shields,
					cargo: cls.cargo,
				};
			}
		}
		return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
	}
	function isDesignDirty(): boolean {
		const base = baselineBlocks();
		return (
			cs.blocks.drive !== base.drive ||
			cs.blocks.armament !== base.armament ||
			cs.blocks.weapons !== base.weapons ||
			cs.blocks.shields !== base.shields ||
			cs.blocks.cargo !== base.cargo
		);
	}
	function tryLoadByName(name: string): void {
		const cls = localShipClass.find((c) => c.name === name);
		if (cls === undefined) return;
		if (cs.loadedExisting === cls.name) return;
		if (isDesignDirty()) {
			const ok = window.confirm(
				i18n.t("game.calculator.confirm_reset_for_load", {
					name: cls.name,
				}),
			);
			if (!ok) {
				cs.name = cs.loadedExisting ?? "";
				return;
			}
		}
		loadExisting(name);
	}
	// Catch the datalist option click immediately. Native `change` only
	// fires on blur in Firefox, which is what made dropdown selection
	// look delayed; `input` fires the moment the value is set. Typed
	// keystrokes carry an `inputType` ("insertText", "deleteContent…");
	// a datalist selection replaces the value in one shot, so its
	// `inputType` is undefined (Firefox) or "insertReplacementText"
	// (Chromium / WebKit). We treat that as a selection.
	function onNameInput(event: Event): void {
		const ev = event as InputEvent;
		const isSelection =
			ev.inputType === undefined ||
			ev.inputType === "insertReplacementText";
		if (!isSelection) {
			cs.loadedExisting = null;
			return;
		}
		tryLoadByName(cs.name);
	}

	// React to the ship-classes table / bottom-tabs asking to load a
	// class (or start a fresh design) into the calculator. The layout
	// flips the sidebar to this tab in parallel.
	$effect(() => {
		const token = calculatorLoadRequest.token;
		if (token === cs.handledLoadToken) return;
		cs.handledLoadToken = token;
		cs.mode = "ship";
		if (calculatorLoadRequest.name === null) cs.resetDesign();
		else loadExisting(calculatorLoadRequest.name);
	});

	async function create(): Promise<void> {
		if (!nameValidation.ok || draft === undefined) return;
		// Capture the validated draft before awaiting: adding the command
		// re-projects `localShipClass`, which re-runs the `nameValidation`
		// derived into a `duplicate_name` failure (the class now exists),
		// leaving `nameValidation.value` undefined after the await.
		const created = nameValidation.value;
		await draft.add({
			kind: "createShipClass",
			id: crypto.randomUUID(),
			name: created.name,
			drive: created.drive,
			armament: created.armament,
			weapons: created.weapons,
			shields: created.shields,
			cargo: created.cargo,
		});
		cs.loadedExisting = created.name;
	}

	const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
		emptyMass: i18n.t("game.calculator.out.mass"),
		loadedMass: i18n.t("game.calculator.out.mass"),
		speedEmpty: i18n.t("game.calculator.out.speed"),
		speedLoaded: i18n.t("game.calculator.out.speed"),
		attack: i18n.t("game.calculator.out.attack"),
		defense: i18n.t("game.calculator.out.defense"),
	});
</script>

{#snippet lockable(output: LockableOutputId, value: number | undefined)}
	{#if cs.lock === output}
		<span class="cell locked" class:infeasible={!result.lockFeasible}>
			<input
				class="no-spin"
				type="number"
				step="any"
				bind:value={cs.lockValue}
				onkeydown={(e) =>
					onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
				oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
				data-testid={`calculator-locked-${output}`}
				title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
			/>
			<button
				type="button"
				class="lock active"
				title={i18n.t("game.calculator.lock.reset")}
				aria-label={i18n.t("game.calculator.lock.reset")}
				data-testid={`calculator-unlock-${output}`}
				onclick={unlock}
			>
				🔒
			</button>
		</span>
	{:else}
		{@const extra = lockDisabledReason(output)}
		<span class="cell">
			<span class="val" data-testid={`calculator-out-${output}`}>{fmt(value)}</span>
			<button
				type="button"
				class="lock"
				disabled={cs.lock !== null || value === undefined || extra !== ""}
				title={cs.lock !== null
					? i18n.t("game.calculator.lock.max")
					: extra !== ""
						? extra
						: LOCK_LABELS[output]}
				aria-label={LOCK_LABELS[output]}
				data-testid={`calculator-lock-${output}`}
				onclick={() => lockOutput(output)}
			>
				🔓
			</button>
		</span>
	{/if}
{/snippet}

<section class="calculator" data-testid="sidebar-tool-calculator">
	<div class="modes" role="tablist">
		<button
			type="button"
			class:active={cs.mode === "ship"}
			data-testid="calculator-mode-ship"
			onclick={() => (cs.mode = "ship")}
		>
			{i18n.t("game.calculator.mode.ship")}
		</button>
		<button
			type="button"
			class:active={cs.mode === "modernization"}
			data-testid="calculator-mode-modernization"
			onclick={() => (cs.mode = "modernization")}
		>
			{i18n.t("game.calculator.mode.modernization")}
		</button>
	</div>

	<div class="namebar">
		<input
			type="text"
			class="name"
			list="calculator-existing-classes"
			placeholder={i18n.t("game.calculator.name.placeholder")}
			maxlength="30"
			bind:value={cs.name}
			oninput={onNameInput}
			onchange={() => tryLoadByName(cs.name)}
			aria-invalid={nameValidation.ok ? "false" : "true"}
			data-testid="calculator-name"
		/>
		<datalist id="calculator-existing-classes">
			{#each localShipClass as cls (cls.name)}
				<option value={cls.name}></option>
			{/each}
		</datalist>
		{#if cs.mode === "ship"}
			<button
				type="button"
				class="create"
				disabled={!canCreate}
				title={canCreate ? "" : createMessage}
				data-testid="calculator-create"
				onclick={() => void create()}
			>
				{i18n.t("game.calculator.action.create")}
			</button>
		{/if}
	</div>

	<ShipDesignArea
		bind:blocks={cs.blocks}
		resolved={resolvedCeil}
		bind:techs={cs.techValues}
		techOverridden={cs.techOverridden}
		techFloor={playerTech}
		computedInput={result.computedInput}
		formatNumber={fmt}
		{onTechInput}
		{onResetTech}
	/>

	{#if cs.mode === "ship"}
		<div class="load">
			<span class="label">{i18n.t("game.calculator.load.label")}</span>
			<div class="seg" role="group">
				{#each LOAD_MODES as m (m)}
					<button
						type="button"
						class:active={cs.loadMode === m}
						disabled={cargoEmpty}
						data-testid={`calculator-load-${m}`}
						onclick={() => (cs.loadMode = m)}
					>
						{i18n.t(`game.calculator.load.${m}` as TranslationKey)}
					</button>
				{/each}
			</div>
			{#if cs.loadMode === "custom"}
				<input
					type="number"
					step="any"
					min="0"
					class="custom-load no-spin"
					bind:value={cs.customLoad}
					onkeydown={(e) =>
						onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
					oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
					aria-invalid={customLoadError !== "" ? "true" : "false"}
					title={customLoadError}
					data-testid="calculator-custom-load"
				/>
			{:else if cs.loadMode === "full"}
				<span
					class="full-capacity"
					title={i18n.t("game.calculator.out.cargo_capacity")}
					data-testid="calculator-full-capacity"
				>
					{fmt(result.cargoCapacity)}
				</span>
			{/if}
		</div>

		<div class="results" data-testid="calculator-results">
			<div class="rrow head">
				<span></span>
				<span class="col-head">{i18n.t("game.calculator.col.empty")}</span>
				<span class="col-head">{i18n.t("game.calculator.col.loaded")}</span>
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.mass")}</span>
				{@render lockable("emptyMass", result.outputs?.emptyMass)}
				{@render lockable("loadedMass", result.outputs?.loadedMass)}
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.speed")}</span>
				{@render lockable("speedEmpty", result.outputs?.speedEmpty)}
				{@render lockable("speedLoaded", result.outputs?.speedLoaded)}
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.attack")}</span>
				{@render lockable("attack", result.outputs?.attack)}
				<span></span>
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.defense")}</span>
				{@render lockable("defense", result.outputs?.defense)}
				<span></span>
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.bombing")}</span>
				<span class="cell">
					<span class="val" data-testid="calculator-out-bombing">
						{fmt(result.outputs?.bombing)}
					</span>
					<span class="lock-slot" aria-hidden="true">🔓</span>
				</span>
				<span></span>
			</div>
			<div class="rrow">
				<span class="label">{i18n.t("game.calculator.out.cargo_capacity")}</span>
				<span class="cell">
					<span class="val" data-testid="calculator-out-cargo-capacity">
						{fmt(result.outputs === null ? null : result.cargoCapacity)}
					</span>
					<span class="lock-slot" aria-hidden="true">🔓</span>
				</span>
				<span></span>
			</div>
		</div>

		<div class="planet" data-testid="calculator-planet-area">
			{#if selectedPlanet === null}
				<p class="hint" data-testid="calculator-planet-none">
					{i18n.t("game.calculator.planet.none")}
				</p>
			{:else}
				<p class="planet-name" data-testid="calculator-planet-name">
					{i18n.t("game.calculator.planet.label", {
						name: selectedPlanet.name,
						number: String(selectedPlanet.number),
					})}
				</p>
				<div class="rrow">
					<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
					<span class="cell">
						{#if cs.matOverridden}
							<input
								bind:this={matInputRef.el}
								class="no-spin"
								type="number"
								step="any"
								min="0"
								bind:value={cs.matValue}
								onkeydown={(e) =>
									onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
								oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
								aria-invalid={matError !== "" ? "true" : "false"}
								title={matError}
								data-testid="calculator-planet-mat"
							/>
							<button
								type="button"
								class="lock active"
								title={i18n.t("game.calculator.mat.reset")}
								aria-label={i18n.t("game.calculator.mat.reset")}
								data-testid="calculator-mat-reset"
								onclick={resetMat}
							>
								🔒
							</button>
						{:else}
							<span
								class="mat-val"
								data-testid="calculator-planet-mat-value"
							>
								{fmt(cs.matValue)}
							</span>
							<button
								type="button"
								class="lock"
								title={i18n.t("game.calculator.mat.override")}
								aria-label={i18n.t("game.calculator.mat.override")}
								data-testid="calculator-mat-override"
								onclick={() => void activateMatOverride()}
							>
								🔓
							</button>
						{/if}
					</span>
					<span></span>
				</div>
				<dl class="planet-stats">
					<div>
						<dt>{i18n.t("game.calculator.planet.ships_per_turn")}</dt>
						<dd data-testid="calculator-ships-per-turn">
							{fmt(planetBuild?.shipsPerTurn)}
						</dd>
					</div>
					<div>
						<dt>{i18n.t("game.calculator.planet.turns_per_ship")}</dt>
						<dd data-testid="calculator-turns-per-ship">
							{fmt(planetBuild?.turnsPerShip ?? null)}
						</dd>
					</div>
				</dl>
			{/if}
		</div>
	{:else}
		<div class="modern" data-testid="calculator-modernization">
			<div class="rrow head">
				<span></span>
				<span class="col-head">{i18n.t("game.calculator.modern.target")}</span>
				<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
			</div>
			{#each modernCosts?.perBlock ?? [] as row (row.key)}
				{@const targetError = targetTechError(row.key)}
				<div class="rrow">
					<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
					<span class="cell">
						<input
							class="no-spin"
							type="number"
							step="any"
							min={playerTech[row.key]}
							bind:value={cs.targetTech[row.key]}
							onkeydown={(e) =>
								onStepKey(
									e,
									cs.targetTech[row.key],
									0.001,
									playerTech[row.key],
									(v) => (cs.targetTech[row.key] = v),
								)}
							oninput={(e) =>
								capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
							aria-invalid={targetError !== "" ? "true" : "false"}
							title={targetError}
							data-testid={`calculator-target-${row.key}`}
						/>
					</span>
					<span class="cell">
						<span class="val" data-testid={`calculator-modern-cost-${row.key}`}>
							{fmt(row.cost)}
						</span>
					</span>
				</div>
			{/each}
			<div class="rrow total">
				<span class="label">{i18n.t("game.calculator.modern.total")}</span>
				<span class="cell">
					<span class="val" data-testid="calculator-modern-total">
						{fmt(modernCosts?.total)}
					</span>
				</span>
			</div>
		</div>
	{/if}
</section>

<style>
	.calculator {
		display: flex;
		flex-direction: column;
		gap: 0.6rem;
		padding: 0.75rem;
		font-family: system-ui, sans-serif;
	}
	.modes {
		display: flex;
		gap: 0.25rem;
	}
	.modes button {
		flex: 1;
		font: inherit;
		font-size: 0.8rem;
		padding: 0.25rem;
		background: transparent;
		color: var(--color-text-muted);
		border: 1px solid var(--color-border);
		border-radius: 3px;
		cursor: pointer;
	}
	.modes button.active {
		color: var(--color-text);
		border-color: var(--color-accent);
		background: var(--color-surface-raised);
	}
	.namebar {
		display: flex;
		gap: 0.35rem;
	}
	.name {
		flex: 1;
		min-width: 0;
		font: inherit;
		font-size: 0.85rem;
		padding: 0.25rem 0.4rem;
		background: var(--color-bg);
		color: var(--color-text);
		border: 1px solid var(--color-border);
		border-radius: 3px;
	}
	.name[aria-invalid="true"] {
		border-color: var(--color-danger);
	}
	.create {
		font: inherit;
		font-size: 0.8rem;
		padding: 0.25rem 0.55rem;
		background: transparent;
		color: var(--color-text-muted);
		border: 1px solid var(--color-border);
		border-radius: 3px;
		cursor: pointer;
	}
	.create:not(:disabled):hover {
		color: var(--color-text);
		border-color: var(--color-accent);
	}
	.create:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}
	.load {
		display: flex;
		align-items: center;
		gap: 0.4rem;
	}
	.seg {
		display: flex;
		gap: 0.15rem;
	}
	.seg button {
		font: inherit;
		font-size: 0.75rem;
		padding: 0.15rem 0.4rem;
		background: transparent;
		color: var(--color-text-muted);
		border: 1px solid var(--color-border);
		border-radius: 3px;
		cursor: pointer;
	}
	.seg button.active {
		color: var(--color-text);
		border-color: var(--color-accent);
	}
	.custom-load {
		width: 4rem;
		font-family: var(--font-mono);
		font-size: 0.8rem;
		font-variant-numeric: tabular-nums;
		padding: 0.15rem 0.3rem;
		background: var(--color-bg);
		color: var(--color-text);
		border: 1px solid var(--color-border);
		border-radius: 3px;
		text-align: right;
	}
	.results,
	.modern {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}
	.rrow {
		display: grid;
		grid-template-columns: 4.5rem 1fr 1fr;
		align-items: center;
		gap: 0.35rem;
	}
	.col-head {
		color: var(--color-text-muted);
		font-size: 0.7rem;
		text-align: center;
	}
	.label {
		color: var(--color-text-muted);
		font-size: 0.8rem;
	}
	.cell {
		display: flex;
		align-items: center;
		gap: 0.2rem;
		justify-content: flex-end;
	}
	.cell .val {
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
		font-size: 0.85rem;
		text-align: right;
	}
	.cell input {
		width: 100%;
		min-width: 0;
		font-family: var(--font-mono);
		font-size: 0.8rem;
		padding: 0.15rem 0.3rem;
		background: var(--color-bg);
		color: var(--color-text);
		border: 1px solid var(--color-border);
		border-radius: 3px;
		font-variant-numeric: tabular-nums;
		text-align: right;
	}
	/* Hide the native spinner on every calculator number input — the
	   row drives every numeric edit through ArrowUp / ArrowDown so the
	   column width is stable and the inputs read consistently with the
	   ship-block row inside the design area. */
	input.no-spin::-webkit-inner-spin-button,
	input.no-spin::-webkit-outer-spin-button {
		-webkit-appearance: none;
		margin: 0;
	}
	input.no-spin {
		-moz-appearance: textfield;
		appearance: textfield;
	}
	.cell.locked input {
		color: var(--color-accent);
		border-color: var(--color-accent);
	}
	.cell.infeasible input {
		border-color: var(--color-danger);
		color: var(--color-danger);
	}
	.lock {
		flex: none;
		padding: 0;
		font-size: 0.7rem;
		line-height: 1;
		background: transparent;
		border: 0;
		cursor: pointer;
		opacity: 0.5;
	}
	.lock.active,
	.lock:not(:disabled):hover {
		opacity: 1;
	}
	.lock:disabled {
		cursor: not-allowed;
		opacity: 0.2;
	}
	.lock-slot {
		flex: none;
		font-size: 0.7rem;
		line-height: 1;
		visibility: hidden;
	}
	.planet {
		border-top: 1px solid var(--color-border-subtle);
		padding-top: 0.5rem;
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
	}
	.hint {
		margin: 0;
		color: var(--color-text-muted);
		font-size: 0.8rem;
	}
	.planet-name {
		margin: 0;
		font-size: 0.8rem;
		color: var(--color-text);
	}
	.planet-stats {
		margin: 0;
		display: grid;
		grid-template-columns: 1fr max-content;
		row-gap: 0.2rem;
		column-gap: 0.5rem;
	}
	.planet-stats div {
		display: contents;
	}
	.planet-stats dt {
		color: var(--color-text-muted);
		font-size: 0.8rem;
	}
	.planet-stats dd {
		margin: 0;
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
		font-size: 0.85rem;
		text-align: right;
	}
	.rrow.total .label {
		grid-column: 1 / 3;
		color: var(--color-text);
		white-space: nowrap;
	}
	input[aria-invalid="true"] {
		border-color: var(--color-danger);
	}
	.seg button:disabled {
		opacity: 0.4;
		cursor: not-allowed;
	}
	.full-capacity {
		font-family: var(--font-mono);
		font-variant-numeric: tabular-nums;
		font-size: 0.8rem;
		color: var(--color-accent);
	}
	/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
	   design area) so the cell width stays the same whether the value is
	   the inherited planet number or the player's override. */
	.mat-val {
		flex: 1;
		min-width: 0;
		font-family: var(--font-mono);
		font-size: 0.85rem;
		font-variant-numeric: tabular-nums;
		text-align: right;
		padding: 0.15rem 0.3rem;
	}
</style>
