/* ============================================================
   VKTR TCO — Core data, helpers, and TCO engine  v1.2
   ============================================================
   Load order: vehicles.jsx must load before this file.
   VEHICLES and EV_INFRA are defined in vehicles.jsx.
   ============================================================ */

// ---------- Schema version (bump when DEFAULT_STATE shape changes) ----------
// v1.8.0 (2026-07-17): removed routeId/tripsPerDay/fleetPlanType/shifts/
// fixedChargingWindow/unitsPerMiniShift; added ritaseDistanceKm/
// ritaseTimeOverride/proposedCycleCount/proposedGroupCount/
// chargeSessionMinutes/swapSessionMinutes/chargingDowntimeMinutes
// (Ritase-Cycle Charging Strategy rework, CALCULATION_ENGINE.md §10).
// v1.8.2 (2026-07-18): chargingType/chargerRatingKw/voltageLevel defaults
// changed from concrete values ("dc"/120/"lv") to null (= auto-computed
// from the Charging Requirement Engine; non-null = Expert-Mode override).
const SCHEMA_VERSION = "1.32";
Object.assign(window, { SCHEMA_VERSION });

// ---------- Format helpers ----------
window.fmt = {
  // Indonesian convention: "jt" = juta (million), "M" = miliar (billion).
  // English convention: "M" = million, "B" = billion — note "M" means a
  // DIFFERENT magnitude in each language, so this must read the live UI
  // language rather than hardcoding one abbreviation set. Reads it from
  // VKTRStore (i18n.jsx) rather than taking a parameter, since rpShort is
  // called from ~40 sites that don't otherwise have lang in scope.
  rpShort(n) {
    if (n == null) return "—";
    const lang = (window.VKTRStore && window.VKTRStore.loadLang()) || "id";
    const locale = lang === "en" ? "en-US" : "id-ID";
    const billionUnit = lang === "en" ? "B" : "M";
    const millionUnit = lang === "en" ? "M" : "jt";
    const abs = Math.abs(n);
    const sign = n < 0 ? "-" : "";
    if (abs >= 1e9) return sign + "Rp " + (abs / 1e9).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " " + billionUnit;
    if (abs >= 1e6) return sign + "Rp " + Math.round(abs / 1e6).toLocaleString(locale) + " " + millionUnit;
    return sign + "Rp " + abs.toLocaleString(locale);
  },
  // v1.7.5 fix: both were hardcoded to "id-ID" regardless of active
  // language, unlike rpShort above -- Indonesian formatting uses "." as
  // the thousands separator and "," as the decimal separator, the OPPOSITE
  // of English. An English-mode user reading "8.461" (Indonesian for 8461)
  // naturally misreads it as 8.461 (a decimal point), off by ~1000x. Fixed
  // to read the active language the same way rpShort already did.
  rp(n) {
    const lang = (window.VKTRStore && window.VKTRStore.loadLang()) || "id";
    return "Rp " + Math.round(n).toLocaleString(lang === "en" ? "en-US" : "id-ID");
  },
  num(n) {
    if (n == null) return "—";
    const lang = (window.VKTRStore && window.VKTRStore.loadLang()) || "id";
    return n.toLocaleString(lang === "en" ? "en-US" : "id-ID");
  },
};

// ---------- Industry options (legacy, retained for migration) ----------
window.INDUSTRIES = ["Transportation", "Logistics", "Mining", "Government", "Tourism", "Other"];

// ---------- v1.3 Ecosystem & Project Type options ----------
window.ECOSYSTEM_OPTIONS = [
  { id: "logistics",        label: "Logistics",         labelId: "Logistik",          icon: "🏭" },
  { id: "mining",           label: "Mining",            labelId: "Pertambangan",      icon: "⛏️" },
  { id: "public_transport", label: "Public Transport",  labelId: "Transportasi Umum", icon: "🚌" },
  { id: "industrial",       label: "Industrial Fleet",  labelId: "Industri",          icon: "🏗️" },
  { id: "others",           label: "Others",            labelId: "Lainnya",           icon: "⚙️" },
];

window.TERRAINS = ["Flat", "Rolling", "Hilly", "Mixed"];

// ---------- Ritase Time (RT) — segment x terrain average speed ----------
// v1.8 ritase-cycle rework, §10.6 CALCULATION_ENGINE.md. Engineering
// estimate, NOT measured fleet telemetry -- anchored to a sourced global
// range (heavy-truck flat-highway regional average ~60 km/h; loaded heavy
// truck on hilly/mountain terrain ~32-48 km/h) then interpolated by
// segment power-to-weight, same one-anchor-plus-interpolation method as
// TERRAIN_MULTIPLIER above. Per-segment, not per-SKU -- no published or
// telemetry source exists at 174-vehicle granularity. BUS rows are split
// by the same GVW threshold used for Depot Design bay classification
// (depot_floorplan/js/constants.js, §10.9) since city-duty and
// intercity/mining-shuttle buses do not share a realistic average speed.
// Flagged for validation against real VKTR fleet data when available.
window.TERRAIN_AVG_SPEED_KMH = {
  Pickup:  { Flat: 55, Rolling: 48, Mixed: 45, Hilly: 40 },
  VAN:     { Flat: 55, Rolling: 48, Mixed: 45, Hilly: 40 },
  LDT:     { Flat: 52, Rolling: 45, Mixed: 42, Hilly: 37 },
  MDT:     { Flat: 48, Rolling: 40, Mixed: 38, Hilly: 33 },
  HDT:     { Flat: 50, Rolling: 42, Mixed: 40, Hilly: 35 },
  TH:      { Flat: 45, Rolling: 38, Mixed: 36, Hilly: 32 },
  // BUS: split by GVW at the same 8,000/14,000 kg thresholds as the Depot
  // Design bay categories (bus8/bus12/bus18) -- resolved per-vehicle in
  // window.avgSpeedForVeh below, not a single flat BUS row.
  BUS_8:   { Flat: 40, Rolling: 30, Mixed: 26, Hilly: 22 },  // city/midibus duty -- frequent stops
  BUS_12:  { Flat: 48, Rolling: 40, Mixed: 37, Hilly: 32 },  // scheduled-route/shuttle duty
  BUS_18:  { Flat: 45, Rolling: 37, Mixed: 34, Hilly: 30 },  // articulated, lower cornering/hill-climb speed than rigid 12m
};

// Resolves a vehicle's avgSpeedKmh row, handling the BUS GVW split.
window.avgSpeedForVeh = function(veh, terrain) {
  if (!veh) return null;
  const t = terrain || "Flat";
  let key = veh.segment;
  if (key === "BUS") {
    const gvw = veh.gvw || 0;
    key = gvw < 8000 ? "BUS_8" : (gvw <= 14000 ? "BUS_12" : "BUS_18");
  }
  const row = window.TERRAIN_AVG_SPEED_KMH[key];
  return row ? (row[t] ?? row.Flat) : null;
};

// ---------- Helpers ----------
// V1.7: single lookup point for the vehicle catalog -- also the single merge
// point for a signed-in user's personal vehicle-spec overrides
// (vehicle_library.jsx keeps window.__customVehicles in sync from Firestore,
// or from local-only state for a guest). window.VEHICLES itself is never
// mutated -- the static catalog stays exactly what the Excel file produced.
window.findVeh = (id) => {
  const custom = window.__customVehicles && window.__customVehicles[id];
  if (custom && custom.isNew) {
    return { id, source: "Custom vehicle (this account)", gvwEst: false, placeholder: false, pmEstimate: false, ...custom };
  }
  const base = window.VEHICLES.find(v => v.id === id);
  if (custom && custom.isOverride && base) {
    return { ...base, ...custom.overrides, id: base.id, _customized: true };
  }
  return base;
};

// ---------- v1.3 EC payload derivation ----------
// EC_empty and EC_full derived from a vehicle's single EC value.
// Flagged as assumptions — see infra_profiles.js EC_PAYLOAD_FACTORS
window.deriveEC = function (vehicleEc, veh, usePhysics) {
  const { emptyFactor, fullFactor } = window.resolvePayloadFactors(veh, usePhysics);
  return {
    ec_empty: vehicleEc * emptyFactor,
    ec_full:  vehicleEc * fullFactor,
  };
};

window.calcEcActual = function (vehicleEc, payloadPct, veh, usePhysics) {
  const { ec_empty, ec_full } = window.deriveEC(vehicleEc, veh, usePhysics);
  return ec_empty + (ec_full - ec_empty) * (payloadPct / 100);
};

// ---------- v1.7.7 Physics-based Payload/Efficiency Refinement (opt-in) ----------
// See infra_profiles.js §3.2a for the full formula/sourcing writeup. Falls
// back to the flat EC_PAYLOAD_FACTORS (0.85/1.15) whenever physics isn't
// requested, or a vehicle lacks the real catalog data (curbWeight/payload)
// needed to derive a physical mass-based ratio.
window.derivePhysicsPayloadFactors = function (veh) {
  const flat = window.INFRA_PROFILES.EC_PAYLOAD_FACTORS;
  const FALLBACK = { emptyFactor: flat.emptyFactor.value, fullFactor: flat.fullFactor.value, source: "flat" };
  if (!veh || veh.curbWeight == null || veh.payload == null) return FALLBACK;

  const IP = window.INFRA_PROFILES;
  const cdA = (IP.CDA_BY_SEGMENT[veh.segment] || IP.CDA_DEFAULT).value;
  const crr = IP.PHYSICS_REF_CRR.value;
  const speedMs = IP.PHYSICS_REF_SPEED_KMH.value / 3.6;
  const faero = 0.5 * IP.AIR_DENSITY_KG_M3.value * cdA * speedMs * speedMs; // N, constant regardless of payload

  const ftotalEmpty = crr * veh.curbWeight * IP.GRAVITY_M_S2 + faero;
  const ftotalFull  = crr * (veh.curbWeight + veh.payload) * IP.GRAVITY_M_S2 + faero;
  if (ftotalEmpty <= 0) return FALLBACK;

  // Preserve the existing calibration anchor: vehicle.energyNum is a
  // 50%-laden figure regardless of which factor source is active, i.e.
  // (emptyFactor + fullFactor) / 2 must stay exactly 1.0.
  const ratio = ftotalFull / ftotalEmpty; // >= 1
  const emptyFactor = 2 / (1 + ratio);
  const fullFactor = ratio * emptyFactor;
  return { emptyFactor, fullFactor, source: "physics" };
};

// Single resolution point used by both deriveEC (cost/CO2 side) and
// computeSizing's own EC actual (§3.1, infra sizing side), so both stay on
// the same energy assumption regardless of which is toggled on -- same
// consistency goal as the ecNum/ecNumLfLmr split elsewhere in this file.
window.resolvePayloadFactors = function (veh, usePhysics) {
  if (usePhysics) return window.derivePhysicsPayloadFactors(veh);
  const flat = window.INFRA_PROFILES.EC_PAYLOAD_FACTORS;
  return { emptyFactor: flat.emptyFactor.value, fullFactor: flat.fullFactor.value, source: "flat" };
};

// ---------- Default state v1.3 ----------
window.DEFAULT_STATE = {
  // Screen 1 — Customer Profile
  company: "PT. Transportasi Nusantara",
  contact: "Budi Santoso",
  ecosystemId: "public_transport",   // was free-text industry; "Transportation" migrates here
  costModelFlat: false,              // true = ignore ecosystem multipliers/unit costs, use flat "others" baseline for all infra CAPEX/OPEX & financial-preset math
  approxFleetSize: null,
  projectStartDate: null,            // ISO string, month/year
  city: "Jakarta",
  notes: "",

  // Screen 2 — Vehicle Selection
  // VKTR/EV defaults to B (right) so the comparison reads incumbent (A) vs proposed VKTR EV (B).
  vehA: "P048",
  vehB: "P133",
  priceA: 1160000000,
  priceB: 2850000000,
  maintOverrideA: null,
  maintOverrideB: null,
  maintOverrideBasisA: "year",
  maintOverrideBasisB: "year",
  maintOverrideCycleA: null,
  maintOverrideCycleB: null,
  tyreTierA: null,           // null = template default price; "cheap" | "expensive" = tier override
  tyreTierB: null,
  tyreTierPriceOverrides: {},  // { cheap: 3500000, expensive: 6800000 } — editable tier Rp values
  energyOverrideA: null,     // null = catalog default. Same units as veh.energyNum: L/100km (ICE) or kWh/km (EV) — NOT km/L or km/kWh. Real-world consumption can differ from the catalog spec by route/load/scenario.
  energyOverrideB: null,
  maintGroupOverrides: {},  // { A: { tyre: 1500000, brake: ... }, B: {...} } -- per-vehicle, per-group annual Rp override; null/absent = use the parts-template sum
  nozzlesPerVehicleOverrideA: null,  // null = catalog default (window.EV_NOZZLE_COUNT[vehA.id] ?? 1); a number = Expert-Mode override of simultaneous DC nozzles required
  nozzlesPerVehicleOverrideB: null,
  payloadBuildA: false,      // v1.7.6 -- Lead Time module (informational only, does not affect computeTCO()). Only meaningful when vehA is a toggle-eligible cabin-chassis SKU (window.isPayloadBuildToggleable), default off.
  payloadBuildB: false,

  // Screen 3 — Operation
  // v1.8 (ritase-cycle rework, §10.10 CALCULATION_ENGINE.md): annualKm is
  // now DERIVED whenever an EV vehicle is present in the comparison --
  // computeRitaseCycle()'s deriveAnnualKm() overwrites it at read time
  // (RD x per-vehicle daily ritase x operating days/yr). It stays a raw
  // editable input only for pure ICE-vs-ICE comparisons (no VR/battery
  // range to derive a ritase count from) -- see getEvVehicle() null-guard.
  annualKm: 50000,
  fleetSize: 10,
  horizon: 5,
  // RD default is 0 (not "not entered yet"), deliberately -- computeRitaseCycle
  // treats RD<=0 as "not resolvable" and falls back to the raw annualKm/
  // dailyMileageKm inputs untouched (§10.10). A non-zero default here would
  // silently activate ritase-derivation for every scenario/preset/test
  // fixture that predates this rework and never opted into it -- confirmed
  // as a real regression via runExcelValidation() during the v1.8 build
  // (all 7 cases' energy figures came out ~80% off before this was caught).
  ritaseDistanceKm: 0,        // RD -- one round-trip depot->site->depot distance, km. Replaces routeId (v1.8).
  ritaseTimeOverride: null,   // RT manual override, minutes; null = auto-computed from TERRAIN_AVG_SPEED_KMH (segment x terrain, see §10.6)
  terrainManual: "Flat",
  trackProfile: {            // generalized track-profile input — overrides terrainManual/RD when enabled
    enabled: false,
    distanceKm: null,
    elevGainM: null,
    elevLossM: null,
    netElevDeltaM: null,
    oscillationCreditRate: 0.4,  // EV regen-credit rate, editable, source:"research" (ScienceDirect regen-recovery range)
  },

  // Screen 4 — v1.3 state
  screen4_activeTab: 0,
  // Depot Design integration (v1.5) — populated live via postMessage from the
  // embedded depot_floorplan iframe (Tab6DepotDesign in screens.jsx) whenever
  // a plan/Studio design is marked "Select for TCO". null until then, in
  // which case infraForVeh() (below) falls back to the Sizing Engine.
  depotBom: null,
  depotMetrics: null,
  depotBomInclude: null,
  // v1.21 — Infrastructure now has 5 visual tabs (Fleet & Charging merged,
  // Growth & Assets, Sizing Engine, Depot Design [no expert toggle], CAPEX
  // Breakdown), so only 4 distinct expert-mode flags are needed:
  // tab1 = Fleet & Charging (merged), tab2 = Growth & Assets,
  // tab3 = Sizing Engine, tab4 = CAPEX Breakdown.
  // NOTE: these flags are independent of the "tabN." prefixes used inside
  // screen4_valueOverrides (those are content namespaces, e.g. "tab2." /
  // "tab4." / "tab5." below, left unchanged from their pre-merge values to
  // avoid silently dropping any already-saved user overrides).
  screen4_expertMode_tab1: false,
  screen4_expertMode_tab2: false,
  screen4_expertMode_tab3: false,
  screen4_expertMode_tab4: false,
  screen4_activePresetId: null,
  screen4_valueOverrides: {},        // { "tab4.redundancyFactor": 1.3, ... } — content-namespace keys, NOT visual tab index

  // Tab 1 — Fleet Profile
  // v1.8 (2026-07-17) -- ritase-cycle rework, §10 CALCULATION_ENGINE.md.
  // Replaces the old fleetPlanType 3-way split (shift/fixed/opportunity)
  // and its shifts[]/fixedChargingWindow/unitsPerMiniShift inputs with one
  // unified cycle+group model: the user proposes a cycle count and a
  // group count, computeRitaseCycle() validates both against ritase
  // physics and falls back to the max-feasible value if infeasible (see
  // computeRitaseCycle, data.jsx).
  proposedCycleCount: 3,       // PZ_CC -- proposed charging cycles/day
  proposedGroupCount: 4,       // PZ_TG -- proposed scheduled charging groups/cycle
  chargeSessionMinutes: 90,    // SS -- plug-charge session length, dropdown: 40/50/60/70/80/90, default 90
  swapSessionMinutes: 10,      // SS for swap-infra vehicles (EV_INFRA[id]==="swap"/"both") -- separate short-duration control, §10.5
  chargingDowntimeMinutes: 0,  // CD -- depot-closed window, min/day (0 = open 24h)
  payloadPct: 50,
  useLfLmrRefinement: false,  // expert-only, opt-in — when true, payloadPct used for the energy-payload curve is derived as loadFactorPct x loadedMileRatioPct instead of being typed directly. Off by default: zero effect on any already-validated comparison.
  usePhysicsPayloadFactors: false,  // v1.7.7, expert-only, opt-in — when true, the empty/full EC_PAYLOAD_FACTORS spread (flat +-15%) is replaced per-vehicle by a tractive-effort physics derivation (see infra_profiles.js §3.2a / data.jsx derivePhysicsPayloadFactors). Off by default: zero effect on any already-validated comparison.
  loadFactorPct: 75,          // % of rated payload carried when loaded — international research default (Flock Freight/ICCT-style studies). Checked 2026-06-30 for an Indonesia-specific load-factor figure; none found publicly — international default retained, flagged as assumption pending real fleet data.
  loadedMileRatioPct: 65,     // % of total distance driven loaded vs. empty-return. RECALIBRATED 2026-06-30 from the international default (70%) using Indonesia-specific data: World Bank, "Improving Indonesia's Freight Logistics System: A Plan of Action" (2018) — Indonesian backhauls run >=70% empty BY VOLUME due to the eastbound/westbound trade imbalance, worse than the US-sourced Flock Freight figure the international default was based on. 65% is a derived approximation (avg of a fully-loaded fronthaul + a ~30%-loaded backhaul leg), not a directly-measured Indonesian LMR statistic — still an approximation, but grounded in Indonesia-specific research rather than a different country's freight market.

  // Tab 2 — Charging Strategy ("ac" | "dc" | "dcfast")
  // v1.8.2: all 3 auto-computed from the Charging Strategy card's SS input
  // (Charging Requirement Engine, data.jsx) -- null = use the computed
  // default; non-null = Expert-Mode override, same nullable-override
  // pattern as ecEmptyOverride/chargerRatioOverride elsewhere.
  chargingType: null,
  chargerRatingKw: null,
  siteAvailableKva: null,
  voltageLevel: null,

  // Tab 3 — Growth
  plannedFleet5yr: null,

  // Tab 1 — Fleet Profile (additional)
  dailyMileageKm: 100,
  operatingDaysPerYear: 300,
  chargingEfficiencyOverride: null,   // default 0.92 (expert)
  ecEmptyOverride: null,
  ecFullOverride: null,

  // Tab 2 — Charging Strategy (expert)
  powerFactorOverride: null,          // default 0.95 (expert)
  demandChargeEnabled: false,         // future feature — disabled in UI

  // Tab 4 — Sizing Engine overrides (expert mode)
  chargerCountOverride: null,
  transformerKvaOverride: null,

  // Tab 2 — Demand Charge Modeling (expert, estimated)
  demandChargeEnabled: false,
  demandChargeProfileId: "mixed",

  // Screen 2 — Maintenance Parts Breakdown (expert, estimated)
  screen2_activeTab: 0,

  // Screen 3 — Operation (v1.5: tabbed, audit tab added)
  screen3_activeTab: 0,

  // Screen 5 — Financials
  paymentA: "cash",          // v1.6.1: split from shared `payment` — independent per vehicle. v1.7.7: "lease" removed -- cash|loan only (the old Commercial Scheme Ladder + lease payment method were replaced by the Customer/VKTR expense-bucket model, see computeExpenseBuckets)
  paymentB: "cash",
  interest: 4.5,
  screen5_activeTab: 0,
  downPayment: 20,
  tenor: 5,
  diesel: 30000,             // Rp/L — market rate
  electricity: 1000,         // Rp/kWh — Tarif I-4 (≥30,000 kVA commercial)
  adblue: 12500,             // Rp/L
  adblueDose: 4,             // % of diesel volume
  wacc: 12,                  // % p.a. — Indonesian corporate cost of capital (mid of 10-14% sensitivity range)
  carbon: 60000,             // Rp/ton CO2 — IDXCarbon domestic avg (research default), follows FINANCIAL_PRESETS.carbonCreditPricePerTon
  inflation: 5,              // % per year, default 5% compound
  // v1.7.8: driverSalaryMonthly/driversPerVehicle removed — labor cost is
  // out of scope for this platform (VKTR team decision; see report.jsx's
  // Assumptions & Limitations note). The only labor line the platform
  // still models is depot security staffing, inside the Depot Design tool
  // (depot_floorplan/js/bom.js `securityStaffFlatYear`, gated by that
  // tool's own "other" BOM category toggle) — a facility cost, not a
  // vehicle-operator cost, and out of scope for this change.
  insuranceRatePct: null,    // % of unit price per year; null = 0 (opt-in — naturally differs between A/B since each uses its own price)
  batteryDegradationPctPerYear: 2.5, // EV-only; transparency metric, assumption pending Indonesian fleet data
  modelBatteryReplacement: false,    // opt-in — off by default, doesn't change any already-validated comparison
  batteryPackCostPct: 35,             // assumption: battery pack as % of EV purchase price
  includeResidualInTco: false, // off by default — matches VKTR's own TCOO convention (cash cost only, no resale credit netted into Total TCO/Savings). Residual is still shown as its own row and still nets into the Monthly Cost/Unit depreciation KPI.
  loanInterestMethod: "flat", // "flat" | "amortizing" — flat (default) preserves every already-validated TCOO comparison; amortizing uses a standard declining-balance annuity (PMT), which charges less total interest for the same nominal rate
  residualValueMethod: "soh", // "soh" | "schedule" — "soh" (default) ties EV residual value to the same battery-degradation rate already configured above (SOH-linked, battery-weighted); "schedule" is the prior fixed lookup-curve behavior, kept for comparison/rollback
  batteryWeightPctOfPrice: 35,        // % of EV purchase price attributable to the battery pack — commonly-cited commercial-EV range is 30-40%; used by the SOH-linked residual value formula
  nonBatteryDeprPctPerYear: 8,        // declining-balance rate for the non-battery (chassis/body/electrical) portion of an EV's value

  // v1.7.7: Customer/VKTR expense-bucket model (replaces the old Commercial
  // Scheme Ladder + lease payment method entirely) -- see computeExpenseBuckets.
  expenseBucketState: { UNIT: "customer", FMC: "customer", INFRA: "customer", WARRANTY: "customer", ENERGY: "vktr", INSURANCE: "vktr" },
  expenseBucketMarkupPct: { UNIT: 0, FMC: 0, INFRA: 0, WARRANTY: 0, ENERGY: 0, INSURANCE: 0 },
  subscriptionTermYears: 5, // v1.7.8 -- VKTR-borne buckets' cost-recovery term; subscriptionAnnual = vktrBorneLoaded / this, then renews at the same rate for the rest of the horizon (pure margin beyond the term). Expected <= horizon.
  unscheduledRepairReservePctOfOtr: 1.5, // %/yr of OTR -- WARRANTY bucket placeholder, pending real VKTR service/warranty-claims data (see CALCULATION_ENGINE.md). Editable on Screen 5, Warranty Reserve card.
  avgOperatingSpeedKmh: 25,       // km/h -- new scalar for Rp/hour bucket rates, default matches an urban stop-start route; pending real fleet data like loadFactorPct/loadedMileRatioPct

  // v1.9.12: UNIT (Capital) bucket -- off by default so it shows the fresh
  // buying price (+ financing) only, matching Vehicle Selection's Buying
  // Price at a glance. Netting an estimated resale/residual value is now an
  // explicit opt-in "relief", not baked unconditionally into the bucket
  // (previously was -- reported confusing since the two numbers looked like
  // they should match but silently didn't). resaleValueOverride{A,B} let the
  // user substitute their own estimate for the auto-computed residual value
  // (window.residualFraction-derived) once relief is enabled; null means
  // "use the auto-computed figure".
  applyResaleReliefInUnit: false,
  resaleValueOverrideA: null,
  resaleValueOverrideB: null,

  // Screen 5 — v1.3 budget & financial expert
  infraBudgetCap: null,
  totalProjectBudgetCap: null,
  screen5_expertMode: false,
  screen5_valueOverrides: {},

  // v1.8 — embodied (manufacturing) CO2, opt-in, off by default so no
  // existing comparison changes unless explicitly enabled. EV-only — see
  // window.CO2 comment block for why ICE embodied carbon is left unmodeled.
  includeEmbodiedCo2: false,
  evBatteryMfgCo2PerKwh: 74,   // kgCO2/kWh, NMC-typical cradle-to-gate median — pending Indonesia-specific battery supply chain data

  yearlyOverrides: {},   // "category.vehKey.year" -> value (task #30/#32)
};

// DEFAULT_STATE's nested objects/arrays (shifts, trackProfile, sensitivity
// ranges, etc.) are shared by reference across every loadState()/set() call
// that falls back to a default. Freezing makes any accidental in-place
// mutation (e.g. `s.shifts.push(...)` instead of spreading) throw immediately
// instead of silently corrupting the default for the rest of the session.
(function deepFreeze(obj) {
  Object.values(obj).forEach(v => { if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v); });
  return Object.freeze(obj);
})(window.DEFAULT_STATE);

// v1.6.1 — payment method split from a shared `payment` field into
// `paymentA`/`paymentB`. A profile/preset exported before this change (or a
// hand-authored one) may still carry the old field; without this shim,
// `{...DEFAULT_STATE, ...rawState}` would silently drop the user's cash/loan
// choice back to the "cash" default instead of carrying it forward to both
// vehicles (the pre-v1.6.1 behavior every existing export/preset assumed).
window.migratePaymentField = function (rawState) {
  if (!rawState || typeof rawState.payment !== "string") return rawState;
  const migrated = { ...rawState };
  if (migrated.paymentA === undefined) migrated.paymentA = rawState.payment;
  if (migrated.paymentB === undefined) migrated.paymentB = rawState.payment;
  return migrated;
};

/* ============================================================
   v1.5 — Per-screen reset + consolidated change-log (task #33)
   ============================================================ */

// Which DEFAULT_STATE keys belong to each screen's "Reset to Default" button.
window.SCREEN_RESET_KEYS = {
  screen1: ["company", "contact", "ecosystemId", "costModelFlat", "approxFleetSize", "projectStartDate", "city", "notes"],
  screen2: ["vehA", "vehB", "priceA", "priceB", "maintOverrideA", "maintOverrideB", "maintOverrideBasisA", "maintOverrideBasisB",
    "maintOverrideCycleA", "maintOverrideCycleB", "tyreTierA", "tyreTierB", "tyreTierPriceOverrides", "energyOverrideA", "energyOverrideB",
    "maintGroupOverrides", "screen2_activeTab", "payloadBuildA", "payloadBuildB"],
  screen3: ["annualKm", "ritaseDistanceKm", "ritaseTimeOverride", "terrainManual", "trackProfile", "operatingDaysPerYear",
    "diesel", "electricity", "adblue", "adblueDose", "demandChargeEnabled", "demandChargeProfileId", "screen3_activeTab"],
  screen4: ["fleetSize", "screen4_activeTab", "screen4_expertMode_tab1", "screen4_expertMode_tab2", "screen4_expertMode_tab3",
    "screen4_expertMode_tab4", "screen4_activePresetId", "screen4_valueOverrides",
    "proposedCycleCount", "proposedGroupCount", "chargeSessionMinutes", "swapSessionMinutes", "chargingDowntimeMinutes", "payloadPct", "useLfLmrRefinement", "usePhysicsPayloadFactors", "loadFactorPct", "loadedMileRatioPct", "chargingType", "chargerRatingKw", "nozzlesPerVehicleOverrideA", "nozzlesPerVehicleOverrideB", "siteAvailableKva", "voltageLevel",
    "plannedFleet5yr", "dailyMileageKm", "chargingEfficiencyOverride",
    "ecEmptyOverride", "ecFullOverride", "powerFactorOverride", "chargerCountOverride", "transformerKvaOverride",
    "depotBom", "depotMetrics", "depotBomInclude"],
  screen5: ["paymentA", "paymentB", "interest", "downPayment", "tenor", "horizon", "wacc", "carbon", "inflation",
    "insuranceRatePct",
    "batteryDegradationPctPerYear", "modelBatteryReplacement", "batteryPackCostPct",
    "includeResidualInTco", "loanInterestMethod", "residualValueMethod", "batteryWeightPctOfPrice", "nonBatteryDeprPctPerYear",
    "expenseBucketState", "expenseBucketMarkupPct", "subscriptionTermYears", "unscheduledRepairReservePctOfOtr", "avgOperatingSpeedKmh",
    "applyResaleReliefInUnit", "resaleValueOverrideA", "resaleValueOverrideB",
    "infraBudgetCap", "totalProjectBudgetCap", "screen5_expertMode", "screen5_valueOverrides", "screen5_activeTab",
    "includeEmbodiedCo2", "evBatteryMfgCo2PerKwh"],
};

// yearlyOverrides categories (task #32) attributed to each screen, so a
// screen reset also clears that screen's per-year cell overrides.
window.SCREEN_YEARLY_CATEGORIES = {
  screen2: ["maintenance"],
  screen3: ["energy", "adblue"],
  screen4: ["infrastructure"],
  screen5: ["financing"],
};

function cloneDefault(val) {
  return (val && typeof val === "object") ? JSON.parse(JSON.stringify(val)) : val;
}

window.resetScreenState = function(s, set, screenKey) {
  (window.SCREEN_RESET_KEYS[screenKey] || []).forEach(k => set(k, cloneDefault(window.DEFAULT_STATE[k])));
  const cats = window.SCREEN_YEARLY_CATEGORIES[screenKey];
  if (cats && s.yearlyOverrides) {
    const next = {};
    Object.entries(s.yearlyOverrides).forEach(([k, v]) => {
      if (!cats.includes(k.split(".")[0])) next[k] = v;
    });
    set("yearlyOverrides", next);
  }
};

window.resetAllState = function(set) {
  Object.keys(window.DEFAULT_STATE).forEach(k => set(k, cloneDefault(window.DEFAULT_STATE[k])));
};

// Human-readable labels for the change-log overlay — falls back to a
// camelCase->Title Case split for any key not explicitly listed here.
window.CHANGE_LOG_LABELS = {
  ecosystemId: ["Industry", "Industri"], costModelFlat: ["Flat Cost Model", "Model Biaya Rata"],
  vehA: ["Vehicle A", "Kendaraan A"], vehB: ["Vehicle B", "Kendaraan B"],
  priceA: ["Vehicle A Price", "Harga Kendaraan A"], priceB: ["Vehicle B Price", "Harga Kendaraan B"],
  maintOverrideA: ["Vehicle A Maintenance Override", "Override Perawatan Kendaraan A"],
  maintOverrideB: ["Vehicle B Maintenance Override", "Override Perawatan Kendaraan B"],
  tyreTierA: ["Vehicle A Tyre Tier", "Tier Ban Kendaraan A"], tyreTierB: ["Vehicle B Tyre Tier", "Tier Ban Kendaraan B"],
  tyreTierPriceOverrides: ["Tyre Tier Prices", "Harga Tier Ban"],
  maintGroupOverrides: ["Maintenance Group Overrides", "Override Kelompok Perawatan"],
  energyOverrideA: ["Vehicle A Energy Consumption Override", "Override Konsumsi Energi Kendaraan A"],
  energyOverrideB: ["Vehicle B Energy Consumption Override", "Override Konsumsi Energi Kendaraan B"],
  annualKm: ["Annual Mileage", "Jarak Tahunan"], terrainManual: ["Terrain", "Medan"],
  ritaseDistanceKm: ["Ritase Distance", "Jarak Ritase"], ritaseTimeOverride: ["Ritase Time Override", "Override Waktu Ritase"],
  trackProfile: ["Track Profile", "Profil Trek"], diesel: ["Diesel Price", "Harga Diesel"], electricity: ["Electricity Tariff", "Tarif Listrik"],
  adblue: ["AdBlue Price", "Harga AdBlue"], demandChargeEnabled: ["WBP Surcharge Enabled", "Tambahan WBP Aktif"],
  fleetSize: ["Fleet Size", "Jumlah Armada"],
  proposedCycleCount: ["Proposed Cycle Count", "Jumlah Siklus Diusulkan"], proposedGroupCount: ["Proposed Group Count", "Jumlah Grup Diusulkan"],
  chargeSessionMinutes: ["Scheduled Charging Shift", "Shift Pengisian Terjadwal"], swapSessionMinutes: ["Swap Session Duration", "Durasi Sesi Swap"],
  chargingDowntimeMinutes: ["Charging Downtime", "Waktu Henti Pengisian"],
  payloadPct: ["Payload Utilization", "Utilisasi Muatan"], chargingType: ["Charging Type", "Tipe Pengisian"],
  useLfLmrRefinement: ["Refine via Load Factor x Loaded-Mile Ratio", "Sempurnakan via Load Factor x Loaded-Mile Ratio"],
  usePhysicsPayloadFactors: ["Physics-based Payload/Efficiency Factors", "Faktor Muatan/Efisiensi Berbasis Fisika"],
  loadFactorPct: ["Load Factor", "Load Factor"], loadedMileRatioPct: ["Loaded-Mile Ratio", "Loaded-Mile Ratio"],
  chargerRatingKw: ["Charger Rating", "Daya Charger"],
  nozzlesPerVehicleOverrideA: ["Vehicle A Nozzles per Vehicle Override", "Override Nosel per Kendaraan A"],
  nozzlesPerVehicleOverrideB: ["Vehicle B Nozzles per Vehicle Override", "Override Nosel per Kendaraan B"],
  payloadBuildA: ["Vehicle A Payload Build", "Payload Build Kendaraan A"],
  payloadBuildB: ["Vehicle B Payload Build", "Payload Build Kendaraan B"],
  siteAvailableKva: ["Site Available Power", "Daya Lokasi Tersedia"],
  voltageLevel: ["Voltage Level", "Tingkat Tegangan"], dailyMileageKm: ["Daily Mileage", "Jarak Harian"],
  screen4_valueOverrides: ["Infrastructure Expert Overrides", "Override Ahli Infrastruktur"],
  chargerCountOverride: ["Charger Count Override", "Override Jumlah Charger"],
  transformerKvaOverride: ["Transformer Size Override", "Override Ukuran Trafo"],
  depotBom: ["Depot Design Result", "Hasil Desain Depot"],
  paymentA: ["Payment Method — Vehicle A", "Metode Pembayaran — Kendaraan A"],
  paymentB: ["Payment Method — Vehicle B", "Metode Pembayaran — Kendaraan B"],
  interest: ["Interest Rate", "Suku Bunga"],
  downPayment: ["Down Payment", "Uang Muka"], tenor: ["Loan Tenor", "Tenor Kredit"], horizon: ["Analysis Horizon", "Horizon Analisis"],
  wacc: ["WACC", "WACC"], carbon: ["Carbon Price", "Harga Karbon"], inflation: ["Inflation Rate", "Tingkat Inflasi"],
  insuranceRatePct: ["Insurance Rate", "Tarif Asuransi"],
  batteryDegradationPctPerYear: ["Battery Degradation Rate", "Tingkat Degradasi Baterai"],
  modelBatteryReplacement: ["Model Battery Replacement Cost", "Modelkan Biaya Penggantian Baterai"],
  batteryPackCostPct: ["Battery Pack Cost %", "% Biaya Paket Baterai"],
  includeResidualInTco: ["Net Residual Value into Total TCO", "Kurangkan Nilai Sisa dari Total TCO"],
  loanInterestMethod: ["Loan Interest Method", "Metode Bunga Kredit"],
  residualValueMethod: ["Residual Value Method", "Metode Nilai Sisa"],
  batteryWeightPctOfPrice: ["Battery Weight % of Price", "% Bobot Baterai dari Harga"],
  nonBatteryDeprPctPerYear: ["Non-Battery Depreciation Rate", "Tingkat Depresiasi Non-Baterai"],
  includeEmbodiedCo2: ["Include Embodied Manufacturing CO2", "Sertakan CO2 Manufaktur Tertanam"],
  evBatteryMfgCo2PerKwh: ["EV Battery Manufacturing CO2 Factor", "Faktor CO2 Manufaktur Baterai EV"],
  expenseBucketState: ["Expense Bucket Toggle", "Toggle Kelompok Biaya"],
  expenseBucketMarkupPct: ["Expense Bucket Markup %", "% Markup Kelompok Biaya"],
  subscriptionTermYears: ["Subscription Term", "Jangka Waktu Sewa"],
  unscheduledRepairReservePctOfOtr: ["Unscheduled Repair Reserve % of OTR", "% Cadangan Perbaikan Tak Terjadwal dari OTR"],
  applyResaleReliefInUnit: ["Net Resale Value into Unit (Capital)", "Kurangkan Nilai Jual Kembali dari Unit (Modal)"],
  resaleValueOverrideA: ["Estimated Resale Value — Vehicle A", "Estimasi Nilai Jual Kembali — Kendaraan A"],
  resaleValueOverrideB: ["Estimated Resale Value — Vehicle B", "Estimasi Nilai Jual Kembali — Kendaraan B"],
  avgOperatingSpeedKmh: ["Average Operating Speed", "Kecepatan Operasional Rata-rata"],
  infraBudgetCap: ["Infrastructure Budget Cap", "Batas Anggaran Infrastruktur"],
  screen5_valueOverrides: ["Financial Expert Overrides", "Override Ahli Keuangan"],
};

function titleCaseKey(key) {
  return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase());
}

function valuesDiffer(a, b) {
  if (a === b) return false;
  if (a && b && typeof a === "object" && typeof b === "object") return JSON.stringify(a) !== JSON.stringify(b);
  return true;
}

// Returns every field across all 5 screens (plus yearlyOverrides cells) that
// currently differs from DEFAULT_STATE — the data backing the consolidated
// change-log overlay.
window.computeChangeLog = function(s) {
  const entries = [];
  const screenLabels = {
    screen1: ["Customer Profile", "Profil Pelanggan"], screen2: ["Vehicle Selection", "Pemilihan Kendaraan"],
    screen3: ["Operation", "Skenario Operasional"], screen4: ["Infrastructure", "Infrastruktur"], screen5: ["Financials", "Asumsi Keuangan"],
  };
  Object.entries(window.SCREEN_RESET_KEYS).forEach(([screenKey, keys]) => {
    keys.forEach(key => {
      const def = window.DEFAULT_STATE[key];
      const cur = s[key];
      if (!valuesDiffer(def, cur)) return;
      const lbl = window.CHANGE_LOG_LABELS[key] || [titleCaseKey(key), titleCaseKey(key)];
      entries.push({ key, screenKey, screenLabel: screenLabels[screenKey], fieldLabel: lbl, defaultValue: def, currentValue: cur });
    });
  });
  Object.entries(s.yearlyOverrides || {}).forEach(([ovKey, val]) => {
    const [cat, vehKey, year] = ovKey.split(".");
    const catLabels = {
      energy: ["Energy Cost", "Biaya Energi"], adblue: ["AdBlue Cost", "Biaya AdBlue"],
      maintenance: ["Maintenance Cost", "Biaya Perawatan"], infrastructure: ["Infra OPEX", "OPEX Infrastruktur"],
      financing: ["Financing Cost", "Biaya Pembiayaan"],
      insurance: ["Insurance Cost", "Biaya Asuransi"],
    };
    const screenByCat = { energy: "screen3", adblue: "screen3", maintenance: "screen2", infrastructure: "screen4", financing: "screen5", insurance: "screen5" };
    const screenKey = screenByCat[cat] || "screen5";
    entries.push({
      key: `yearlyOverrides.${ovKey}`, screenKey, screenLabel: screenLabels[screenKey],
      fieldLabel: [`${(catLabels[cat] || [cat, cat])[0]} — Vehicle ${vehKey}, Yr ${year}`, `${(catLabels[cat] || [cat, cat])[1]} — Kendaraan ${vehKey}, Thn ${year}`],
      defaultValue: null, currentValue: val, isYearly: true, yearlyKey: ovKey,
    });
  });
  return entries;
};

window.revertChangeLogEntry = function(s, set, entry) {
  if (entry.isYearly) {
    const next = { ...(s.yearlyOverrides || {}) };
    delete next[entry.yearlyKey];
    set("yearlyOverrides", next);
  } else {
    set(entry.key, cloneDefault(entry.defaultValue));
  }
};

/* ============================================================
   PM_SCHEDULE — Diponegoro TCOO V4 (Data_PM sheet)
   IDR per unit per year at 30,000 km/yr base.
   Arrays of up to 10 years; maintCostForYear() caps at last entry.
   Keys remapped to v1.5 catalogue P-ids (P + V6 row number).
   Vehicles without a direct V6 row match (old VK18/VK19 and the
   competitor-bus rows CP048/049/107/CP_HRM/CP_MBZ etc.) fall back
   to PM_SEGMENT_DEFAULT below.
   ============================================================ */
window.PM_SCHEDULE = {
  // ── LDT (ICE confirmed) ──
  "P011":      [2778798,  4692238,  4305040,  5173192,  3377646,  5703444,  6237875,  6288048,  4105550,  6932572],
  "P109":      [3245100,  7898749,  5701887,  8708371,  3944439,  9600979,  6930679, 10585080,  4794491, 11670050],

  // ── EV LDT (VKTR TLD080F / TLD082FM) ──
  "P131":      [3079000,  7159425,  5312396,  7893266,  3742544,  8702326,  6457251,  9594314,  4549085, 10577731],
  "P132":      [3079000,  7159425,  5312396,  7893266,  3742544,  8702326,  6457251,  9594314,  4549085, 10577731],

  // ── EV BUS 8m (VKTR BHF080) ──
  "P133":      [5205300,  7748947,  4791245,  9237789,  5069633,  9418893,  8637988, 13620661,  6162171, 11448723],

  // ── EV VAN (VKTR PTR035A Transporter, GVW 4.45T) — EV LDT × (4.45/10)^0.5 ──
  "P134":      [3200000,  3800000,  3270000,  4070000,  3800000],

  // ── EV BUS 12m (BYD D9) ──
  "P135":      [4200000,  7748947,  4791245,  9237789,  5069633,  9418893,  8637988, 13620661,  6162171, 11448723],

  // ── EV HDT 8×4 (VKTR THD310S, Swap / Charge) ──
  "P124":      [2670000,  6945750,  4812413,  7657689,  3245402,  8442603,  5849517,  9307969,  3944806, 10262036],
  "P125":      [2670000,  6945750,  4812413,  7657689,  3245402,  8442603,  5849517,  9307969,  3944806, 10262036],

  // ── EV TH (Sinotruk V7X TH 6×4, GCW 120T) — EV HDT × (53/24)^0.5 ──
  "P126":      [6109000,  7748000,  6705000,  9089000,  8046000],

  // ── EV HDT 6×4 (VKTR THD261S) ──
  "P127":      [2670000,  6945750,  4812413,  7657689,  3245402,  8442603,  5849517,  9307969,  3944806, 10262036],

  // ── EV MDT (Sinotruk HOWO V36X MDT 4×2, GVW 16T) — EV LDT × (16/10)^0.5 ──
  "P128":      [6048000,  7182000,  6174000,  7686000,  7182000],

  // ── EV LDT (Sinotruk LDT 4×2 CARGO, GVW 12T) — EV LDT × (12/10)^0.5 ──
  "P129":      [5256000,  6242000,  5367000,  6680000,  6242000],

  // ── EV LDT (Sinotruk LDT 4×2 DUMP, GVW 12T, dump +15%) — EV LDT × 1.095 × 1.15 ──
  "P130":      [6044000,  7178000,  6171000,  7682000,  7178000],
};

/* PM segment fallback — used when pmKey not in PM_SCHEDULE */
window.PM_SEGMENT_DEFAULT = {
  "BUS":          7000000,
  "LDT":          4000000,
  "MDT":          6000000,
  "HDT":          8000000,
  "TH":           9000000,
  "VAN":          2500000,
  "Pickup":       1800000,
  "Double Cabin": 2000000,
  "EV_BUS":       5500000,
  "EV_LDT":       3500000,
  "EV_MDT":       5000000,
  "EV_HDT":       4000000,
  "EV_TH":        6000000,
  "EV_VAN":       2000000,
  "EV_Pickup":    1200000,
};

/* ============================================================
   maintCostForYear(vehId, year, annualKm, terrain) -- SUPERSEDED, v1.7.7 Wave 3.
   No longer used by the TCO calculation or the Maintenance tab: the parts
   breakdown below (maintGroupBreakdownCostForYear) is now the sole computed
   maintenance source, and it produces real per-year figures itself (see that
   block for why the old toggle between "top-down schedule" and "parts
   breakdown" was redundant). PM_SCHEDULE's cited Diponegoro TCOO V4 figures
   are kept here as reference data, same as the isSunkAsset precedent
   elsewhere in this file -- not wired to anything, deliberately not deleted.
   Returns IDR per-fleet total: PM cost for vehId in year `y`
   (1-indexed), scaled for mileage and terrain.
   NOTE: caller multiplies by fleetSize.
   ============================================================ */
window.maintCostForYear = (vehId, year, annualKm, terrain) => {
  const tm        = window.TERRAIN_MULTIPLIER[terrain] ?? 1.0;
  const mileScale = (annualKm || 50000) / 30000;
  const sched     = window.PM_SCHEDULE[vehId];

  if (sched && sched.length > 0) {
    const idx = Math.min(year - 1, sched.length - 1);
    return sched[idx] * mileScale * tm;
  }

  // Fallback: segment default
  const veh = window.findVeh(vehId);
  const prefix = (veh && veh.powertrain === "EV") ? "EV_" : "";
  const seg    = veh ? veh.segment : "LDT";
  const base   = window.PM_SEGMENT_DEFAULT[prefix + seg]
              ?? window.PM_SEGMENT_DEFAULT[seg]
              ?? window.PM_SEGMENT_DEFAULT["LDT"];
  return base * mileScale * tm;
};

/* ============================================================
   MAINTENANCE PARTS BREAKDOWN (bottom-up, parts-level estimate)
   Two generic templates (EV / ICE), scaled by vehicle GVW vs. a
   reference vehicle. Grounded in:
   - EV: BYD electric bus PM reference table (BYD D9HF 12m) and BYD
     EV service-interval documentation (gear oil ~4yr, battery
     coolant & brake fluid ~2yr, tire rotation ~10,000km).
   - ICE: Hino/Isuzu diesel bus/truck PM items, with Indonesian
     aftermarket spare-part prices researched June 2026 (Pertamina
     Meditran SX Plus 15W-40 oil, Sakura filters, Hino brake lining
     sets, 295/80R22.5 truck tires).
   Every line is tagged with `source` ("web_research" = grounded in
   a cited retail price, "estimated" = analyst placeholder) so the
   relevant department can review/replace values before final use.
   v1.7.7 Wave 3: this breakdown is now the sole computed maintenance
   source feeding TCO (see maintGroupBreakdownCostForYear below) —
   there is no longer a competing top-down mode to opt into.
   ============================================================ */
// Six fixed maintenance groups (consolidation, replaces the flat per-part
// list as the primary display grouping). "other" also catches unscheduled /
// not-yet-classified items. Bucket rulings (per VKTR review):
//  - battery-cooling coolant -> cooling (not battery_fuel)
//  - air-compressor oil/filter & wheel-hub bearing grease -> powertrain
//  - all greases/oils -> powertrain
window.MAINTENANCE_GROUPS = {
  tyre:         { nameEn: "Tyre",                  nameId: "Ban",                       icon: "🛞" },
  brake:        { nameEn: "Brake system",          nameId: "Sistem Rem",                icon: "🛑" },
  battery_fuel: { nameEn: "Battery / fuel system",  nameId: "Sistem Baterai / BBM",      icon: "🔋" },
  cooling:      { nameEn: "Cooling system",        nameId: "Sistem Pendingin",          icon: "❄️" },
  powertrain:   { nameEn: "Powertrain system",     nameId: "Sistem Penggerak",          icon: "⚙️" },
  other:        { nameEn: "Others / unscheduled",  nameId: "Lainnya / Tak Terjadwal",   icon: "🔧" },
};

// Tyre 2-tier pricing — toggle per vehicle between a budget and premium tyre
// brand band; both Rp values are user-editable (tyreTierPriceOverrides).
window.TYRE_TIERS = {
  cheap: {
    nameEn: "Budget", nameId: "Ekonomis", price: 3000000,
    examplesEn: "China-made budget brands, e.g. Linglong, Triangle, Aeolus",
    examplesId: "Merek ekonomis buatan China, mis. Linglong, Triangle, Aeolus",
  },
  expensive: {
    nameEn: "Premium", nameId: "Premium", price: 7000000,
    examplesEn: "Japan/Europe premium brands, e.g. Bridgestone, Michelin, Continental",
    examplesId: "Merek premium Jepang/Eropa, mis. Bridgestone, Michelin, Continental",
  },
};

window.MAINTENANCE_BREAKDOWN_TEMPLATES = {
  EV: {
    refGvw: 10000, refLabel: "VKTR BHF080 (8m bus, GVW 10,000 kg)",
    parts: [
      { nameEn: "Chassis/suspension grease", nameId: "Gemuk (grease) sasis/suspensi", spec: "Lithium EP2", qty: 2, unit: "kg", price: 85000, replacement: "interval_km", intervalKm: 10000, source: "estimated", group: "powertrain" },
      { nameEn: "Reduction gear oil", nameId: "Oli gear reduksi (motor listrik)", spec: "75W-90 GL-5", qty: 4, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 60000, intervalYears: 4, source: "web_research", note: "BYD EV service docs: EHS gear oil change every 4 years.", group: "powertrain" },
      { nameEn: "Air compressor oil", nameId: "Oli kompresor udara", spec: "Synthetic compressor oil", qty: 1, unit: "L", price: 150000, replacement: "interval_km", intervalKm: 10000, source: "estimated", group: "powertrain" },
      { nameEn: "Air compressor filter", nameId: "Filter kompresor udara", spec: "Cartridge", qty: 1, unit: "pc", price: 250000, replacement: "interval_km", intervalKm: 20000, source: "estimated", group: "powertrain" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: "Desiccant cartridge", qty: 1, unit: "pc", price: 450000, replacement: "interval_years", intervalYears: 1, source: "estimated", group: "brake" },
      { nameEn: "Battery cooling coolant", nameId: "Coolant pendingin baterai", spec: "EV battery thermal-management coolant", qty: 8, unit: "L", price: 180000, replacement: "interval_years", intervalYears: 2, source: "web_research", note: "BYD EV service docs: coolant & brake fluid replacement every 2 years.", group: "cooling" },
      { nameEn: "Wheel hub bearing grease", nameId: "Gemuk bearing roda", spec: "High-temp wheel-bearing grease", qty: 1, unit: "kg", price: 85000, replacement: "interval_km", intervalKm: 20000, source: "estimated", group: "powertrain" },
      { nameEn: "Brake fluid", nameId: "Minyak rem", spec: "DOT4", qty: 1, unit: "L", price: 90000, replacement: "interval_years", intervalYears: 2, source: "estimated", group: "brake" },
      { nameEn: "Cabin/HVAC air filter", nameId: "Filter kabin/AC", spec: "Cabin filter element", qty: 1, unit: "pc", price: 150000, replacement: "interval_km", intervalKm: 10000, source: "estimated", group: "other" },
      { nameEn: "Tires", nameId: "Ban", spec: "275/70R22.5 (bus)", qty: 6, unit: "pc", price: 5500000, replacement: "interval_km", intervalKm: 60000, source: "web_research", note: "295/80R22.5 Indonesian retail range ~Rp5.2-6.7jt (Giti/Dunlop/Michelin, 2025).", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 150000, replacement: "interval_years", intervalYears: 1, source: "estimated", group: "other" },
      { nameEn: "12V auxiliary battery", nameId: "Aki 12V (auxiliary)", spec: "Lead-acid auxiliary battery", qty: 1, unit: "pc", price: 1800000, replacement: "interval_years", intervalYears: 3, source: "estimated", group: "battery_fuel" },
    ],
  },
  ICE: {
    refGvw: 13500, refLabel: "Hino GB150 AT bus (GVW ~13,500 kg)",
    parts: [
      { nameEn: "Engine oil", nameId: "Oli mesin diesel", spec: "15W-40 CI-4", qty: 25, unit: "L", price: 56800, replacement: "interval_km", intervalKm: 10000, source: "web_research", note: "Pertamina Meditran SX Plus 15W-40, ~Rp56,800/L retail.", group: "powertrain" },
      { nameEn: "Oil filter", nameId: "Filter oli", spec: "Spin-on cartridge", qty: 1, unit: "pc", price: 67724, replacement: "interval_km", intervalKm: 10000, source: "web_research", note: "Sakura C-1318 (Hino Dutro) retail price.", group: "powertrain" },
      { nameEn: "Fuel filters (primary + secondary)", nameId: "Filter solar (atas + bawah)", spec: "Element/spin-on set", qty: 2, unit: "pc", price: 65000, replacement: "interval_km", intervalKm: 10000, source: "web_research", note: "Avg of Sakura FC-1301 (Rp26,980) and Hino 500 Euro4 element (Rp105,000).", group: "battery_fuel" },
      { nameEn: "Air filter", nameId: "Filter udara", spec: "Panel/cartridge", qty: 1, unit: "pc", price: 69495, replacement: "interval_km", intervalKm: 20000, source: "web_research", note: "Sakura A-1135 (Hino Dutro/Dyna/Rino) retail price.", group: "powertrain" },
      { nameEn: "Coolant", nameId: "Air radiator / coolant", spec: "Long-life coolant", qty: 20, unit: "L", price: 35000, replacement: "interval_km", intervalKm: 40000, intervalYears: 2, source: "estimated", group: "cooling" },
      { nameEn: "Brake lining (front + rear sets)", nameId: "Kampas rem (set depan + belakang)", spec: "Lining set", qty: 2, unit: "set", price: 535000, replacement: "interval_km", intervalKm: 40000, source: "web_research", note: "Avg of Hino Lohan 285 rear (Rp507,000) and RK/RG/Lohan 320 rear (Rp563,000).", group: "brake" },
      { nameEn: "Tires", nameId: "Ban", spec: "295/80R22.5", qty: 6, unit: "pc", price: 6000000, replacement: "interval_km", intervalKm: 60000, source: "web_research", note: "Mid-range of Rp5.2-6.7jt retail (Giti/Dunlop/Michelin), 2025.", group: "tyre", isTyre: true },
      { nameEn: "Transmission oil", nameId: "Oli transmisi", spec: "SAE 40 / GL-4", qty: 8, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 40000, source: "estimated", group: "powertrain" },
      { nameEn: "Differential/axle oil", nameId: "Oli gardan/axle", spec: "GL-5 85W-140", qty: 6, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 40000, source: "estimated", group: "powertrain" },
      { nameEn: "Drive belts", nameId: "Tali kipas / V-belt", spec: "Fan/alternator belt", qty: 1, unit: "pc", price: 250000, replacement: "interval_km", intervalKm: 40000, source: "estimated", group: "powertrain" },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 150000, replacement: "interval_years", intervalYears: 1, source: "estimated", group: "other" },
      { nameEn: "Starter battery", nameId: "Aki starter", spec: "Lead-acid 12V (×2 for 24V system)", qty: 2, unit: "pc", price: 1800000, replacement: "interval_years", intervalYears: 2, source: "estimated", group: "battery_fuel" },
    ],
  },

  // ============================================================
  // v1.7.7 (definitive-DB pass) -- real, segment-specific templates,
  // replacing the 2 generic EV/ICE ones above for the 3 segments VKTR has
  // actual internal data for (BUS/LDT/HDT). EV: getMaintenanceBreakdown()
  // resolves powertrainKey + veh.segment -> "{SEGMENT}_{EV|ICE}" first,
  // falling back to the flat EV/ICE templates above for any other segment
  // (MDT/TH/VAN/Pickup/Double Cabin), so nothing regresses to worse data
  // than it had before.
  //
  // Two source families, both real VKTR-internal data, distinct provenance:
  //  - "diponegoro_tcoo": 250909 TCOO V4 Project Diponegoro.xlsx, the
  //    per-segment "* Detail" sheets (fluids/greases/filters/air-system --
  //    the recurring-service items; each sheet's one-time-fill-only rows,
  //    no km/year interval, are excluded here since this platform's part
  //    model only handles recurring costs). NOTE on the source data itself:
  //    HDT 6x4 SWB 352 Detail and HDT 8x4 SWB 375 Detail sheets are
  //    byte-for-byte identical (same vehicles, same every part/price) --
  //    reproduced faithfully as one HDT template, not invented as two.
  //    Likewise the Bus segment's ICE fluid/filter list is identical
  //    between the 8m and 12m sheets, and (coincidentally, per the source
  //    file) identical again to the HDT segment's Fuso comparator list --
  //    the source analyst evidently reused one generic-ICE-truck PM input
  //    table across multiple vehicle-comparison sheets; represented here
  //    as-is rather than artificially differentiated.
  //  - "internal_po": PO List Production Parts & Spare-Parts (20260710),
  //    real 2022-2026 VKTR purchase-order transaction prices -- used only
  //    for the 3 part categories Diponegoro's Detail sheets don't cover at
  //    all (tires, 12V battery, wiper blades), each refreshed to its most
  //    recent (2026) OEM-tier price per explicit direction ("pick one
  //    representative price per part," not a tier system for every part).
  //    A cluster of BYD-branded PO List lines showed internally
  //    inconsistent scale (same part number 10-10,000x apart across rows)
  //    and was excluded rather than risk propagating a data-entry error.
  BUS_EV: {
    refGvw: 10000, refLabel: "BYD D9HF 12m / VKTR BCH080 (EV bus -- Diponegoro TCOO V4, identical BOM both sheets)",
    parts: [
      { nameEn: "Low-temp chassis grease", nameId: "Gemuk suhu rendah sasis", spec: "Lithium grease", qty: 1, unit: "kg", price: 50000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor oil", nameId: "Oli kompresor udara", spec: "Shell Corena S4 R46", qty: 1.85, unit: "L", price: 170000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor filter (built-in)", nameId: "Filter kompresor udara (built-in)", spec: "K9FE-3509111A", qty: 1, unit: "unit", price: 226800, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor oil separator", nameId: "Oil separator kompresor udara", spec: "K9FE-3509321", qty: 1, unit: "unit", price: 1000000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Reduction gear oil", nameId: "Oli gear reduksi", spec: "SAE80W-90 GL-5", qty: 8.5, unit: "L", price: 70000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "High-temp grease", nameId: "Gemuk suhu tinggi", spec: null, qty: 4, unit: "kg", price: 150000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor external filter", nameId: "Filter luar kompresor udara", spec: "C7a-3509121C", qty: 1, unit: "unit", price: 650000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Compressor seal kit", nameId: "Sealkit kompresor", spec: null, qty: 1, unit: "set", price: 1784149, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: "K9A-3555011C", qty: 1, unit: "pc", price: 1393200, replacement: "interval_years", intervalYears: 1, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Steering fluid", nameId: "Minyak power steering", spec: "ATF Dexron III/CHF-202", qty: 5, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 120000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Battery cooling coolant", nameId: "Coolant pendingin baterai", spec: "BYD coolant, -25°C/-40°C", qty: 42, unit: "L", price: 50000, replacement: "interval_km", intervalKm: 200000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "Wheel hub bearing grease", nameId: "Gemuk bearing roda", spec: null, qty: 0.5, unit: "kg", price: 350000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Cooling system coolant", nameId: "Coolant sistem pendingin", spec: "BYD coolant, -25°C/-40°C", qty: 34, unit: "L", price: 50000, replacement: "interval_km", intervalKm: 240000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "Tires", nameId: "Ban", spec: "12.00 R20 18PR", qty: 6, unit: "pc", price: 6006757, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2025-2026 real price (Paderona/Giti GAO822 12.00 R20 18PR).", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "12V auxiliary battery", nameId: "Aki 12V (auxiliary)", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 3, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
  BUS_ICE: {
    refGvw: 13500, refLabel: "Mercedes-Benz 1626 E4 / Fuso FE84G BC (ICE bus -- Diponegoro TCOO V4)",
    parts: [
      { nameEn: "Low-temp chassis grease", nameId: "Gemuk suhu rendah sasis", spec: "Lithium grease", qty: 2, unit: "kg", price: 80000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Engine oil", nameId: "Oli mesin diesel", spec: null, qty: 28, unit: "L", price: 59000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Oil filter", nameId: "Filter oli", spec: null, qty: 1, unit: "PC", price: 250000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Fuel filter", nameId: "Filter solar", spec: null, qty: 1, unit: "PC", price: 200000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Water separator", nameId: "Pemisah air (water separator)", spec: null, qty: 1, unit: "PC", price: 350000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Air filter", nameId: "Filter udara", spec: null, qty: 1, unit: "PC", price: 1200000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: null, qty: 1, unit: "PC", price: 1500000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Transmission oil", nameId: "Oli transmisi", spec: "SAE80W-90 GL-4", qty: 8, unit: "L", price: 96000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Differential/axle oil", nameId: "Oli gardan/axle", spec: "SAE80W-90 GL-5", qty: 13, unit: "L", price: 100000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "High-temp bearing grease", nameId: "Gemuk bearing suhu tinggi", spec: null, qty: 4, unit: "kg", price: 150000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Clutch fluid", nameId: "Minyak kopling", spec: null, qty: 1, unit: "L", price: 50000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Steering fluid", nameId: "Minyak power steering", spec: "ATF Dexron III/CHF-202", qty: 15, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Radiator coolant", nameId: "Coolant radiator", spec: "Anti Freeze", qty: 20, unit: "L", price: 80000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "SCR filter (AdBlue/exhaust)", nameId: "Filter SCR (AdBlue/gas buang)", spec: null, qty: 1, unit: "PC", price: 200000, replacement: "interval_km", intervalKm: 120000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Tires", nameId: "Ban", spec: "12.00 R20 18PR", qty: 6, unit: "pc", price: 6006757, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2025-2026 real price (Paderona/Giti GAO822 12.00 R20 18PR).", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "Starter battery", nameId: "Aki starter", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 2, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
  LDT_EV: {
    refGvw: 12500, refLabel: "VKTR TLD080F (EV LDT -- Diponegoro TCOO V4)",
    parts: [
      { nameEn: "Low-temp chassis grease", nameId: "Gemuk suhu rendah sasis", spec: "Lithium grease", qty: 1, unit: "kg", price: 150000, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Reduction gear oil", nameId: "Oli gear reduksi", spec: "SAE80W-90 GL-5", qty: 6, unit: "L", price: 70000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor oil", nameId: "Oli kompresor udara", spec: "Shell Corena S4 R46", qty: 1.85, unit: "L", price: 170000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Low-temp grease", nameId: "Gemuk suhu rendah", spec: null, qty: 0.5, unit: "kg", price: 50000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor oil (2nd stage)", nameId: "Oli kompresor udara (tahap 2)", spec: "Shell Corena S4 R46", qty: 1.85, unit: "L", price: 170000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air compressor filter (built-in)", nameId: "Filter kompresor udara (built-in)", spec: null, qty: 1, unit: "unit", price: 500000, replacement: "interval_km", intervalKm: 20000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: null, qty: 1, unit: "pc", price: 1500000, replacement: "interval_km", intervalKm: 20000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Steering fluid", nameId: "Minyak power steering", spec: "ATF Dexron III/CHF-202", qty: 1.5, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Cooling system coolant", nameId: "Coolant sistem pendingin", spec: "-25°C/-40°C, 50% ethylene glycol", qty: 10, unit: "L", price: 80000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "Tires", nameId: "Ban", spec: "275/70R22.5 18PR", qty: 6, unit: "pc", price: 3641892, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2024-2025 real price (Ban Luar 275/70 R22.5 18PR, Paderona).", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "12V auxiliary battery", nameId: "Aki 12V (auxiliary)", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 3, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
  LDT_ICE: {
    refGvw: 8500, refLabel: "Fuso FE SHDX (ICE LDT -- Diponegoro TCOO V4)",
    parts: [
      { nameEn: "Low-temp chassis grease", nameId: "Gemuk suhu rendah sasis", spec: "Lithium grease", qty: 2, unit: "kg", price: 80000, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Engine oil", nameId: "Oli mesin diesel", spec: "API CI-4", qty: 9, unit: "L", price: 76800, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Oil filter", nameId: "Filter oli", spec: null, qty: 1, unit: "PC", price: 95000, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Fuel filter", nameId: "Filter solar", spec: null, qty: 1, unit: "PC", price: 85200, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Air filter", nameId: "Filter udara", spec: null, qty: 1, unit: "PC", price: 150900, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Transmission oil", nameId: "Oli transmisi", spec: "SAE80W-90 GL-4", qty: 4, unit: "L", price: 56670, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Differential/axle oil", nameId: "Oli gardan/axle", spec: "SAE80W-90 GL-5", qty: 13, unit: "L", price: 29904, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "High-temp bearing grease", nameId: "Gemuk bearing suhu tinggi", spec: null, qty: 4, unit: "kg", price: 150000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Clutch fluid", nameId: "Minyak kopling", spec: null, qty: 1, unit: "L", price: 87083, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Steering fluid", nameId: "Minyak power steering", spec: "ATF Dexron III/CHF-202", qty: 15, unit: "L", price: 85000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Radiator coolant", nameId: "Coolant radiator", spec: "Anti Freeze", qty: 20, unit: "L", price: 85000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "Tires", nameId: "Ban", spec: "275/70R22.5 18PR", qty: 6, unit: "pc", price: 3641892, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2024-2025 real price (Ban Luar 275/70 R22.5 18PR, Paderona).", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "Starter battery", nameId: "Aki starter", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 2, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
  HDT_EV: {
    refGvw: 60000, refLabel: "VKTR THD261S / HOWO V7X HDT (EV HDT -- Diponegoro TCOO V4; reproduced identically for both 6x4 and 8x4 SWB in the source workbook)",
    parts: [
      { nameEn: "Air compressor filter", nameId: "Filter kompresor udara", spec: "Synland", qty: 1, unit: "Pc", price: 500000, replacement: "interval_km", intervalKm: 20000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Rear axle oil", nameId: "Oli gardan belakang", spec: "GL-5, weight-load motor gear oil", qty: 6, unit: "L", price: 90000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Wheel gear bearing grease", nameId: "Gemuk bearing gear roda", spec: "High-temp wheel gear grease", qty: 0.5, unit: "kg", price: 350000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Leaf spring pin grease", nameId: "Gemuk pin per daun (leaf spring)", spec: "Lithium grease", qty: 1, unit: "kg", price: 140000, replacement: "interval_km", intervalKm: 10000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Steering oil", nameId: "Oli power steering", spec: "ATF Dextron 3", qty: 1.5, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Cabin/system filter", nameId: "Filter kabin/sistem", spec: "Synland", qty: 1, unit: "Pc", price: 250000, replacement: "interval_km", intervalKm: 20000, source: "diponegoro_tcoo", group: "other" },
      { nameEn: "Cooling system coolant", nameId: "Coolant sistem pendingin", spec: "50% ethylene glycol", qty: 10, unit: "L", price: 80000, replacement: "interval_km", intervalKm: 40000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: null, qty: 1, unit: "Pc", price: 1500000, replacement: "interval_km", intervalKm: 20000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Tires", nameId: "Ban", spec: "295/80R22.5 18PR", qty: 10, unit: "pc", price: 4016297, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2025-2026 real price (Ban Giti GSR 295/80 R22.5 18PR); qty 10 for a typical 6x4/8x4 HDT axle count.", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "12V auxiliary battery", nameId: "Aki 12V (auxiliary)", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 3, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
  HDT_ICE: {
    refGvw: 26000, refLabel: "Fuso FN62F (ICE HDT -- Diponegoro TCOO V4)",
    parts: [
      { nameEn: "Low-temp chassis grease", nameId: "Gemuk suhu rendah sasis", spec: "Lithium grease", qty: 2, unit: "kg", price: 80000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Engine oil", nameId: "Oli mesin diesel", spec: null, qty: 28, unit: "L", price: 59000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Oil filter", nameId: "Filter oli", spec: null, qty: 1, unit: "PC", price: 250000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Fuel filter", nameId: "Filter solar", spec: null, qty: 1, unit: "PC", price: 200000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Water separator", nameId: "Pemisah air (water separator)", spec: null, qty: 1, unit: "PC", price: 350000, replacement: "interval_km", intervalKm: 15000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Air filter", nameId: "Filter udara", spec: null, qty: 1, unit: "PC", price: 1200000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Air dryer cartridge", nameId: "Cartridge air dryer", spec: null, qty: 1, unit: "PC", price: 1500000, replacement: "interval_km", intervalKm: 30000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Transmission oil", nameId: "Oli transmisi", spec: "SAE80W-90 GL-4", qty: 8, unit: "L", price: 96000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Differential/axle oil", nameId: "Oli gardan/axle", spec: "SAE80W-90 GL-5", qty: 13, unit: "L", price: 100000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "High-temp bearing grease", nameId: "Gemuk bearing suhu tinggi", spec: null, qty: 4, unit: "kg", price: 150000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Clutch fluid", nameId: "Minyak kopling", spec: null, qty: 1, unit: "L", price: 50000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "brake" },
      { nameEn: "Steering fluid", nameId: "Minyak power steering", spec: "ATF Dexron III/CHF-202", qty: 15, unit: "L", price: 120000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "powertrain" },
      { nameEn: "Radiator coolant", nameId: "Coolant radiator", spec: "Anti Freeze", qty: 20, unit: "L", price: 80000, replacement: "interval_km", intervalKm: 60000, source: "diponegoro_tcoo", group: "cooling" },
      { nameEn: "SCR filter (AdBlue/exhaust)", nameId: "Filter SCR (AdBlue/gas buang)", spec: null, qty: 1, unit: "PC", price: 200000, replacement: "interval_km", intervalKm: 120000, source: "diponegoro_tcoo", group: "battery_fuel" },
      { nameEn: "Tires", nameId: "Ban", spec: "295/80R22.5 18PR", qty: 10, unit: "pc", price: 4016297, replacement: "interval_km", intervalKm: 60000, source: "internal_po", note: "PO List 2025-2026 real price (Ban Giti GSR 295/80 R22.5 18PR); qty 10 for a typical 6x4/8x4 HDT axle count.", group: "tyre", isTyre: true },
      { nameEn: "Wiper blades", nameId: "Wiper blade", spec: "Standard blade, pair", qty: 2, unit: "pc", price: 125000, replacement: "interval_years", intervalYears: 1, source: "internal_po", note: "PO List real price, mid of Rp99,750-152,250 vendor range.", group: "other" },
      { nameEn: "Starter battery", nameId: "Aki starter", spec: "Lead-acid, 100Ah", qty: 1, unit: "pc", price: 2081081, replacement: "interval_years", intervalYears: 2, source: "internal_po", note: "PO List 2026 real price (Battery Low Voltage 60038 100Ah).", group: "battery_fuel" },
    ],
  },
};

// Returns { refLabel, scale, parts: [...] } with quantities of liquid
// items (L/kg) scaled by vehicle GVW vs. the template's reference GVW.
// tyreTier ("cheap"|"expensive"|null) + tyreTierPriceOverrides override the
// price of any part flagged isTyre:true (the platform's 2-tier tyre toggle).
window.getMaintenanceBreakdown = function(veh, tyreTier, tyreTierPriceOverrides, partPriceOverrides) {
  if (!veh) return null;
  const powertrainKey = veh.powertrain === "EV" ? "EV" : "ICE";
  // v1.7.7 (definitive-DB pass): real, segment-specific templates
  // (BUS/LDT/HDT x EV/ICE) take priority over the flat generic EV/ICE
  // fallback for any other segment (MDT/TH/VAN/Pickup/Double Cabin) --
  // see MAINTENANCE_BREAKDOWN_TEMPLATES' own comment for provenance.
  const segmentKey = veh.segment + "_" + powertrainKey;
  const templateKey = window.MAINTENANCE_BREAKDOWN_TEMPLATES[segmentKey] ? segmentKey : powertrainKey;
  const tmpl = window.MAINTENANCE_BREAKDOWN_TEMPLATES[templateKey];
  const scale = Math.max(0.4, Math.min(2.5, (veh.gvw || tmpl.refGvw) / tmpl.refGvw));
  const tierPrice = tyreTier && window.TYRE_TIERS[tyreTier]
    ? (tyreTierPriceOverrides?.[tyreTier] ?? window.TYRE_TIERS[tyreTier].price)
    : null;
  return {
    refLabel: tmpl.refLabel,
    scale,
    parts: tmpl.parts.map((p, i) => {
      const partKey = `${templateKey}.${i}`;
      const overridePrice = partPriceOverrides?.[partKey];
      return {
        ...p,
        partKey,
        defaultPrice: p.price,
        qty: (p.unit === "L" || p.unit === "kg") ? Math.round(p.qty * scale * 10) / 10 : p.qty,
        price: overridePrice ?? ((p.isTyre && tierPrice != null) ? tierPrice : p.price),
      };
    }),
  };
};

// Annualized cost of a single part at a given yearly mileage —
// for km-based items with a year cap ("every X km or Y years,
// whichever comes first"), the shorter cycle wins.
window.maintPartAnnualCost = function(part, annualKm) {
  const km = annualKm || 50000;
  const totalCost = part.qty * part.price;
  if (part.replacement === "interval_years") {
    return totalCost / (part.intervalYears || 1);
  }
  let cycleKm = part.intervalKm || 1;
  if (part.intervalYears) cycleKm = Math.min(cycleKm, km * part.intervalYears);
  return (totalCost / cycleKm) * km;
};

// Sum of all parts' annualized cost for one vehicle (per unit, per year).
window.maintBreakdownAnnualCost = function(veh, annualKm, tyreTier, tyreTierPriceOverrides, partPriceOverrides) {
  const bd = window.getMaintenanceBreakdown(veh, tyreTier, tyreTierPriceOverrides, partPriceOverrides);
  if (!bd) return 0;
  return bd.parts.reduce((sum, p) => sum + window.maintPartAnnualCost(p, annualKm), 0);
};

// V1.6 simplification: the parts template is kept as the *default* source for
// each of the 6 fixed groups (window.MAINTENANCE_GROUPS), but the UI no longer
// exposes individual parts -- the user edits one Rp/yr figure per group, and
// that override (when set) replaces the whole group's parts-template sum.
// Returns { tyre: 1234567, brake: ..., ... } -- one annualized default per group.
window.maintGroupAnnualCost = function(veh, annualKm, tyreTier, tyreTierPriceOverrides) {
  const bd = window.getMaintenanceBreakdown(veh, tyreTier, tyreTierPriceOverrides, null);
  if (!bd) return {};
  const sums = {};
  bd.parts.forEach(p => {
    const g = p.group || "other";
    sums[g] = (sums[g] || 0) + window.maintPartAnnualCost(p, annualKm);
  });
  return sums;
};

// Group-override-aware total: for each of the 6 groups, use the user's
// override if set, else the parts-template default sum for that group.
// This is the smoothed/flat annualized figure -- kept for any caller that
// still wants a single representative number (e.g. quick estimates); the
// TCO calc and the Maintenance tab use the real per-year functions below.
window.maintGroupBreakdownAnnualCost = function(veh, annualKm, tyreTier, tyreTierPriceOverrides, groupOverrides) {
  const sums = window.maintGroupAnnualCost(veh, annualKm, tyreTier, tyreTierPriceOverrides);
  return Object.keys(sums).reduce((total, g) => total + (groupOverrides?.[g] ?? sums[g]), 0);
};

/* ============================================================
   Real per-year maintenance cost (v1.7.7 Wave 3) -- replaces the
   smoothed/averaged figures above as the TCO source. Instead of spreading
   a part's replacement cost evenly across every year, this counts how many
   replacement cycles (by km or by time, whichever the part uses) actually
   fall within a given year and charges their full cost in that year --
   e.g. tyres replaced every 60,000 km show Rp0 in a year the fleet hasn't
   covered 60,000 km yet, then a real spike the year it crosses that
   threshold. This is the same underlying parts template as above; it's
   just evaluated per-year instead of averaged, so the "top summary box"
   and the "6-group breakdown" are now two views of one model, not two
   competing methods.
   ============================================================ */
window.maintPartCostForYear = function(part, annualKm, year) {
  const km = annualKm || 50000;
  const totalCost = part.qty * part.price;
  if (part.replacement === "interval_years") {
    const cycleYears = part.intervalYears || 1;
    const before = Math.floor((year - 1) / cycleYears);
    const through = Math.floor(year / cycleYears);
    return totalCost * (through - before);
  }
  let cycleKm = part.intervalKm || 1;
  if (part.intervalYears) cycleKm = Math.min(cycleKm, km * part.intervalYears);
  const before = Math.floor((km * (year - 1)) / cycleKm);
  const through = Math.floor((km * year) / cycleKm);
  return totalCost * (through - before);
};

// Per-group totals for one specific year (real, not smoothed).
window.maintGroupCostForYear = function(veh, annualKm, tyreTier, tyreTierPriceOverrides, year) {
  const bd = window.getMaintenanceBreakdown(veh, tyreTier, tyreTierPriceOverrides, null);
  if (!bd) return {};
  const sums = {};
  bd.parts.forEach(p => {
    const g = p.group || "other";
    sums[g] = (sums[g] || 0) + window.maintPartCostForYear(p, annualKm, year);
  });
  return sums;
};

// Group-override-aware total for one specific year. A manual group
// override is a flat Rp/yr figure the user entered in place of the
// generic template — it applies identically every year (it IS the user's
// stated annual number), while groups without an override use that
// year's real parts-cycle cost.
window.maintGroupBreakdownCostForYear = function(veh, annualKm, tyreTier, tyreTierPriceOverrides, groupOverrides, year) {
  const sums = window.maintGroupCostForYear(veh, annualKm, tyreTier, tyreTierPriceOverrides, year);
  return Object.keys(sums).reduce((total, g) => total + (groupOverrides?.[g] ?? sums[g]), 0);
};

// ---------- AdBlue (DEF) cost helper ----------
window.ADBLUE_DOSE_DEFAULT = 4; // % of diesel volume, SCR typical 3–5%

window.iceEnergyCost = (dieselLiters, dieselPrice, adbluePrice, dosePct) => {
  const dieselCost  = dieselLiters * dieselPrice;
  const adblueCost  = adbluePrice > 0
    ? dieselLiters * ((dosePct ?? window.ADBLUE_DOSE_DEFAULT) / 100) * adbluePrice
    : 0;
  return { dieselCost, adblueCost, total: dieselCost + adblueCost, usesAdblue: adbluePrice > 0 };
};

/* ============================================================
   RESIDUAL VALUE SCHEDULE — DJKN PMK 223/2021 (ICE baseline),
   plus an EV-specific curve.
   ============================================================
   The DJKN schedule is an official fiscal/tax depreciation table, not an
   EV-specific market resale curve. Indonesia's used-EV market is still
   immature and buyers price in battery-health uncertainty, so commercial
   EVs are ASSUMED to depreciate faster in years 1-5 than the DJKN/ICE
   schedule before converging toward the same long-run floor (scrap +
   battery-recycling value dominates either way by year 15-20). This is an
   assumption pending real Indonesian used-EV transaction data — override
   via Tab 5 if better data becomes available. */
window.RESIDUAL_SCHEDULE_ICE = { 0:1.00, 1:0.80, 2:0.70, 3:0.62, 4:0.55, 5:0.50, 7:0.40, 10:0.30, 15:0.20, 20:0.10 };
window.RESIDUAL_SCHEDULE_EV  = { 0:1.00, 1:0.72, 2:0.60, 3:0.52, 4:0.46, 5:0.42, 7:0.34, 10:0.26, 15:0.18, 20:0.10 };
// Back-compat alias — historical name, still the ICE/default schedule.
window.RESIDUAL_SCHEDULE = window.RESIDUAL_SCHEDULE_ICE;

window.residualFractionSchedule = (years, powertrain) => {
  const sched = powertrain === "EV" ? window.RESIDUAL_SCHEDULE_EV : window.RESIDUAL_SCHEDULE_ICE;
  const keys  = Object.keys(sched).map(Number).sort((a, b) => a - b);
  if (years <= keys[0])             return sched[keys[0]];
  if (years >= keys[keys.length-1]) return sched[keys[keys.length-1]];
  const lo = keys.filter(k => k <= years).pop();
  const hi = keys.filter(k => k >  years)[0];
  const t  = (years - lo) / (hi - lo);
  return sched[lo] + t * (sched[hi] - sched[lo]);
};

/* SOH-linked EV residual value (v1.6) — decomposes vehicle value into a
   battery portion (depreciates exactly with the user's own configured
   battery-degradation rate, i.e. State-of-Health) and a non-battery portion
   (chassis/body/electrical, standard declining-balance). Adopted in place of
   the fixed empirical lookup curve because the old curve was internally
   inconsistent: a user could set batteryDegradationPctPerYear anywhere and
   the EV residual line wouldn't move, since it read from a static table
   instead of that same input. Cross-checked against an external commercial
   EV financing model (battery ~30-40% of unit price is a commonly-cited
   range; SOH-proportional battery devaluation is standard in EV residual
   literature given immature secondary markets price battery health directly). */
window.residualFractionSOH = (years, degradationPctPerYear, batteryWeightPct, nonBatteryDeprPctPerYear) => {
  const soh = window.batteryHealthFraction(years, degradationPctPerYear);
  const bw  = (batteryWeightPct ?? 35) / 100;
  const nonBatteryRate = (nonBatteryDeprPctPerYear ?? 8) / 100;
  return bw * soh + (1 - bw) * Math.pow(1 - nonBatteryRate, years);
};

window.residualFraction = (years, powertrain, s) => {
  if (powertrain === "EV" && s && (s.residualValueMethod ?? "soh") === "soh") {
    return window.residualFractionSOH(years, s.batteryDegradationPctPerYear, s.batteryWeightPctOfPrice, s.nonBatteryDeprPctPerYear);
  }
  return window.residualFractionSchedule(years, powertrain);
};

/* Amortizing loan math (v1.6) — standard declining-balance annuity, added
   alongside (not replacing) the platform's existing flat-rate financing
   model. Flat-rate stays the default so every already-validated TCOO
   comparison is untouched; amortizing is an explicit opt-in via
   loanInterestMethod. Guarded for monthlyRate=0 (principal/months) per the
   same edge case the reference commercial model hardens against. */
window.amortizedPMT = (principal, monthlyRate, months) => {
  if (months <= 0) return 0;
  if (monthlyRate === 0) return principal / months;
  return principal * monthlyRate / (1 - Math.pow(1 + monthlyRate, -months));
};

// Returns an array of annual INTEREST-ONLY cost (not principal — principal
// is already counted via the platform's existing `capex` line), one entry
// per year of the loan tenor, computed off the declining balance.
window.amortizingInterestByYear = (principal, annualRatePct, tenorYears) => {
  const monthlyRate = (annualRatePct / 100) / 12;
  const months = Math.max(1, Math.round(tenorYears * 12));
  const pmt = window.amortizedPMT(principal, monthlyRate, months);
  let balance = principal, yearInterest = 0;
  const interestByYear = [];
  for (let m = 1; m <= months; m++) {
    const interest_m = balance * monthlyRate;
    const principal_m = pmt - interest_m;
    balance = Math.max(0, balance - principal_m);
    yearInterest += interest_m;
    if (m % 12 === 0) { interestByYear.push(yearInterest); yearInterest = 0; }
  }
  if (yearInterest > 0) interestByYear.push(yearInterest);
  return interestByYear;
};

/* ============================================================
   EV BATTERY DEGRADATION — opt-in, transparency + optional
   replacement-cost modeling over the TCO horizon.
   ============================================================
   Linear capacity-fade approximation (commonly used for moderate
   horizons in commercial-fleet literature), floored at 50% so the
   curve doesn't go negative for very long horizons. Default rate
   2.5%/yr and replacement threshold/cost are ASSUMPTIONS — surfaced
   read-only in the Financials Audit tab and fully overridable. */
window.batteryHealthFraction = (years, pctPerYear) => {
  const rate = (pctPerYear ?? 2.5) / 100;
  return Math.max(0.5, 1 - rate * years);
};

/* ============================================================
   TERRAIN ENERGY MULTIPLIERS (locked 2026-06-08)
   ============================================================ */
window.TERRAIN_MULTIPLIER = { Flat: 1.00, Rolling: 1.10, Hilly: 1.20, Mixed: 1.05 };
/* Sourced: MDPI Atmosphere 2025 / ORNL — hilly routes show +15-20% fuel over flat,
   confirming the 1.20 Hilly figure; rolling/mixed values are sensible interpolations. */

/* ============================================================
   TRACK PROFILE — generalized route input (distance/contour/elevation/variance)
   computed from gain-per-km + net-elevation-delta instead of a manual guess.
   Also drives the EV regenerative-braking credit against the terrain multiplier.
   ============================================================ */
window.computeTrackProfile = function(tp) {
  if (!tp || !tp.distanceKm || tp.distanceKm <= 0) return null;
  const distanceKm = tp.distanceKm;
  const gainM = Math.max(0, tp.elevGainM || 0);
  const lossM = Math.max(0, tp.elevLossM || 0);
  const netElevDeltaM = tp.netElevDeltaM != null ? tp.netElevDeltaM : (gainM - lossM);
  const gainPerKm = gainM / distanceKm;

  // Contour bucket — computed, not guessed
  let contour = "Flat";
  if (gainPerKm >= 30) contour = "Hilly";
  else if (gainPerKm >= 18) contour = (gainPerKm < 22) ? "Mixed" : "Hilly";
  else if (gainPerKm >= 10) contour = "Rolling";

  // Variance — how much gross climbing cancels itself out (oscillating) vs nets to one grade (steady)
  const totalUpDown = gainM + lossM;
  const linearityIndex = totalUpDown > 0 ? Math.min(1, Math.abs(netElevDeltaM) / totalUpDown) : 1;
  const varianceLabel = linearityIndex > 0.6 ? "Steady Grade" : (linearityIndex >= 0.25 ? "Mixed Grade" : "Variable/Choppy");

  // EV regenerative-braking credit — choppier routes give EVs more downhill recovery opportunity
  const oscillationCreditRate = tp.oscillationCreditRate ?? 0.4;
  const terrainMultiplier = window.TERRAIN_MULTIPLIER[contour] ?? 1.0;
  const regenCredit = (1 - linearityIndex) * oscillationCreditRate * (terrainMultiplier - 1);
  const evMultiplier = terrainMultiplier - regenCredit;
  const iceMultiplier = terrainMultiplier;

  return {
    distanceKm, gainM, lossM, netElevDeltaM, gainPerKm,
    contour, linearityIndex, varianceLabel,
    oscillationCreditRate, terrainMultiplier, regenCredit, evMultiplier, iceMultiplier,
  };
};

/* ============================================================
   INFRA OPEX RATE — SUPERSEDED, v1.7.7 Wave 3. No longer applied to TCO:
   confirmed business fact is that Helio Sinar Energi manages and expends
   100% of EVCS OPEX, so VKTR's infra OPEX in TCO is always 0 (see
   infraForVeh below). Kept as a field (not deleted) since it's a real
   historical estimate that may be useful if the Helio arrangement changes.
   ============================================================ */
window.INFRA_OPEX_RATE = 0.05;

/* ============================================================
   DEMAND CHARGE / WBP (Waktu Beban Puncak) charging-profile presets
   Simplified estimate: extra cost = energy_y * wbpSharePct * (wbpMultiplier - 1)
   PLN time-of-use tariffs for medium-voltage business/industrial customers
   (e.g. B-3, I-3/I-4) can apply a higher rate during the WBP window
   (commonly 17:00–22:00). wbpMultiplier and wbpSharePct are ESTIMATES —
   confirm against the site's actual PLN tariff class & contract before use.
   ============================================================ */
window.DEMAND_CHARGE_PROFILES = [
  {
    id: "off_peak", source: "estimated",
    labelEn: "Off-peak / overnight charging", labelId: "Pengisian di luar WBP / malam hari",
    descEn: "Fleet charges entirely outside the 17:00–22:00 peak window — no demand-charge surcharge applies.",
    descId: "Armada mengisi daya seluruhnya di luar jendela WBP 17:00–22:00 — tidak ada tambahan biaya beban puncak.",
    wbpSharePct: 0, wbpMultiplier: 1.5,
  },
  {
    id: "mixed", source: "estimated",
    labelEn: "Mixed daytime charging", labelId: "Pengisian campuran siang hari",
    descEn: "Some charging sessions overlap the WBP window — a partial surcharge is applied to lifetime energy cost.",
    descId: "Sebagian sesi pengisian bersinggungan dengan jendela WBP — sebagian biaya tambahan dikenakan pada biaya energi seumur hidup.",
    wbpSharePct: 30, wbpMultiplier: 1.5,
  },
  {
    id: "peak", source: "estimated",
    labelEn: "Includes peak-hour (17:00–22:00) charging", labelId: "Termasuk pengisian jam sibuk (17:00–22:00)",
    descEn: "Significant charging happens during the WBP window — the full estimated surcharge is applied.",
    descId: "Sebagian besar pengisian terjadi pada jendela WBP — tambahan biaya estimasi penuh dikenakan.",
    wbpSharePct: 70, wbpMultiplier: 1.5,
  },
];
window.findDemandChargeProfile = function(id) {
  return window.DEMAND_CHARGE_PROFILES.find(p => p.id === id) || window.DEMAND_CHARGE_PROFILES[1];
};

/* ============================================================
   CO₂ EMISSION FACTORS (v1.8 — well-to-wheel, both sides consistent)
   TODO(handover): refresh annually as PLN grid mix evolves
   ============================================================
   v1.8 update: both factors moved to a consistent well-to-wheel (WTW)
   boundary. Previously the EV side counted its upstream (grid generation)
   while the ICE side counted only combustion (tank-to-wheel) — an
   asymmetric boundary that biased every comparison against EVs regardless
   of calibration accuracy. Fixed by adding diesel's upstream
   extraction/refining/transport share and refreshing the grid factor. */
window.CO2 = {
  // Well-to-wheel: 2.56 kgCO2e/L tank-to-wheel (combustion) + 0.61 kgCO2e/L
  // well-to-tank (extraction/refining/transport, ~19% of total) = 3.17,
  // rounded to this platform's existing significant-figure convention.
  // Was 2.68 (tank-to-wheel only) — understated ICE's true footprint by
  // omitting its upstream share while the EV side already counted its own.
  diesel_kg_per_liter: 3.29,
  // Indonesia national grid average, 2023 (Ember/Statista; corroborated by
  // Low Carbon Power at ~0.625). Was 0.851 — traced to a ~2006-2008 vintage
  // JAMALI-system CDM/JCM "combined margin" baseline, which is a
  // deliberately conservative crediting benchmark, not a current national
  // consumption-average figure, and ~18 years stale.
  grid_kg_per_kwh: 0.68,
};

/* ============================================================
   computeTCO v2 — inflation-adjusted, per-vehicle PM, 8 rows
   ============================================================ */
window.computeTCO = function(rawS) {
  if (!rawS) return null;
  // v1.8 (§10.10 CALCULATION_ENGINE.md): resolve the Ritase-Cycle Engine's
  // derived annualKm/dailyMileageKm ONCE, here, before anything below reads
  // them -- every existing s.annualKm/s.dailyMileageKm consumer in this
  // function (energy, maintenance, CO2, battery-cycle-life, lifetime
  // totals, ton-km rate) then needs zero changes. Falls back to the raw
  // stored inputs untouched for ICE-only comparisons or when RD hasn't
  // been entered yet (see withDerivedOperation, computeRitaseCycle).
  const s = window.withDerivedOperation(rawS);
  const vA = window.findVeh(s.vehA);
  const vB = window.findVeh(s.vehB);
  if (!vA || !vB) return null;

  const priceA = s.priceA ?? vA.price;
  const priceB = s.priceB ?? vB.price;

  // Terrain — track profile (computed contour/variance, v1.8: also the
  // "computed" ritase-distance source, §10.6/10.10) overrides manual
  // terrain selection when enabled
  const trackProfile = s.trackProfile?.enabled ? window.computeTrackProfile(s.trackProfile) : null;
  const terrain       = trackProfile ? trackProfile.contour : s.terrainManual;
  const tm            = window.TERRAIN_MULTIPLIER[terrain] ?? 1.0; // unmodulated — used for maintenance (both powertrains)
  const tmIce         = trackProfile ? trackProfile.iceMultiplier : tm; // ICE energy — no regen capability
  const tmEv          = trackProfile ? trackProfile.evMultiplier  : tm; // EV energy — regen credit applied on choppy routes

  const { fleetSize, horizon } = s;
  const inflation = (s.inflation ?? 5) / 100;


  function infraForVeh(veh, nozzlesPerVehicle) {
    if (veh.powertrain !== "EV") return { capex: 0, opexAnnual: 0 };
    // Depot Design (Screen 4 -> Depot Design tab) is the live default source
    // once it has computed a result for the currently selected EV — wholesale
    // substitution (depot's own dollar totals, not merged field-by-field)
    // per DEPOT_INTEGRATION_HANDOVER.md §3.1/§3.2: avoids the charger
    // unit-of-account mismatch and the redundancy/growth-margin double-count
    // that a line-item merge would risk. Falls back to the Sizing Engine
    // below until Depot Design posts its first result.
    if (s.depotBom && typeof s.depotBom.tcoCapex === "number") {
      return { capex: s.depotBom.tcoCapex, opexAnnual: s.depotBom.tcoOpex };
    }
    // Sizing Engine fallback — the only other live path, now that the
    // legacy "simple per-category INFRA presets" toggle/tier (pre-V1.3) has
    // been removed; that toggle's OFF-state UI was deleted in the V1.3
    // EVCS rebuild and never replaced, so it was silently returning a
    // phantom $0 infra CAPEX whenever a user flipped it off pre-Depot-result.
    const sizing = window.computeSizing(s);
    // window.computeCapex resolves nozzlesPerVehicle itself from s.vehA/s.vehB
    // + s.nozzlesPerVehicleOverrideA/B (matching sizing.veh, the EV side) to
    // scale category-A dispenser/gun CAPEX — nozzlesPerVehicle here is the
    // same resolved value, kept as a vehicleCalc param per the EV-side
    // contract, not re-passed since computeCapex already derives it from s.
    const capexCalc = sizing ? window.computeCapex(s, sizing) : null;
    if (capexCalc) {
      // v1.7.7 Wave 3: electrical-only CAPEX (A+B), zero OPEX -- same rule
      // as Depot Design's BOM_INCLUDE, applied here for consistency so a
      // user who never opens Depot Design sees the same reality (Helio
      // bears Civil/Utility/Software CAPEX and 100% of EVCS OPEX).
      return { capex: capexCalc.tcoCapex, opexAnnual: 0 };
    }
    return { capex: 0, opexAnnual: 0 };
  }

  // Per-year cell override lookup — lets the user edit any single year's
  // Energy/AdBlue (Operation), Maintenance, Infra OPEX (Infrastructure), or
  // Financing (Financial) cost directly, overriding the computed default for
  // that one year only. Keyed "category.vehKey.year" in s.yearlyOverrides.
  function yearlyOverride(category, vehKey, year) {
    const v = (s.yearlyOverrides || {})[`${category}.${vehKey}.${year}`];
    return v != null ? v : null;
  }

  function vehicleCalc(veh, price, maintOverride, maintBasis, maintCycle, tyreTier, vehKey, energyOverride, nozzlesPerVehicle) {
    // Sunk-asset detection (v1.6) — permanently false as of v1.7.7. This
    // depended on `existingVehicleId` (Screen 1 "Existing Fleet Vehicle"),
    // which existed only for non-Greenfield project types; the platform is
    // Greenfield-only now, so that field and its Screen 1 UI are gone and
    // `s.existingVehicleId` is always undefined. Left as an inert constant
    // (not stripped from every downstream branch below) rather than surgically
    // removing every isSunkAsset conditional from this dense, already-tested
    // financial function for zero behavioral change — every branch that reads
    // it below now always takes the "not sunk" path.
    const isSunkAsset = vehKey === "A" && !!s.existingVehicleId && s.vehA === s.existingVehicleId;

    const { capex: infraCapexRaw, opexAnnual: infraOpexAnnualRaw } = isSunkAsset
      ? { capex: 0, opexAnnual: 0 }
      : infraForVeh(veh, nozzlesPerVehicle);

    // Energy/payload curve — unchanged by default (energyOverride or catalog
    // veh.energyNum, exactly as before). Only when useLfLmrRefinement is
    // explicitly turned on (Expert Mode, opt-in) does payload utilization
    // get derived from Load Factor x Loaded-Mile Ratio and fed through the
    // existing empty/full payload-energy interpolation — zero effect on any
    // already-validated comparison unless a user opts in.
    const baseEc = energyOverride ?? veh.energyNum;
    const ecNum = s.useLfLmrRefinement
      ? window.calcEcActual(baseEc, (s.loadFactorPct ?? 75) * (s.loadedMileRatioPct ?? 70) / 100, veh, s.usePhysicsPayloadFactors)
      : baseEc;

    // LF x LMR-consistent EC (v1.6.1) — computed unconditionally, regardless
    // of useLfLmrRefinement, so the expense-bucket model's ENERGY bucket and
    // its Rp/km / Rp/ton-km rates (whose ton-km denominator always assumes
    // explicit Load Factor x Loaded-Mile Ratio, see computeExpenseBuckets)
    // have an energy numerator computed on the same load basis, not
    // whatever payload assumption happens to be active for the headline
    // A-vs-B comparison.
    const ecNumLfLmr = window.calcEcActual(baseEc, (s.loadFactorPct ?? 75) * (s.loadedMileRatioPct ?? 70) / 100, veh, s.usePhysicsPayloadFactors);

    // CO2 emissions (v1.8) — uses the same `ecNum` as cost above (respects
    // energyOverride / LF×LMR refinement). Previously computed separately
    // from the catalog's raw `veh.energyNum`, silently diverging from
    // whatever energy assumption cost was actually using — this closes that
    // inconsistency. Physical consumption is inflation-independent
    // (inflation escalates price, not fuel/electricity quantity) and
    // constant across the horizon, matching the engine's no-fleet-growth
    // assumption elsewhere. `window.CO2.diesel_kg_per_liter` is well-to-wheel
    // (combustion + upstream extraction/refining/transport); AdBlue/urea
    // does not combust for CO2 and is correctly excluded.
    // NOT gated on isSunkAsset: that flag means "no new purchase / financing"
    // (correctly zeroes capex/finCost/infra/residual below), but a sunk-asset
    // vehicle is still driven and still burns real fuel/electricity every
    // year of the horizon -- its operational emissions are not "sunk" and
    // must be counted like any other vehicle's. (Fixed 2026-07-06: this was
    // previously zeroed here too, silently making every existing-incumbent
    // comparison show negative CO2 "reduction" regardless of the real EV vs
    // ICE emissions gap.)
    const annualCo2 = veh.powertrain === "EV"
      ? s.annualKm * fleetSize * ecNum * tmEv * window.CO2.grid_kg_per_kwh / 1000
      : s.annualKm * fleetSize * (ecNum / 100) * tmIce * window.CO2.diesel_kg_per_liter / 1000;
    const totalCo2 = annualCo2 * horizon;
    // Per-vehicle (fleet-size-independent) tons/km rate — used for the
    // carbon payback distance calc in computeTCO.
    const co2PerKm = s.annualKm > 0 ? annualCo2 / (s.annualKm * fleetSize) : 0;

    // Embodied manufacturing CO2 (v1.8, opt-in, default off) — one-time,
    // EV-only, uses veh.batteryKwh (already in the catalog) x a sourced
    // per-kWh factor. ICE embodied/glider manufacturing carbon is
    // deliberately left unmodeled (null, not 0) — no reliable per-vehicle
    // default exists for this catalog, and showing 0 would falsely imply
    // zero manufacturing footprint rather than "unknown."
    const embodiedCo2 = veh.powertrain === "EV"
      ? (s.includeEmbodiedCo2 && !isSunkAsset ? (veh.batteryKwh || 0) * fleetSize * (s.evBatteryMfgCo2PerKwh ?? 74) / 1000 : 0)
      : null;

    // Payment method is per-vehicle (v1.6.1) — paymentA/paymentB. v1.7.7:
    // "lease" removed entirely (along with the old Commercial Scheme
    // Ladder it shared formulas with) -- cash|loan only now. VKTR-borne
    // financing/expense-bearing is modeled by computeExpenseBuckets()
    // instead (see below computeTCO).
    const paymentMethod = s["payment" + vehKey] ?? "cash";

    // OTR (on-the-road price) — the real full fleet price, always available
    // for the expense-bucket model regardless of what the displayed `capex`
    // row shows (0 for a sunk asset — there's no purchase to count).
    const otr = isSunkAsset ? 0 : price * fleetSize;
    const capex = isSunkAsset ? 0 : otr;
    const infraCapex = infraCapexRaw;

    // Year-by-year OPEX with inflation
    let totalEnergy = 0;
    let totalAdblue = 0;
    let totalMaint  = 0;
    let totalInfraOpex = 0;
    let totalInsurance = 0;
    let totalBattery = 0;
    let totalEnergyLfLmr = 0;
    let totalAdblueLfLmr = 0;
    // Raw (pre-any-expense-reassignment) reference totals — still needed by
    // computeExpenseBuckets() to build the UNIT/FMC/INFRA/WARRANTY buckets
    // from the same validated aggregates this function already computes.
    // Name kept from the pre-v1.7.7 "pre-lease" era rather than renamed, to
    // minimize churn — the concept (raw totals before any scheme/bucket
    // reassignment) is the same.
    let preLeaseMaintTot = 0;
    let preLeaseInsuranceTot = 0;
    let preLeaseInfraOpexTot = 0;
    const annualOpex = [];
    const energyAnnual = [], adblueAnnual = [], maintAnnual = [], infraOpexAnnualArr = [], insuranceAnnual = [], batteryAnnual = [];
    // energyLfLmrAnnual/adblueLfLmrAnnual (v1.7.12) -- per-year twins of
    // totalEnergyLfLmr/totalAdblueLfLmr below, added so computeExpenseBuckets'
    // year-by-year customer cash flow (customerCumulative) can build its
    // ENERGY bucket contribution on the SAME LF x LMR-consistent basis the
    // bucket's own lifetime figure already uses (CALCULATION_ENGINE.md 2.10)
    // -- reusing energyAnnual/adblueAnnual instead would silently diverge
    // from bucketRaw.ENERGY's real total.
    const energyLfLmrAnnual = [], adblueLfLmrAnnual = [];

    // Battery replacement timing (v1.7.7 Wave 4) -- cycle-based, replacing
    // the old calendar SOH-threshold trigger. NOTE: batteryDegradationPctPerYear
    // / window.batteryHealthFraction are NOT retired -- they still drive
    // window.residualFractionSOH's residual-value curve, a separate, still-
    // valid use. A pack is assumed to need replacing once it accumulates
    // veh.batteryCycleLife (per-vehicle catalog spec, default 4000 if unset
    // -- editable in Screen 2 -> Vehicle Specifications) full charge cycles,
    // derived from this scenario's actual annual km and the vehicle's usable
    // range per cycle (same 20-80% SOC window computeChargingRequirement/
    // computeRitaseCycle assume elsewhere, kept consistent rather than
    // inventing a second DoD figure).
    const batteryHealthByYear = [];
    let batteryReplacementYear = null;
    let batteryCyclesPerYear = 0;
    if (veh.powertrain === "EV") {
      const usableSocFraction = 0.6; // 20-80% SOC, matches computeChargingRequirement/computeRitaseCycle
      const usableRangeKm = (ecNum > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / ecNum : 0;
      const cycleLifeStandard = veh.batteryCycleLife ?? 4000;
      batteryCyclesPerYear = usableRangeKm > 0 ? (s.annualKm || 0) / usableRangeKm : 0;
      for (let y = 1; y <= horizon; y++) {
        const cyclesUsed = batteryCyclesPerYear * y;
        batteryHealthByYear.push(Math.max(0, 1 - cyclesUsed / cycleLifeStandard));
        if (batteryReplacementYear == null && cyclesUsed >= cycleLifeStandard) batteryReplacementYear = y;
      }
    }

    for (let y = 1; y <= horizon; y++) {
      const inf = Math.pow(1 + inflation, y - 1);  // year 1 = no inflation

      // Energy
      let energy_y, adblue_y;
      if (veh.powertrain === "EV") {
        energy_y = s.annualKm * fleetSize * ecNum * tmEv * s.electricity * inf;
        if (s.demandChargeEnabled) {
          const dcp = window.findDemandChargeProfile(s.demandChargeProfileId);
          energy_y *= 1 + (dcp.wbpSharePct / 100) * (dcp.wbpMultiplier - 1);
        }
        adblue_y = 0;
      } else {
        const dieselL = s.annualKm * fleetSize * (ecNum / 100) * tmIce;
        const ec = window.iceEnergyCost(dieselL, s.diesel, s.adblue, s.adblueDose);
        energy_y = ec.dieselCost * inf;
        adblue_y = ec.adblueCost * inf;
      }
      energy_y = yearlyOverride("energy", vehKey, y) ?? energy_y;
      adblue_y = yearlyOverride("adblue", vehKey, y) ?? adblue_y;

      // LF x LMR-consistent energy shadow calc (v1.6.1, PPU tariff basis
      // only) — same formula shape as above, ecNumLfLmr instead of ecNum,
      // no yearly overrides applied (those are hand-edits to the main
      // A-vs-B scenario, not meaningful for a load-basis-consistent shadow).
      let energyLfLmr_y, adblueLfLmr_y;
      if (veh.powertrain === "EV") {
        energyLfLmr_y = s.annualKm * fleetSize * ecNumLfLmr * tmEv * s.electricity * inf;
        if (s.demandChargeEnabled) {
          const dcp = window.findDemandChargeProfile(s.demandChargeProfileId);
          energyLfLmr_y *= 1 + (dcp.wbpSharePct / 100) * (dcp.wbpMultiplier - 1);
        }
        adblueLfLmr_y = 0;
      } else {
        const dieselLLfLmr = s.annualKm * fleetSize * (ecNumLfLmr / 100) * tmIce;
        const ecLfLmr = window.iceEnergyCost(dieselLLfLmr, s.diesel, s.adblue, s.adblueDose);
        energyLfLmr_y = ecLfLmr.dieselCost * inf;
        adblueLfLmr_y = ecLfLmr.adblueCost * inf;
      }
      totalEnergyLfLmr += energyLfLmr_y;
      totalAdblueLfLmr += adblueLfLmr_y;
      energyLfLmrAnnual.push(energyLfLmr_y);
      adblueLfLmrAnnual.push(adblueLfLmr_y);

      // Maintenance
      let maint_y;
      if (maintOverride != null) {
        let baseYr;
        if (maintBasis === "month") {
          baseYr = maintOverride * 12;
        } else if (maintBasis === "perkm") {
          const cycle = maintCycle || 10000;
          baseYr = maintOverride * (s.annualKm / cycle);
        } else {
          baseYr = maintOverride;
        }
        maint_y = baseYr * fleetSize * inf;
      } else {
        maint_y = window.maintGroupBreakdownCostForYear(veh, s.annualKm, tyreTier, s.tyreTierPriceOverrides, s.maintGroupOverrides?.[vehKey], y) * fleetSize * inf;
      }
      maint_y = yearlyOverride("maintenance", vehKey, y) ?? maint_y;

      // Infra OPEX — uses the raw per-vehicle figure regardless of lease
      // type (needed to derive the Operating Lease rental rate below).
      const infOpex_y = yearlyOverride("infrastructure", vehKey, y) ?? (infraOpexAnnualRaw * inf);

      // Insurance — flat % of this vehicle's own purchase price per year, so
      // it naturally differs between A and B if their prices differ. Also
      // opt-in (default rate 0).
      const insuranceCostDefault = price * fleetSize * ((s.insuranceRatePct || 0) / 100) * inf;
      const insurance_y = yearlyOverride("insurance", vehKey, y) ?? insuranceCostDefault;

      // EV battery replacement — one-time cost injected the first year
      // battery health crosses the configured threshold, only when
      // explicitly enabled (opt-in, defaults to off like driver/insurance).
      const battery_y = (s.modelBatteryReplacement && y === batteryReplacementYear)
        ? price * ((s.batteryPackCostPct ?? 35) / 100) * fleetSize
        : 0;

      totalEnergy    += energy_y;
      totalAdblue    += adblue_y;
      totalBattery   += battery_y;
      preLeaseMaintTot     += maint_y;
      preLeaseInsuranceTot += insurance_y;
      preLeaseInfraOpexTot += infOpex_y;
      totalMaint     += maint_y;
      totalInfraOpex += infOpex_y;
      totalInsurance += insurance_y;

      energyAnnual.push(energy_y);
      adblueAnnual.push(adblue_y);
      maintAnnual.push(maint_y);
      infraOpexAnnualArr.push(infOpex_y);
      insuranceAnnual.push(insurance_y);
      batteryAnnual.push(battery_y);
      annualOpex.push((energy_y + adblue_y + battery_y + maint_y + infOpex_y + insurance_y) / 1e9);
    }

    // Residual — EV and ICE use different schedules (see window.residualFraction).
    // Always computed (shown as its own row and used by the Monthly Cost/Unit
    // depreciation KPI below), but only NETTED into Total TCO/Savings when
    // includeResidualInTco is explicitly turned on — off by default to match
    // VKTR's own TCOO convention of reporting cash cost only. Suppressed
    // entirely for a sunk asset — there's no fresh CAPEX to recover against,
    // so netting a residual credit against zero CAPEX would make a kept,
    // already-owned vehicle look artificially cheap. Computed before
    // financing (unlike pre-v1.9) — Finance Lease's balloon and Operating
    // Lease's RV-risk buffer both need it as an input.
    const residual = isSunkAsset ? 0 : price * fleetSize * window.residualFraction(horizon, veh.powertrain, s);

    // Financing / lease cost. Loan: unchanged flat/amortizing interest.
    // Finance Lease: down payment + amortized installments + balloon, with
    // residual netted here (not a second time in `tco` below) — otherwise
    // structurally identical to a loan (maintenance/insurance/infra stay
    // separate rows, unlike Operating Lease). Operating Lease: one bundled
    // rental (depreciation + interest + maintenance + insurance + infra +
    // RV-risk buffer, marked up) — the entire non-energy cost of running
    // this vehicle, which is why totalMaint/totalInsurance/totalInfraOpex/
    // infraCapex were zeroed above for this path.
    let finCost = 0;
    const finCostAnnual = [];
    if (isSunkAsset) {
      for (let y = 1; y <= horizon; y++) finCostAnnual.push(yearlyOverride("financing", vehKey, y) ?? 0);
    } else if (paymentMethod === "loan") {
      const principal = capex * (1 - s.downPayment / 100);
      if (s.loanInterestMethod === "amortizing") {
        const interestByYear = window.amortizingInterestByYear(principal, s.interest, s.tenor);
        for (let y = 1; y <= horizon; y++) {
          const idx = y - 1;
          const finDefault = idx < interestByYear.length ? interestByYear[idx] : 0;
          finCostAnnual.push(yearlyOverride("financing", vehKey, y) ?? finDefault);
        }
        finCost = interestByYear.reduce((a, b) => a + b, 0);
      } else {
        const finCostPerYear = principal * (s.interest / 100);
        for (let y = 1; y <= horizon; y++) {
          const finDefault = y <= s.tenor ? finCostPerYear : 0;
          finCostAnnual.push(yearlyOverride("financing", vehKey, y) ?? finDefault);
        }
        finCost = finCostPerYear * s.tenor;
      }
    } else {
      for (let y = 1; y <= horizon; y++) finCostAnnual.push(yearlyOverride("financing", vehKey, y) ?? 0);
    }

    // TCO (includes both energy + adblue). `includeResidualInTco` off by
    // default (matches VKTR's own TCOO convention of reporting cash cost
    // only) -- see the field's own DEFAULT_STATE comment.
    const tco = capex + finCost + totalEnergy + totalAdblue + totalMaint
              + infraCapex + totalInfraOpex + totalInsurance + totalBattery
              - (s.includeResidualInTco ? residual : 0);

    // Monthly cost per unit (depreciation + maintenance).
    const deprecMonthly = isSunkAsset ? 0 : (price * (1 - window.residualFraction(horizon, veh.powertrain, s))) / (horizon * 12);
    const maintMonthly  = totalMaint / (horizon * fleetSize * 12);
    const monthly       = deprecMonthly + maintMonthly;

    // Unit economics (v1.6) — powertrain-aware, always computed (not gated
    // behind any toggle). Tier 1 (allInRpKm) and the ton-km variant are
    // comparable across A and B; nativeEnergyUnit is NOT (Rp/L vs Rp/kWh
    // are different units) but matches how each operator type natively
    // thinks about their own energy cost.
    const totalKmLifetime = (s.annualKm || 0) * fleetSize * horizon;
    const allInRpKm = totalKmLifetime > 0 ? tco / totalKmLifetime : 0;
    const energyRpKm = totalKmLifetime > 0 ? (totalEnergy + totalAdblue) / totalKmLifetime : 0;
    const nativeEnergyUnit = veh.powertrain === "EV" ? { unit: "Rp/kWh", rate: s.electricity } : { unit: "Rp/L", rate: s.diesel };
    const payloadTon = ((veh.payload ?? 0) / 1000) * ((s.payloadPct ?? 50) / 100);
    const tonKmLifetime = totalKmLifetime * payloadTon;
    const rpTonKm = tonKmLifetime > 0 ? tco / tonKmLifetime : 0;

    return {
      allInRpKm, energyRpKm, nativeEnergyUnit, rpTonKm,
      capex, finCost, finCostAnnual, paymentMethod, otr,
      totalEnergy, totalAdblue, totalEnergyLfLmr, totalAdblueLfLmr,
      totalCo2, embodiedCo2, co2PerKm, ecNum,
      totalMaint, preLeaseMaintTot, preLeaseInsuranceTot, preLeaseInfraOpexTot, infraCapexRaw,
      infraCapex, totalInfraOpex,
      totalInsurance,
      totalBattery, batteryHealthByYear, batteryReplacementYear, batteryCyclesPerYear,
      residual, tco,
      annualOpex,
      energyAnnual, adblueAnnual, maintAnnual, infraOpexAnnualArr, insuranceAnnual, batteryAnnual,
      energyLfLmrAnnual, adblueLfLmrAnnual,
      monthly, isSunkAsset,
    };
  }

  const nozzlesPerVehicleA = s.nozzlesPerVehicleOverrideA ?? (window.EV_NOZZLE_COUNT[vA?.id] ?? 1);
  const nozzlesPerVehicleB = s.nozzlesPerVehicleOverrideB ?? (window.EV_NOZZLE_COUNT[vB?.id] ?? 1);
  const A = vehicleCalc(vA, priceA, s.maintOverrideA, s.maintOverrideBasisA, s.maintOverrideCycleA, s.tyreTierA, "A", s.energyOverrideA, nozzlesPerVehicleA);
  const B = vehicleCalc(vB, priceB, s.maintOverrideB, s.maintOverrideBasisB, s.maintOverrideCycleB, s.tyreTierB, "B", s.energyOverrideB, nozzlesPerVehicleB);

  // KPIs
  const savings      = B.tco - A.tco;  // B vs A: positive = B costs more than A (A is cheaper)
  const capexPremium = (A.capex + A.infraCapex) - (B.capex + B.infraCapex);

  /* Generalized payback: years for X's higher upfront cost (vs Y) to be
     recouped by X's lower annual OPEX (vs Y). Returns note "no_premium"
     when X is not more expensive upfront than Y, or "beyond_horizon"
     when the premium is never recouped within the analysis horizon. */
  function computePaybackPair(X, Y) {
    const upfrontDiff = (X.capex + X.infraCapex) - (Y.capex + Y.infraCapex);
    if (upfrontDiff <= 0) return { payback: null, note: "no_premium" };
    let cum = 0;
    for (let y = 1; y <= horizon; y++) {
      const yearSav = (Y.annualOpex[y-1] - X.annualOpex[y-1]) * 1e9
                    + (Y.finCostAnnual[y-1] - X.finCostAnnual[y-1]);
      const prev = cum;
      cum += yearSav;
      if (cum >= upfrontDiff) return { payback: (y - 1) + (upfrontDiff - prev) / yearSav, note: null };
    }
    return { payback: null, note: "beyond_horizon" };
  }

  // KPI framing: B vs A — years for B's incremental upfront cost (vs A)
  // to be recouped by A's lower annual OPEX (vs B).
  const { payback, note: paybackNote } = computePaybackPair(B, A);

  // Winner framing (used by takeaways / decision points): the cheaper-TCO
  // vehicle's incremental upfront cost (vs the other) recouped by its OPEX savings.
  const aWinsTco = A.tco < B.tco;
  const winnerCalc = aWinsTco ? A : B;
  const loserCalc  = aWinsTco ? B : A;
  const { payback: paybackWinner, note: paybackWinnerNote } = computePaybackPair(winnerCalc, loserCalc);

  // NPV at WACC
  function npvAt(r) {
    let npv = -capexPremium;
    for (let y = 1; y <= horizon; y++) {
      const yearSav = (B.annualOpex[y-1] - A.annualOpex[y-1]) * 1e9
                    + (B.finCostAnnual[y-1] - A.finCostAnnual[y-1]);
      npv += yearSav / Math.pow(1 + r, y);
    }
    return npv;
  }
  const npv = npvAt(s.wacc / 100);

  // IRR — binary search
  let irr = null;
  {
    let lo = -0.50, hi = 2.00;
    let npvLo = npvAt(lo), npvHi = npvAt(hi);
    if (npvLo === 0) irr = lo;
    else if (npvHi === 0) irr = hi;
    else if (npvLo * npvHi < 0) {
      for (let i = 0; i < 100; i++) {
        const mid    = (lo + hi) / 2;
        const npvMid = npvAt(mid);
        if (Math.abs(npvMid) < 0.0001 || (hi - lo) < 0.0001) { irr = mid; break; }
        if ((npvMid < 0) === (npvLo < 0)) { lo = mid; npvLo = npvMid; } else { hi = mid; }
      }
      if (irr === null) irr = (lo + hi) / 2;
    }
    if (irr !== null) irr = irr * 100;
  }

  // CO₂ — operational, well-to-wheel, per-vehicle lifetime emissions (tons).
  // v1.8: now sourced from A.totalCo2/B.totalCo2 (computed inside
  // vehicleCalc using the same ecNum as cost — respects energyOverride/LF×LMR
  // — rather than a separate calc reading the catalog's raw energyNum).
  const co2A = A.totalCo2;
  const co2B = B.totalCo2;
  const co2  = co2B - co2A; // B vs A: positive = B emits more than A

  // Life-cycle CO2 (v1.8, opt-in via includeEmbodiedCo2) — operational +
  // one-time embodied manufacturing carbon. ICE embodiedCo2 is null (not
  // modeled); treated as 0 for this sum, but the per-vehicle field stays
  // null so the UI can distinguish "zero" from "unknown."
  const lifeCycleCo2A = co2A + (A.embodiedCo2 || 0);
  const lifeCycleCo2B = co2B + (B.embodiedCo2 || 0);

  // Carbon payback distance (v1.8) — km until B's lower per-km operational
  // CO2 offsets its own embodied manufacturing footprint (ICCT-style
  // crossover framing: BEVs typically break even within ~17,000 km despite
  // ~40% higher production emissions). null = B's operational CO2/km is not
  // lower than A's, so embodied carbon never gets offset under these inputs
  // — a genuine possible outcome, not an error (see the P048/P133 case).
  const co2PerKmDelta = A.co2PerKm - B.co2PerKm;
  const carbonPaybackKm = (co2PerKmDelta > 0 && B.embodiedCo2 > 0)
    ? (B.embodiedCo2 / (fleetSize || 1)) / co2PerKmDelta
    : null;

  // Cumulative total project cost (CAPEX + financing + OPEX, including
  // infra CAPEX/OPEX for the EV), Year 0..horizon — for the Screen 6
  // cumulative cost chart. Residual value is NOT subtracted, since it
  // is only realized at end-of-life and would distort the running total.
  function buildCumulative(X) {
    const arr = [X.capex + X.infraCapex + X.finCost];
    for (let y = 0; y < horizon; y++) {
      arr.push(arr[arr.length - 1] + X.annualOpex[y] * 1e9);
    }
    return arr;
  }
  const cumulativeA = buildCumulative(A);
  const cumulativeB = buildCumulative(B);

  return {
    A, B,
    savings, payback, paybackNote, paybackWinner, paybackWinnerNote,
    npv, irr, co2, co2A, co2B, aWinsTco,
    lifeCycleCo2A, lifeCycleCo2B, carbonPaybackKm,
    monthlyA: A.monthly,
    monthlyB: B.monthly,
    rows: [
      { en: "Unit Purchase Price",      id: "Harga Pembelian Unit",          a: A.capex,          b: B.capex          },
      { en: "Financing Cost",           id: "Biaya Pembiayaan",              a: A.finCost,        b: B.finCost        },
      { en: "Lifetime Energy Cost",     id: "Biaya Energi Seumur Hidup",     a: A.totalEnergy,    b: B.totalEnergy    },
      { en: "Lifetime AdBlue Cost",     id: "Biaya AdBlue Seumur Hidup",     a: A.totalAdblue,    b: B.totalAdblue    },
      { en: "Lifetime Maintenance",     id: "Biaya Perawatan Seumur Hidup",  a: A.totalMaint,     b: B.totalMaint     },
      { en: "Lifetime Insurance Cost",  id: "Biaya Asuransi Seumur Hidup",    a: A.totalInsurance, b: B.totalInsurance },
      { en: "Battery Replacement Cost (EV)", id: "Biaya Penggantian Baterai (EV)", a: A.totalBattery, b: B.totalBattery },
      { en: "Infra CAPEX",              id: "CAPEX Infrastruktur",           a: A.infraCapex,     b: B.infraCapex     },
      { en: "Infra OPEX",               id: "OPEX Infrastruktur",            a: A.totalInfraOpex, b: B.totalInfraOpex },
      { en: s.includeResidualInTco ? "Residual Value (−)" : "Residual Value (reference only, not in Total TCO)",
        id: s.includeResidualInTco ? "Nilai Sisa (−)" : "Nilai Sisa (referensi saja, tidak dihitung di Total TCO)",
        a: -A.residual, b: -B.residual, residual: true },
    ],
    annualA: A.annualOpex,
    annualB: B.annualOpex,
    cumulativeA, cumulativeB,
    yearlyA: { energy: A.energyAnnual, adblue: A.adblueAnnual, maintenance: A.maintAnnual, infrastructure: A.infraOpexAnnualArr, financing: A.finCostAnnual, insurance: A.insuranceAnnual },
    yearlyB: { energy: B.energyAnnual, adblue: B.adblueAnnual, maintenance: B.maintAnnual, infrastructure: B.infraOpexAnnualArr, financing: B.finCostAnnual, insurance: B.insuranceAnnual },
  };
};

/* ============================================================
   RESULT SANITY GUARD (v1.7.5)
   ------------------------------------------------------------
   A class of bug no test suite catches because it only manifests on some
   specific real input combination: computeTCO() producing a number that's
   technically a valid JS value but obviously wrong to show a customer
   (NaN, a negative total cost, savings bigger than either vehicle's own
   total). Runs on every Results render, surfaces visibly in the UI (not
   just console) -- see Screen6 in report.jsx.
   ============================================================ */
// Deterministic, non-cryptographic checksum (v1.7.5) for profile export/
// import integrity -- catches accidental corruption or a hand-edited file
// with a stray typo, NOT a security/tamper-proofing measure (that's not
// the threat model for a JSON file a user exports to their own disk).
// Plain 32-bit rolling hash, no Web Crypto dependency, so it stays
// synchronous and usable from a plain FileReader callback.
window.simpleChecksum = function(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = (hash << 5) - hash + str.charCodeAt(i);
    hash |= 0;
  }
  return (hash >>> 0).toString(16);
};

window.checkResultSanity = function(R, s) {
  const issues = [];
  if (!R || !R.rows) return issues;
  const totalA = R.rows.reduce((sum, r) => sum + ((r.residual && !s.includeResidualInTco) ? 0 : r.a), 0);
  const totalB = R.rows.reduce((sum, r) => sum + ((r.residual && !s.includeResidualInTco) ? 0 : r.b), 0);

  if (!Number.isFinite(totalA) || !Number.isFinite(totalB)) {
    issues.push({ sev: "error", en: "Total TCO computed as NaN or Infinity — one of the input fields is likely invalid or missing.", id: "Total TCO dihitung sebagai NaN atau Infinity — salah satu bidang input kemungkinan tidak valid atau kosong." });
    return issues; // downstream checks are meaningless once this fires
  }
  if (totalA < 0 || totalB < 0) {
    issues.push({ sev: "error", en: "Total TCO is negative for one of the vehicles — check residual value and financing inputs.", id: "Total TCO negatif untuk salah satu kendaraan — periksa input nilai sisa dan pembiayaan." });
  }
  const savings = Math.abs(totalA - totalB);
  const larger = Math.max(totalA, totalB);
  if (larger > 0 && savings > larger) {
    issues.push({ sev: "warn", en: "Savings exceeds either vehicle's own total TCO — double-check fleet size, horizon, and price inputs.", id: "Penghematan melebihi total TCO salah satu kendaraan — periksa kembali jumlah armada, horizon, dan input harga." });
  }
  if (R.npv != null && !Number.isFinite(R.npv)) {
    issues.push({ sev: "warn", en: "NPV computed as NaN or Infinity.", id: "NPV dihitung sebagai NaN atau Infinity." });
  }
  if (R.co2A < 0 || R.co2B < 0) {
    issues.push({ sev: "warn", en: "Lifecycle CO2 is negative for one of the vehicles.", id: "CO2 siklus hidup negatif untuk salah satu kendaraan." });
  }
  return issues;
};

/* ============================================================
   CUSTOMER/VKTR EXPENSE-BUCKET MODEL (v1.7.7) — replaces the old
   Commercial Scheme Ladder (Jual Putus/Operating Lease/Finance Lease/MARC/
   Pay-per-Use) and the "lease" payment method entirely. Payment method is
   cash|loan only now.
   ============================================================
   Six cost buckets per vehicle, each independently assignable to whoever
   bears that expense (customer or VKTR), with a per-bucket markup applied
   only on the VKTR-borne side. Source: LEASING_MODEL_REDESIGN_PROPOSAL.md
   (References/Markdowns/), built from Rija's own framing -- "what VKTR
   bears vs. what the customer pays," relating it to VKTR's real
   pay-per-use + Full Maintenance Contract (FMC) posture.

   UNIT      — capital: (OTR - RV) + financingInterestFull. Unconditional
               (not gated by the vehicle's real cash/loan payment method) --
               this bucket prices the capital itself, independent of how
               it happens to be financed on Screen 2.
   FMC       — scheduled maintenance only (preLeaseMaintTot -- same PM
               schedule/parts-breakdown/override priority as the main TCO).
   INFRA     — charging depot CAPEX+OPEX (EV only, 0 for ICE).
   WARRANTY  — unscheduled repairs + battery replacement. NEW cost concept
               -- neither existing maintenance formula (maintCostForYear,
               maintBreakdownAnnualCost) models unscheduled/out-of-cycle
               repairs, only scheduled service. unscheduledRepairReserve
               = OTR x unscheduledRepairReservePctOfOtr%/yr x horizon, a
               placeholder rate (reuses the old MARC formula's 1.5%
               battery-risk rate as a starting anchor, applied to the full
               OTR instead of just the battery-weighted share) -- flagged
               pending real VKTR service/warranty-claims data, the single
               most unvalidated number in this whole model.
   ENERGY    — fuel/electricity, LF x LMR-consistent (V.totalEnergyLfLmr/
               totalAdblueLfLmr, same load-basis-consistent total the old
               PPU scheme used).
   INSURANCE — preLeaseInsuranceTot.

   All six are independently toggleable (customer|vktr per bucket) --
   default customer for UNIT/FMC/INFRA/WARRANTY, default vktr for
   ENERGY/INSURANCE (explicit product decision, not the proposal doc's own
   suggested default of always-customer for these two -- see DEFAULT_STATE
   comment). Each carries its own markupPct, applied only when that bucket
   is VKTR-borne.

   customerDirect        = sum of customer-borne buckets, no markup
   vktrBorneRaw          = sum of VKTR-borne buckets, pre-markup (VKTR's
                           own real cost exposure for what it's taken on)
   vktrBorneLoaded       = sum of VKTR-borne buckets x (1+markup) (what
                           VKTR actually charges the customer for them)
   customerTotalPayment  = customerDirect + vktrBorneLoaded -- the
                           customer's real total out-of-pocket spend,
                           whether paid directly or paid to VKTR as a
                           marked-up rate. This is the headline number
                           (Results screen shows ONLY this, per explicit
                           direction -- not a blended platform-vs-customer
                           total that would understate what VKTR earns).
   The Results side-by-side chart shows customerDirect vs. vktrBorneRaw --
   the risk/cost-EXPOSURE split (who is financially on the hook for which
   bucket), which is the whole point of this redesign, distinct from the
   headline's who-writes-the-check total.
   ============================================================ */
window.EXPENSE_BUCKET_KEYS = ["UNIT", "FMC", "INFRA", "WARRANTY", "ENERGY", "INSURANCE"];

window.EXPENSE_BUCKET_LABELS = {
  // v1.9.11: "Unit (Capital)" was reading as the raw purchase price to
  // users comparing it against Vehicle Selection's "Buying Price" -- it's
  // actually (OTR - residual/resale value) + financing cost, i.e. net
  // capital exposure after resale credit, always smaller than OTR. Label
  // + UNIT_NOTE below make that explicit instead of silently differing.
  UNIT:      { en: "Unit (Capital, net of resale)", id: "Unit (Modal, bersih dari nilai jual kembali)" },
  FMC:       { en: "FMC (Scheduled Maintenance)", id: "FMC (Perawatan Terjadwal)" },
  INFRA:     { en: "Infra (Charging Depot)", id: "Infra (Depot Pengisian)" },
  WARRANTY:  { en: "Warranty (Unscheduled Repairs + Battery)", id: "Garansi (Perbaikan Tak Terjadwal + Baterai)" },
  ENERGY:    { en: "Energy (Fuel/Electricity)", id: "Energi (BBM/Listrik)" },
  INSURANCE: { en: "Insurance", id: "Asuransi" },
};

// Per-bucket hover explainer for anything whose "Lifetime (fleet)" number
// isn't a simple raw sum a user could sanity-check against another screen
// at a glance. Only UNIT needs one today (see EXPENSE_BUCKET_LABELS above).
window.EXPENSE_BUCKET_NOTES = {
  UNIT: {
    en: "(On-the-road price x fleet size) plus financing cost -- matches the fresh Buying Price shown on Vehicle Selection by default. Turn on \"Net Resale Value into Unit (Capital)\" below to credit an estimated resale value as an optional relief.",
    id: "(Harga OTR x jumlah armada) ditambah biaya pembiayaan -- sesuai dengan Harga Beli baru yang ditampilkan di Pemilihan Kendaraan secara default. Aktifkan \"Kurangkan Nilai Jual Kembali dari Unit (Modal)\" di bawah untuk mengkreditkan estimasi nilai jual kembali sebagai keringanan opsional.",
  },
};

// Preset buttons -- explicit full overrides, not just a display of the
// default. "Beli Putus" resets all six to customer even though the real
// DEFAULT_STATE ships ENERGY/INSURANCE as vktr, so the preset always means
// exactly what its name says regardless of where a user's state currently is.
window.EXPENSE_BUCKET_PRESETS = [
  { id: "beli_putus", en: "Beli Putus", idLbl: "Beli Putus",
    desc: { en: "Customer owns and bears everything.", id: "Pelanggan memiliki dan menanggung semuanya." },
    state: { UNIT: "customer", FMC: "customer", INFRA: "customer", WARRANTY: "customer", ENERGY: "customer", INSURANCE: "customer" } },
  { id: "sewa_unit", en: "Sewa Unit", idLbl: "Sewa Unit",
    desc: { en: "VKTR carries the capital only.", id: "VKTR menanggung modal saja." },
    state: { UNIT: "vktr", FMC: "customer", INFRA: "customer", WARRANTY: "customer", ENERGY: "customer", INSURANCE: "customer" } },
  { id: "fmc", en: "FMC", idLbl: "FMC",
    desc: { en: "The real, most common current arrangement — customer owns the unit, VKTR runs maintenance.", id: "Pengaturan riil yang paling umum saat ini — pelanggan memiliki unit, VKTR menjalankan perawatan." },
    state: { UNIT: "customer", FMC: "vktr", INFRA: "customer", WARRANTY: "customer", ENERGY: "customer", INSURANCE: "customer" } },
  { id: "sewa_fmc_infra", en: "Sewa + FMC + Infra", idLbl: "Sewa + FMC + Infra",
    desc: { en: "VKTR carries capital, maintenance, and the charging depot.", id: "VKTR menanggung modal, perawatan, dan depot pengisian." },
    state: { UNIT: "vktr", FMC: "vktr", INFRA: "vktr", WARRANTY: "customer", ENERGY: "customer", INSURANCE: "customer" } },
  { id: "emaas_penuh", en: "e-MaaS Penuh", idLbl: "e-MaaS Penuh",
    desc: { en: "The full stack VKTR can offer — everything VKTR-borne.", id: "Stack penuh yang dapat ditawarkan VKTR — semuanya ditanggung VKTR." },
    state: { UNIT: "vktr", FMC: "vktr", INFRA: "vktr", WARRANTY: "vktr", ENERGY: "vktr", INSURANCE: "vktr" } },
];

window.computeExpenseBuckets = function(s, vehKey) {
  const key = vehKey === "A" ? "A" : "B";
  const R = window.computeTCO(s);
  if (!R) return null;
  const V = key === "A" ? R.A : R.B;
  const veh = window.findVeh(key === "A" ? s.vehA : s.vehB);
  if (!V || !veh) return null;

  const horizon = s.horizon || 5;
  const fleetSize = s.fleetSize || 1;
  const OTR = V.otr;
  const RV = V.residual;

  const unscheduledRepairReserve = OTR * (s.unscheduledRepairReservePctOfOtr ?? 1.5) / 100 * horizon;

  // UNIT's financing component was previously a second, ungated interest
  // calculation (principal x rate x horizon) that applied even when the
  // vehicle's real payment method was Cash -- fixed, v1.7.7 Wave 4: reuse
  // V.finCost, the same real, already-correctly-gated (loan tenor,
  // amortizing/flat per s.loanInterestMethod) interest figure the base TCO
  // engine computes once. Cash -> V.finCost is 0.
  //
  // v1.9.12: resale value is no longer netted unconditionally -- UNIT
  // defaults to the fresh buying price (OTR) + financing only, so it reads
  // consistently with the Buying Price shown on Vehicle Selection. Netting
  // an estimated resale value is opt-in (applyResaleReliefInUnit), with
  // resaleValueOverride{A,B} letting the user substitute their own number
  // for the auto-computed residual value (RV) as the relief amount.
  const resaleOverride = s[`resaleValueOverride${key}`];
  const resaleRelief = s.applyResaleReliefInUnit ? (resaleOverride ?? RV) : 0;
  const bucketRaw = {
    UNIT: (OTR - resaleRelief) + V.finCost,
    FMC: V.preLeaseMaintTot,
    INFRA: V.infraCapexRaw + V.preLeaseInfraOpexTot,
    WARRANTY: unscheduledRepairReserve + V.totalBattery,
    ENERGY: V.totalEnergyLfLmr + V.totalAdblueLfLmr,
    INSURANCE: V.preLeaseInsuranceTot,
  };
  const total = window.EXPENSE_BUCKET_KEYS.reduce((sum, k) => sum + bucketRaw[k], 0);

  const state = { ...window.DEFAULT_STATE.expenseBucketState, ...(s.expenseBucketState || {}) };
  const markup = { ...window.DEFAULT_STATE.expenseBucketMarkupPct, ...(s.expenseBucketMarkupPct || {}) };

  const buckets = {};
  let customerDirect = 0, vktrBorneRaw = 0, vktrBorneLoaded = 0;
  for (const k of window.EXPENSE_BUCKET_KEYS) {
    const raw = bucketRaw[k];
    const isVktr = (state[k] || "customer") === "vktr";
    const mk = (markup[k] || 0) / 100;
    const loaded = isVktr ? raw * (1 + mk) : raw;
    buckets[k] = { raw, bearer: isVktr ? "vktr" : "customer", markupPct: markup[k] || 0, loaded };
    if (isVktr) { vktrBorneRaw += raw; vktrBorneLoaded += loaded; }
    else customerDirect += raw;
  }
  const totKm = (s.annualKm || 0) * fleetSize * horizon;
  const lfPct = (s.loadFactorPct ?? 75) / 100, lmrPct = (s.loadedMileRatioPct ?? 65) / 100;
  const pmaxTon = (veh.payload ?? 7000) / 1000;
  const effTonKm = totKm * lmrPct * pmaxTon * lfPct;
  const avgSpeedKmh = s.avgOperatingSpeedKmh || 25;
  const totHours = avgSpeedKmh > 0 ? totKm / avgSpeedKmh : 0;

  const perKm = (v) => totKm > 0 ? v / totKm : 0;
  const perTonKm = (v) => effTonKm > 0 ? v / effTonKm : 0;
  const perHour = (v) => totHours > 0 ? v / totHours : 0;
  const rateSet = (v) => ({ km: perKm(v), tonKm: perTonKm(v), hour: perHour(v) });

  // v1.7.7 Wave 4 -- two distinct annual cash-outflow streams for the
  // customer:
  //  - loanAnnual: financing the customer-borne UNIT purchase (0 if Cash),
  //    evenly spread across the horizon.
  //  - subscriptionAnnual: the "rent" the customer pays VKTR for whichever
  //    buckets VKTR carries (loaded with markup) -- conceptually starts
  //    once the vehicle is delivered, which is already what "year 1" of
  //    this horizon represents (CAPEX/OTR is the one upfront, pre-year-1
  //    event; every other stream here already runs post-delivery).
  // If payment method is Loan AND at least one bucket is VKTR-borne, both
  // streams are genuinely concurrent (not a double-charge) -- reported
  // separately so that's visible, not hidden inside one blended number.
  const loanAnnual = horizon > 0 ? V.finCost / horizon : 0;

  // Subscription pricing (v1.7.8): a distinct cost-recovery term, NOT the
  // TCO horizon -- VKTR prices the subscription to recover its full
  // VKTR-borne cost basis (vktrBorneLoaded) within subscriptionTermYears
  // (default 5, independently editable, Screen 5). Once the term is up the
  // subscription renews at the SAME annual rate for the rest of the
  // horizon, since VKTR still owns/carries those buckets -- so every year
  // beyond the term is pure margin for VKTR, by design (Rija's own framing:
  // "going more than that, VKTR will benefit"). The term is expected to be
  // <= horizon (surfaced as a validation WarnHint on Screen 5 if not) --
  // if it isn't, this is still well-defined (VKTR just hasn't broken even
  // within this particular comparison window), not a divide-by-zero risk
  // since subscriptionTermYears is never allowed to reach 0 here.
  const subscriptionTermYears = Math.max(1, s.subscriptionTermYears || 5);
  const subscriptionAnnual = vktrBorneLoaded / subscriptionTermYears;

  // Customer's real total lifetime payment now follows the subscription's
  // actual renewing revenue schedule (subscriptionAnnual x horizon), not
  // the raw vktrBorneLoaded cost-basis figure -- those two diverge once
  // horizon > subscriptionTermYears (renewal years are pure VKTR margin,
  // still charged to and paid by the customer). vktrBorneLoaded itself is
  // untouched below (buckets/vktrBorneRaw/vktrBorneLoaded still describe
  // VKTR's own cost/liability, a different, still-valid question from what
  // the customer is actually billed -- see "Who Bears What", Results).
  const customerTotalPayment = customerDirect + subscriptionAnnual * horizon;

  // ---- v1.7.12: year-by-year customer cash flow ----
  // Bucket-aware twin of R.cumulativeA/B (data.jsx buildCumulative, the
  // Results "Total Cumulative Project Cost" chart's source) -- same Year 0
  // (upfront) + Year 1..horizon (opex) shape, but partitioned by who
  // actually bears each bucket, with subscriptionAnnual folded into every
  // operating year for whichever buckets are VKTR-borne, instead of always
  // showing the full raw ownership cost regardless of the commercial
  // structure. Closes the gap flagged in report.jsx since v1.7.7 Wave 2
  // ("NPV/IRR/Payback/the cumulative chart are not Expense-Bucket-aware
  // yet") for the chart and the Side-by-Side Comparison summary table only,
  // by explicit scope decision -- NPV/IRR/Payback stay on raw ownership TCO.
  // Reuses vehicleCalc's own per-year arrays (V.maintAnnual etc) rather than
  // re-deriving them, so this can only diverge from the bucketRaw lifetime
  // totals above by a real construction bug, not by drifting onto a
  // different data source.
  const isCustomerBorne = (k) => (state[k] || "customer") !== "vktr";
  const unscheduledReservePerYear = OTR * (s.unscheduledRepairReservePctOfOtr ?? 1.5) / 100;
  const customerYear0 =
      (isCustomerBorne("UNIT")  ? bucketRaw.UNIT : 0)
    + (isCustomerBorne("INFRA") ? V.infraCapexRaw : 0);
  const customerAnnual = [];
  for (let y = 0; y < horizon; y++) {
    customerAnnual.push(
        (isCustomerBorne("ENERGY")    ? (V.energyLfLmrAnnual[y] + V.adblueLfLmrAnnual[y]) : 0)
      + (isCustomerBorne("FMC")       ? V.maintAnnual[y] : 0)
      + (isCustomerBorne("INFRA")     ? V.infraOpexAnnualArr[y] : 0)
      + (isCustomerBorne("WARRANTY")  ? (unscheduledReservePerYear + V.batteryAnnual[y]) : 0)
      + (isCustomerBorne("INSURANCE") ? V.insuranceAnnual[y] : 0)
      + subscriptionAnnual  // 0 whenever nothing is VKTR-borne (vktrBorneLoaded is then 0 too)
    );
  }
  const customerCumulative = [customerYear0];
  for (let y = 0; y < horizon; y++) customerCumulative.push(customerCumulative[customerCumulative.length - 1] + customerAnnual[y]);

  // customerRowsBreakdown -- bucket-aware twin of report.jsx's 4-category
  // Side-by-Side Comparison rollup (sbsRowsBase). Residual value is NOT a
  // separate line here (unlike the raw-TCO table's conditional Residual
  // row) -- when applyResaleReliefInUnit is on, bucketRaw.UNIT already nets
  // the relief amount, so adding a second residual line would double-count
  // it; when off (the default), there's simply no relief to show. Sums to exactly
  // customerTotalPayment: verified by construction (every bucket appears
  // in exactly one of the 3 cost rows below, gated the same way
  // customerDirect itself is gated above).
  const customerRowsBreakdown = {
    upfrontPrice: (isCustomerBorne("UNIT") ? bucketRaw.UNIT : 0) + (isCustomerBorne("INFRA") ? V.infraCapexRaw : 0),
    energyLifetime: isCustomerBorne("ENERGY") ? bucketRaw.ENERGY : 0,
    maintOtherOpex:
        (isCustomerBorne("FMC") ? bucketRaw.FMC : 0)
      + (isCustomerBorne("INFRA") ? V.preLeaseInfraOpexTot : 0)
      + (isCustomerBorne("WARRANTY") ? bucketRaw.WARRANTY : 0)
      + (isCustomerBorne("INSURANCE") ? bucketRaw.INSURANCE : 0),
    subscriptionPayment: subscriptionAnnual * horizon,
  };

  return {
    vehKey: key, veh, OTR, RV, resaleRelief, horizon, totKm, effTonKm, totHours,
    buckets, total,
    customerDirect, vktrBorneRaw, vktrBorneLoaded, customerTotalPayment,
    loanAnnual, subscriptionAnnual, subscriptionTermYears,
    customerCumulative, customerAnnual, customerRowsBreakdown,
    rates: {
      total: rateSet(total),
      customerTotalPayment: rateSet(customerTotalPayment),
      customerDirect: rateSet(customerDirect),
      vktrBorneRaw: rateSet(vktrBorneRaw),
      vktrBorneLoaded: rateSet(vktrBorneLoaded),
    },
  };
};

/* ============================================================
   v1.3 — Infrastructure Sizing & CAPEX Engine (§7.8 / §7.9)
   ============================================================ */

window.getEvVehicle = function(s) {
  const vA = window.findVeh(s.vehA);
  const vB = window.findVeh(s.vehB);
  return [vA, vB].find(v => v && v.powertrain === "EV") || null;
};

// EC actual (kWh/km), payload-weighted -- shared by computeSizing and
// computeRitaseCycle's VR (usable range) derivation, v1.8, so the two
// engines can't drift onto different EC bases.
window.computeEcActual = function(s, veh) {
  const payloadFactors = window.resolvePayloadFactors(veh, s.usePhysicsPayloadFactors);
  const ecEmptyDefault = veh.energyNum * payloadFactors.emptyFactor;
  const ecFullDefault  = veh.energyNum * payloadFactors.fullFactor;
  const ecEmpty = s.ecEmptyOverride ?? ecEmptyDefault;
  const ecFull  = s.ecFullOverride  ?? ecFullDefault;
  const payloadPct = s.payloadPct ?? 50;
  const ecActual = ecEmpty + (ecFull - ecEmpty) * (payloadPct / 100);
  return { ecEmpty, ecFull, ecEmptyDefault, ecFullDefault, ecActual, payloadPct };
};

// Ritase distance (RD) resolution -- the "computed" KML/KMZ/OSM track
// profile (§10.10 CALCULATION_ENGINE.md) takes priority over the manual
// input when enabled, same precedence terrain already uses.
window.resolveRitaseDistanceKm = function(s) {
  if (s.trackProfile?.enabled && s.trackProfile.distanceKm > 0) return s.trackProfile.distanceKm;
  return Math.max(0, s.ritaseDistanceKm || 0);
};

// v1.8.2 (2026-07-18) -- Charging Requirement Engine. SS (chargeSessionMinutes,
// the scheduled group charging shift) is now the primary user input
// (Screen 4's Charging Strategy card) -- REVERSES the pre-v1.8.2 direction,
// where the user picked a charger rating and the platform estimated how
// long a session would take (the old computeChargingSession/
// CHARGE_CURVE_STAGES pair, removed here, now fully superseded). Given SS
// and this vehicle's battery spec, back-solves the charging POWER required
// to hit a 20%->80% SOC top-up within that session, then picks the
// smallest charging type/rating (from standard tiers) -- adding a second
// nozzle only if even the largest single-nozzle tier (360kW DC Fast) can't
// deliver enough power in time. Capped at 2 nozzles/vehicle, matching
// depot_floorplan's own DISPENSER_NOZZLE_CAP (real commercial DC-charger
// cabinets max out at 2 nozzles each) -- see render.js.
window.CHARGING_RATING_TIERS = [
  { type: "ac",     kw: 7   }, { type: "ac",     kw: 11  }, { type: "ac",     kw: 22  },
  { type: "dc",     kw: 40  }, { type: "dc",     kw: 60  }, { type: "dc",     kw: 80  }, { type: "dc", kw: 120 },
  { type: "dcfast", kw: 150 }, { type: "dcfast", kw: 180 }, { type: "dcfast", kw: 240 }, { type: "dcfast", kw: 360 },
];
window.computeChargingRequirement = function(s, veh) {
  if (!veh || !veh.batteryKwh) return null;
  const ssMinutes = Math.max(1, s.chargeSessionMinutes || 90);
  const usableSocFraction = 0.6; // 20-80% SOC, matches computeRitaseCycle's VR/battery-cycle-life basis
  const efficiency = s.chargingEfficiencyOverride ?? 0.92;
  const energyNeededKwh = veh.batteryKwh * usableSocFraction;
  const requiredKw = efficiency > 0 ? (energyNeededKwh / (ssMinutes / 60)) / efficiency : 0;

  const MAX_NOZZLES = 2;
  let nozzlesPerVehicle = 1, chosen = null;
  for (; nozzlesPerVehicle <= MAX_NOZZLES; nozzlesPerVehicle++) {
    chosen = window.CHARGING_RATING_TIERS.find(t => t.kw * nozzlesPerVehicle >= requiredKw);
    if (chosen) break;
  }
  const achievable = !!chosen;
  if (!chosen) { chosen = window.CHARGING_RATING_TIERS[window.CHARGING_RATING_TIERS.length - 1]; nozzlesPerVehicle = MAX_NOZZLES; }

  // Voltage: DC Fast tiers (>=150kW) typically need a Medium Voltage grid
  // connection at real sites -- engineering heuristic, not a computed
  // electrical-code threshold, flagged for validation like every other
  // assumption-sourced constant in this engine.
  const voltageLevel = chosen.kw >= 150 ? "mv" : "lv";

  return {
    ssMinutes, requiredKw, energyNeededKwh, efficiency, achievable,
    chargingType: chosen.type, chargerRatingKw: chosen.kw, nozzlesPerVehicle, voltageLevel,
  };
};

/* ============================================================
   v1.8 (2026-07-17) — Ritase-Cycle Charging Strategy Engine
   (§10 CALCULATION_ENGINE.md). Replaces the v1.7.8 shift/fixed/opportunity
   Fleet Plan model entirely with one unified model: the day (TCW = 1440 -
   CD) divides into Z_CC repeating charging cycles; each cycle contains
   Z_TG fleet-wide, FIXED scheduled-charging groups' sequential SS slots
   plus a trailing US unscheduled/opportunistic block. A vehicle's
   charge-trigger is driven by real ritase physics (route distance vs.
   battery range), not a flat daily-mileage input. Both feasibility gates
   (cycle count vs. drive+charge time; group count vs. slots that fit) are
   independent and validated separately (§10.3/§10.7) -- see
   CALCULATION_ENGINE.md §10 for the full spec, agreed with Rija
   2026-07-16/17.
   ============================================================ */
window.computeRitaseCycle = function(s) {
  const veh = window.getEvVehicle(s);
  if (!veh) return null; // ICE-only comparisons: annualKm/dailyMileageKm stay raw inputs, §10.10

  const RD = window.resolveRitaseDistanceKm(s);
  if (RD <= 0) return null; // not yet entered -- callers fall back to raw inputs

  let { ecActual } = window.computeEcActual(s, veh);
  const usableSocFraction = 0.6; // 20-80% SOC, matches computeChargingSession/battery-cycle-life basis
  let VR = (ecActual > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / ecActual : 0;
  // v1.9.6 (2026-07-17): self-heals a stale/corrupted Expert Mode EC
  // override -- one left over from a PREVIOUSLY-selected EV (the exact
  // staleness the v1.9.3 pickVeh fix now prevents going forward, but
  // doesn't retroactively repair) can sit in a saved profile/preset/
  // import indefinitely, since neither is a DEFAULT_STATE shape change
  // that would trigger a reset. If applying the override makes THIS
  // vehicle's range impossible, it's not a legitimate real-world
  // calibration value for this vehicle -- fall back to the catalog
  // default instead of permanently blocking Annual Mileage/Charging
  // Strategy. Runs on every computation (every render, every screen),
  // not just app boot, so it self-heals regardless of how the state was
  // loaded (localStorage, preset, import, needs-intake) or how the
  // override got stale. Reported recurring by Rija/team 2026-07-17
  // ("after pc starting ... annual mileage error again").
  if (VR <= 0 && (s.ecEmptyOverride != null || s.ecFullOverride != null)) {
    const fallback = window.computeEcActual({ ...s, ecEmptyOverride: null, ecFullOverride: null }, veh);
    const fallbackVR = (fallback.ecActual > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / fallback.ecActual : 0;
    if (fallbackVR > 0) { ecActual = fallback.ecActual; VR = fallbackVR; }
  }
  if (VR <= 0) return null; // no battery spec to derive range from

  const terrain = s.trackProfile?.enabled
    ? (window.computeTrackProfile(s.trackProfile)?.contour || s.terrainManual)
    : s.terrainManual;
  const autoSpeedKmh = window.avgSpeedForVeh(veh, terrain) || 40;
  const RT = (s.ritaseTimeOverride != null && s.ritaseTimeOverride > 0)
    ? s.ritaseTimeOverride
    : (autoSpeedKmh > 0 ? (RD / autoSpeedKmh) * 60 : 0); // minutes

  // v1.9.3: SS is one merged user input (Screen 4's Charging Strategy card)
  // regardless of vehicle type -- previously branched charge vs. swap
  // vehicles onto two separate fields/dropdowns; Rija asked for a single
  // dropdown so either scenario's Gantt chart is explorable without
  // needing to change the selected vehicle first.
  const isSwap = window.EV_INFRA[veh.id] === "swap";
  const SS = Math.max(1, s.chargeSessionMinutes || 90);

  const CD  = Math.max(0, Math.min(1440, s.chargingDowntimeMinutes || 0));
  const TCW = 1440 - CD;

  const Z_RC   = Math.max(0, Math.floor(VR / RD));
  const TOT_BR = RT * Z_RC;
  const TOT_OC = TOT_BR + SS;

  const PZ_CC = Math.max(1, Math.round(s.proposedCycleCount) || 1);
  const P_CC  = TCW / PZ_CC;
  const cycleGateOk = TOT_OC > 0 ? (P_CC >= TOT_OC) : true;
  const Z_CC  = cycleGateOk ? PZ_CC : Math.max(1, Math.floor(TCW / Math.max(1, TOT_OC)));
  const CC    = TCW / Z_CC;

  const Z_TPG = Math.max(0, Math.floor(CC / SS));
  const PZ_TG = Math.max(1, Math.round(s.proposedGroupCount) || 1);
  const groupGateOk = PZ_TG <= Z_TPG;
  const Z_TG  = groupGateOk ? PZ_TG : Math.max(1, Z_TPG);
  const US    = Math.max(0, CC - (Z_TG * SS));

  // §10.7: any slack between a group's own drive+charge need (TOT_OC) and
  // the actual cycle length (CC, sized for Z_TG*SS to fit) is idle time for
  // that group -- Z_RC is a hard range-based cap, so it cannot drive
  // further with the extra time even if available.
  const idleMinPerCycle = Math.max(0, CC - TOT_OC);

  const Z_DR = Z_RC * Z_CC;          // per-vehicle daily ritase, §10.4
  const TPR  = (veh.payload || 0) * ((s.payloadPct ?? 50) / 100); // per-vehicle payload/ritase
  const TP_D = TPR * Z_RC * Z_CC;    // per-vehicle daily payload, §10.4

  const fleetSize = Math.max(0, s.fleetSize || 0);
  const fleetZ_DR = Z_DR * fleetSize;
  const fleetTP_D = TP_D * fleetSize;

  // §10.10: dailyMileageKm/annualKm both become derived, per-vehicle,
  // written back into their existing fields (see withDerivedOperation) --
  // every downstream consumer (energy, maintenance, CO2, battery-cycle-
  // life, lifetime totals, ton-km rate) reads them exactly as before, zero
  // changes needed.
  const operatingDaysPerYear = s.operatingDaysPerYear || 300;
  const dailyMileageKmDerived = RD * Z_DR;
  const annualKmDerived = dailyMileageKmDerived * operatingDaysPerYear;

  // §10.8 Day-1 ramp-up: group i's first-ever departure is delayed by
  // (i-1)*SS to seed the staggered rotation (all groups can't start their
  // first drive stint simultaneously, or they'd all need to charge at the
  // same instant -- defeating the point of sequential slots). Day 1 only:
  // fleet-wide ritase/payload is reduced vs. steady state; Day 2 onward
  // every group has completed one staggered entry.
  const groupVehicleCounts = Array.from({ length: Z_TG }, (_, i) => {
    const base = Math.floor(fleetSize / Z_TG);
    return base + (i < fleetSize % Z_TG ? 1 : 0);
  });
  // Group i's delayed start (i*SS minutes behind group 0) only costs it
  // ritase in its FIRST cycle -- every cycle after that, it's already in
  // rotation and completes its normal Z_RC. Capped at Z_RC (one cycle's
  // worth) since a delay can't cost more than the one cycle it happens in.
  let day1FleetRitase = 0, day1FleetPayload = 0;
  groupVehicleCounts.forEach((count, i) => {
    const lostRitase = RT > 0 ? Math.min(Z_RC, Math.floor((i * SS) / RT)) : 0;
    const ritaseThisGroup = Math.max(0, Z_DR - lostRitase);
    day1FleetRitase  += ritaseThisGroup * count;
    day1FleetPayload += ritaseThisGroup * TPR * count;
  });

  return {
    veh, RD, VR, RT, autoSpeedKmh, terrain, SS, isSwap, CD, TCW,
    Z_RC, TOT_BR, TOT_OC, idleMinPerCycle,
    PZ_CC, P_CC, Z_CC, CC, cycleGateOk,
    Z_TPG, PZ_TG, Z_TG, groupGateOk, US,
    Z_DR, TP_D, TPR, fleetSize, fleetZ_DR, fleetTP_D,
    groupVehicleCounts,
    dailyMileageKmDerived, annualKmDerived, operatingDaysPerYear,
    day1: { fleetRitase: day1FleetRitase, fleetPayload: day1FleetPayload },
    steady: { fleetRitase: fleetZ_DR, fleetPayload: fleetTP_D },
  };
};

// v1.8.4 (2026-07-19): computeRitaseCycle returns a bare `null` for 3
// distinct reasons (no EV in the comparison, Ritase Distance not entered,
// EV has no resolvable battery/energy spec to derive range from) -- from
// the UI these all looked identical, a silently-stuck "0"/raw-input field
// with no indication of which precondition was missing (reported by Rija:
// "annual mileage... do not seem to be wired properly" when the real cause
// was simply no EV vehicle selected on Screen 2). Surfaced separately so
// Screen 3/4 can tell the user exactly what to fix. Duplicates
// computeRitaseCycle's own gate checks (cheap, read-only, no side effects)
// rather than changing that function's null-vs-object return contract,
// which every existing `rc &&`/`if (rc)` call site depends on.
window.ritaseCycleBlockedReason = function(s) {
  if (!window.getEvVehicle(s)) return "no-ev";
  if (window.resolveRitaseDistanceKm(s) <= 0) return "no-rd";
  const veh = window.getEvVehicle(s);
  const { ecActual } = window.computeEcActual(s, veh);
  const usableSocFraction = 0.6;
  let VR = (ecActual > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / ecActual : 0;
  // v1.9.6: mirrors computeRitaseCycle's own stale-override self-heal
  // (above) -- without this, a saved profile with a stale EC override
  // would report "no-range" here (Screen 4's blocked message) while
  // computeRitaseCycle/resolveAutoAnnualMileage silently self-healed and
  // succeeded (Screen 3's Annual Mileage), a confusing cross-screen
  // mismatch.
  if (VR <= 0 && (s.ecEmptyOverride != null || s.ecFullOverride != null)) {
    const fallback = window.computeEcActual({ ...s, ecEmptyOverride: null, ecFullOverride: null }, veh);
    VR = (fallback.ecActual > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / fallback.ecActual : 0;
  }
  if (VR <= 0) return "no-range";
  return null;
};

// v1.9.6: companion to the self-heal above -- computeRitaseCycle/
// ritaseCycleBlockedReason now silently IGNORE a stale EC override at
// compute time (every render), but the stale value stays sitting in the
// profile forever, so Screen 4's Expert Mode still shows an overridden
// number that's quietly not being applied -- confusing, and it means
// switching back to the vehicle it originally belonged to would suddenly
// un-silence a value nobody consciously set for THIS session. Called once
// at every full-state load entry point (app boot from localStorage, preset
// load, profile import, needs-intake) to actually clear it from the
// profile when it's invalid for the currently-loaded vehicle, rather than
// just working around it. Pure data hygiene -- computeRitaseCycle's own
// self-heal is what actually fixes the reported bug; this just keeps the
// persisted profile honest going forward.
window.sanitizeLoadedState = function(s) {
  if (s.ecEmptyOverride == null && s.ecFullOverride == null) return s;
  const veh = window.getEvVehicle(s);
  if (!veh) return s;
  const { ecActual } = window.computeEcActual(s, veh);
  const usableSocFraction = 0.6;
  const VR = (ecActual > 0 && veh.batteryKwh) ? (veh.batteryKwh * usableSocFraction) / ecActual : 0;
  if (VR > 0) return s;
  return { ...s, ecEmptyOverride: null, ecFullOverride: null };
};

// v1.9.4: generalizes annual-mileage automation beyond the EV-with-
// resolvable-battery case computeRitaseCycle covers. Rija (2026-07-17):
// "I WANT THE ANNUAL MILEAGE TO BE AUTOMATED following ritase distance,
// ritase time, and all related elements. DO NOT MAKE ANNUAL MILEAGE USER
// EDITABLE." Previously, every consumer below fell back to the raw manual
// s.annualKm/s.dailyMileageKm input whenever computeRitaseCycle returned
// null -- which happens for an ICE-only comparison (by original design,
// see the pre-v1.9.4 comment this replaced) but ALSO for an EV whose
// battery/energy spec can't resolve a usable range (a data gap, e.g. a
// placeholder catalog entry, or -- the actual bug Rija hit -- a stale
// Expert Mode EC override left over from a previously-selected EV, see
// VehicleCard.pickVeh v1.9.3 fix above). Both cases surfaced identically
// in the UI as a "Not Yet Computed" raw-editable field, which Rija
// reasonably read as broken regardless of the underlying cause.
//
// This adds a time-window-constrained fallback: instead of limiting
// ritase count by battery range (Z_RC = floor(VR/RD)), it limits ritase
// count by how many RT-minute round trips physically fit in the
// available operating window (TCW), the same way a non-charging-
// constrained ICE fleet actually operates. Once Ritase Distance is set,
// Annual Mileage ALWAYS auto-computes -- the only remaining "not yet
// computed" case is RD itself being unset. Deliberately does NOT touch
// computeSizing/Screen4's own EV-gated charging-infrastructure sizing
// (chargers/dispensers/transformer/CAPEX genuinely can't be sized without
// a real EV+battery spec) -- only the operational mileage figure and
// everything costed off it (energy, maintenance, TCO).
window.resolveAutoAnnualMileage = function(s) {
  const rc = window.computeRitaseCycle(s);
  // v1.9.13 (2026-07-22): was `if (rc) return {...}` -- accepted rc's own
  // annualKmDerived even when Z_RC (ritase per charge) floors to 0, i.e.
  // the EV's usable range is shorter than Ritase Distance so it can't
  // complete one round trip without mid-route charging. That's a real,
  // reachable state (confirmed: VKTR Mamberamo, ~99km usable range, any
  // Ritase Distance >= 100km) that silently zeroed Annual Mileage -- and
  // with it, every downstream energy/maintenance/TCO figure -- with no
  // error, just a quiet 0 that looked identical to "the box broke" from
  // the outside (reported by Rija during platform QA). Screen 3 already
  // warns about this exact condition (zeroRangeWarn, "Range/Window Too
  // Short"), but the NUMBER itself still needs to be usable, not zeroed --
  // Rija: "I WANT THE ANNUAL MILEAGE TO BE AUTOMATED ... DO NOT MAKE
  // ANNUAL MILEAGE USER EDITABLE" leaves no room for a silent 0 either.
  // Now falls through to the same time-window-constrained estimate used
  // for a non-resolvable-range EV or an ICE-only comparison whenever the
  // charge-cycle basis degenerates -- Annual Mileage always shows a
  // reasonable computed figure; the range-mismatch warning is unchanged.
  if (rc && rc.Z_RC > 0) return { annualKmDerived: rc.annualKmDerived, dailyMileageKmDerived: rc.dailyMileageKmDerived, ritaseCountPerDay: rc.Z_DR, RT: rc.RT, basis: "charge-cycle" };

  const RD = window.resolveRitaseDistanceKm(s);
  if (RD <= 0) return null; // the one remaining legitimate "not yet computed" case

  const anyVeh = window.findVeh(s.vehA) || window.findVeh(s.vehB);
  const terrain = s.trackProfile?.enabled
    ? (window.computeTrackProfile(s.trackProfile)?.contour || s.terrainManual)
    : s.terrainManual;
  const autoSpeedKmh = window.avgSpeedForVeh(anyVeh, terrain) || 40;
  const RT = (s.ritaseTimeOverride != null && s.ritaseTimeOverride > 0)
    ? s.ritaseTimeOverride
    : (autoSpeedKmh > 0 ? (RD / autoSpeedKmh) * 60 : 0);
  if (RT <= 0) return null;

  const CD  = Math.max(0, Math.min(1440, s.chargingDowntimeMinutes || 0));
  const TCW = 1440 - CD;
  const ritaseCountPerDay = Math.max(0, Math.floor(TCW / RT));
  const dailyMileageKmDerived = RD * ritaseCountPerDay;
  const operatingDaysPerYear = s.operatingDaysPerYear || 300;
  const annualKmDerived = dailyMileageKmDerived * operatingDaysPerYear;

  return { annualKmDerived, dailyMileageKmDerived, ritaseCountPerDay, RT, TCW, basis: "time-window" };
};

// §10.10 -- resolves the operational scalars every consumer should read
// instead of s.dailyMileageKm/s.annualKm directly -- now backed by
// resolveAutoAnnualMileage (v1.9.4) above, so it's null only when Ritase
// Distance itself isn't set. Returns a shallow-copied working state so
// every existing s.annualKm/s.dailyMileageKm reader downstream needs zero
// changes -- see CALCULATION_ENGINE.md §10.10.
window.withDerivedOperation = function(s) {
  const auto = window.resolveAutoAnnualMileage(s);
  if (!auto) return s;
  return { ...s, annualKm: auto.annualKmDerived, dailyMileageKm: auto.dailyMileageKmDerived };
};

// Display-side twins of withDerivedOperation -- annualKm/dailyMileageKm are
// NOT mutated back into persisted state (avoids a stateful write-on-every-
// keystroke React effect loop), so any screen that reads s.annualKm/
// s.dailyMileageKm directly for DISPLAY must go through these instead of
// the raw field, or it will show the stale/raw value while computeTCO
// internally uses the correct derived one. Screen 3, Screen 2's
// maintenance panels, and any Results annotation reading these fields
// should call these, not s.annualKm/s.dailyMileageKm directly.
window.resolveAnnualKm = function(s) {
  const auto = window.resolveAutoAnnualMileage(s);
  return auto ? auto.annualKmDerived : (s.annualKm || 0);
};
window.resolveDailyMileageKm = function(s) {
  const auto = window.resolveAutoAnnualMileage(s);
  return auto ? auto.dailyMileageKmDerived : (s.dailyMileageKm || 0);
};

window.computeSizing = function(s) {
  const veh = window.getEvVehicle(s);
  if (!veh) return null;

  const ecosystemId   = s.ecosystemId   || "others";
  const effectiveEcosystemId = s.costModelFlat ? "others" : ecosystemId;
  const profile = window.INFRA_PROFILES.ECOSYSTEM_PROFILES[effectiveEcosystemId]
    || window.INFRA_PROFILES.ECOSYSTEM_PROFILES.others;
  const ov = s.screen4_valueOverrides || {};

  // 1. EC actual (kWh/km) -- shared with computeRitaseCycle's VR derivation
  const { ecEmpty, ecFull, ecEmptyDefault, ecFullDefault, ecActual, payloadPct } = window.computeEcActual(s, veh);

  // 1b. Ritase-Cycle Engine (v1.8, §10 CALCULATION_ENGINE.md) -- derives
  // dailyMileageKm/annualKm from real ritase physics when RD/VR are
  // resolvable; falls back to the raw stored dailyMileageKm otherwise (RD
  // not yet entered, or a battery-less/placeholder vehicle spec).
  const ritaseCycle = window.computeRitaseCycle(s);
  const dailyMileage = ritaseCycle ? ritaseCycle.dailyMileageKmDerived : (s.dailyMileageKm || 0);

  // 2. Daily Fleet Energy (kWh/day)
  const fleetSize = s.fleetSize || 0;
  const dailyFleetEnergy = fleetSize * dailyMileage * ecActual;

  // 3. Required Charging Power (kW)
  // v1.8.2: chargingType/chargerRatingKw/voltageLevel/nozzlesPerVehicle are
  // now AUTO-COMPUTED from the Charging Strategy card's SS input (Charging
  // Requirement Engine, above) -- s.chargingType/s.chargerRatingKw/
  // s.voltageLevel stay as nullable EXPERT-MODE OVERRIDES (null = use the
  // computed default), same pattern as ecEmptyOverride/chargerRatioOverride
  // elsewhere in this function.
  const chargingEfficiency = s.chargingEfficiencyOverride ?? 0.92;
  const chargingRequirement = window.computeChargingRequirement(s, veh);
  const chargingTypeDefault = chargingRequirement ? chargingRequirement.chargingType : "dc";
  const chargingType = s.chargingType ?? chargingTypeDefault;
  const chargerRatingKwDefault = chargingRequirement ? chargingRequirement.chargerRatingKw : 120;
  const chargerRatingKw = (s.chargerRatingKw ?? chargerRatingKwDefault) || 1;
  const voltageLevelDefault = chargingRequirement ? chargingRequirement.voltageLevel : "lv";
  const voltageLevel = s.voltageLevel ?? voltageLevelDefault;
  // Total daily active-charger time -- Z_CC cycles x Z_TG scheduled slots x
  // SS duration (§10.3). Falls back to a flat one-session-per-vehicle-per-
  // day assumption (the same SS input, not a separate curve estimate) if
  // the ritase engine can't resolve yet (RD not entered).
  const chargingWindowHours = ritaseCycle
    ? (ritaseCycle.Z_CC * ritaseCycle.Z_TG * (ritaseCycle.SS / 60))
    : Math.max(0.1, (s.chargeSessionMinutes || 90) / 60);
  const requiredPowerKw = (chargingWindowHours > 0 && chargingEfficiency > 0)
    ? dailyFleetEnergy / (chargingWindowHours * chargingEfficiency)
    : 0;

  // 4. Adjusted Required Power (kW)
  const utilizationFactor = ov["tab2.utilizationFactor"] ?? profile.utilizationFactor.value;
  const infraMultiplier = ov["tab4.infraMultiplier"]
    ?? window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "infraMultiplier");
  const adjustedRequiredPowerKw = utilizationFactor > 0
    ? (requiredPowerKw / utilizationFactor) * infraMultiplier
    : 0;

  // 5. Charger Count
  const redundancyFactor = ov["tab4.redundancyFactor"]
    ?? window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "redundancyFactor");
  const powerBasedChargerCount = Math.ceil(Math.ceil(adjustedRequiredPowerKw / chargerRatingKw) * redundancyFactor);

  // 5b. Charger throughput cross-check — even if power balance needs fewer
  // chargers, the fleet still needs enough chargers to physically cycle
  // every vehicle through one charging session within the window. v1.8:
  // the simultaneous-bay driver is Z_TG (the Ritase-Cycle Engine's
  // scheduled group count) -- Z_TG disjoint groups share bays sequentially
  // within each cycle, so one group's vehicles all need their own bay at
  // once, during that group's SS slot. Falls back to the pre-v1.8
  // segment-ratio default if the ritase engine can't resolve yet.
  const chargerRatioBySegment = window.INFRA_PROFILES.chargerRatioForSegment(veh.segment);
  const chargerRatioDefault = ritaseCycle ? ritaseCycle.Z_TG : chargerRatioBySegment.value;
  const chargerRatioDefaultSource = ritaseCycle ? "computed" : chargerRatioBySegment.source;
  const chargerRatioDefaultNote = ritaseCycle
    ? `Derived from the ${ritaseCycle.Z_TG} scheduled charging groups/cycle (Ritase-Cycle Engine) -- each bay serves one group's vehicles sequentially per cycle.`
    : chargerRatioBySegment.note;
  const chargerRatio = ov["tab2.chargerRatio"] ?? chargerRatioDefault;

  // Group-size floor (v1.8, replaces the pre-v1.8 shift-based
  // peakGroupFloor): Z_TG groups split fleetSize as evenly as possible
  // (§10.5) -- every vehicle in the LARGEST group still needs its own bay
  // simultaneously during that group's SS slot, so chargerCount (and the
  // depot design synced off it via tcoChargerCount, see
  // screens.jsx/Tab6DepotDesign) can't be undersized relative to that real
  // peak -- wrong nozzle/dispenser counts otherwise ripple into wrong
  // depot CAPEX.
  const groupSizeFloor = ritaseCycle && ritaseCycle.groupVehicleCounts.length
    ? Math.max(...ritaseCycle.groupVehicleCounts)
    : 0;

  const throughputChargerCount = Math.max(chargerRatio > 0 ? Math.ceil(fleetSize / chargerRatio) : 0, groupSizeFloor);

  let chargerCount = Math.max(powerBasedChargerCount, throughputChargerCount);
  if (s.chargerCountOverride != null) chargerCount = s.chargerCountOverride;

  // 6. Total Charging Load (kW)
  const totalChargingLoadKw = chargerCount * chargerRatingKw;

  // 7. Recommended Transformer Size (kVA)
  const powerFactor = s.powerFactorOverride ?? 0.95;
  const diversityFactor = ov["tab2.diversityFactor"]
    ?? window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "diversityFactor");
  const growthMargin = ov["tab4.growthMargin"]
    ?? window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "growthMargin");
  let transformerKva = powerFactor > 0
    ? (totalChargingLoadKw / powerFactor) * diversityFactor * growthMargin
    : 0;
  if (s.transformerKvaOverride != null) transformerKva = s.transformerKvaOverride;

  // 8/9. Upgrade required + readiness
  const existingKva = s.siteAvailableKva;
  let readiness = "yellow";
  let increasePct = null;
  let upgradeRequired = null;
  if (existingKva != null && existingKva > 0) {
    upgradeRequired = transformerKva > existingKva;
    increasePct = ((transformerKva - existingKva) / existingKva) * 100;
    if (!upgradeRequired) readiness = "green";
    else if (increasePct <= 30) readiness = "yellow";
    else readiness = "red";
  }

  return {
    veh, ecosystemId, effectiveEcosystemId, costModelFlat: !!s.costModelFlat, profile,
    ecEmpty, ecFull, ecEmptyDefault, ecFullDefault, ecActual, payloadPct,
    fleetSize, dailyMileage, dailyFleetEnergy,
    ritaseCycle,
    chargingWindowHours, chargingEfficiency, requiredPowerKw,
    utilizationFactor, utilizationFactorDefault: profile.utilizationFactor.value,
    infraMultiplier, infraMultiplierDefault: window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "infraMultiplier"),
    adjustedRequiredPowerKw,
    chargingRequirement,
    chargingType, chargingTypeDefault,
    chargerRatingKw, chargerRatingKwDefault,
    voltageLevel, voltageLevelDefault,
    redundancyFactor, redundancyFactorDefault: window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "redundancyFactor"),
    chargerRatio, chargerRatioDefault, chargerRatioDefaultSource, chargerRatioDefaultNote,
    powerBasedChargerCount, throughputChargerCount, groupSizeFloor,
    chargerCount,
    totalChargingLoadKw,
    powerFactor,
    diversityFactor, diversityFactorDefault: window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "diversityFactor"),
    growthMargin, growthMarginDefault: window.INFRA_PROFILES.MASTER_MULTIPLIER(effectiveEcosystemId, "growthMargin"),
    transformerKva,
    existingKva, upgradeRequired, increasePct, readiness,
  };
};

/* ============================================================
   v1.8 (2026-07-17) — Charging Schedule Simulation, ritase-cycle model
   (§10.5/10.8 CALCULATION_ENGINE.md). Replaces the v1.7.8 shift/mini-shift
   Gantt entirely. One integrated Gantt: Z_CC repeating cycles, each
   containing Z_TG sequential group SS slots + a trailing US block. Groups
   are fleet-wide and FIXED -- the same roster charges in the same slot
   every cycle, every day (§10.5) -- no per-vehicle randomness or rotation.
   Within a cycle, a group drives except during its own SS slot (§10.7's
   idle-time resolution: any slack between CC and that group's own
   TOT_OC becomes idle time, not extra ritase, since Z_RC is already a
   hard range-based cap).
   Known simplification: does not yet visually distinguish a CD
   (charging-downtime / depot-closed) window from ordinary cycle tiling --
   harmless while CD defaults to 0 and isn't exposed in the UI; revisit if
   chargingDowntimeMinutes becomes user-editable.
   ============================================================ */
window.simulateChargingSchedule = function(s, opts) {
  const veh = window.getEvVehicle(s);
  const sizing = veh ? window.computeSizing(s) : null;
  const rc = veh ? window.computeRitaseCycle(s) : null;
  if (!veh || !sizing || !sizing.chargerCount || !rc) return null;

  const fleetSize = rc.fleetSize;
  if (fleetSize <= 0 || rc.Z_TG <= 0) return null;

  const chargerCount = sizing.chargerCount;
  const chargeHours = rc.SS / 60;
  const cycleHours = rc.CC / 60;

  const groups = rc.groupVehicleCounts.map((count, i) => ({
    label: `Group ${i + 1}`, count, slotStartHour: (i * rc.SS) / 60,
  })).filter(g => g.count > 0);
  if (groups.length === 0) return null;

  const horizonHours = (opts && opts.horizonHours) || 24;

  // Per-group Gantt: within EVERY cycle, a group drives except during its
  // own SS slot -- Charge [slotStart, slotStart+SS) -> Drive, repeated for
  // Z_CC cycles/day, repeated again to fill horizonHours.
  const ganttRows = groups.map(g => {
    const segments = [];
    let t = 0;
    let guard = 0;
    while (t < horizonHours && guard++ < 400) {
      const cycleStart = Math.floor(t / cycleHours) * cycleHours;
      const chargeAbsStart = cycleStart + g.slotStartHour;
      const chargeAbsEnd = chargeAbsStart + chargeHours;
      const cycleAbsEnd = cycleStart + cycleHours;
      if (t < chargeAbsStart) {
        const end = Math.min(horizonHours, chargeAbsStart);
        if (end > t) segments.push({ type: "drive", hours: end - t });
        t = end;
      } else if (t < chargeAbsEnd) {
        const end = Math.min(horizonHours, chargeAbsEnd);
        if (end > t) segments.push({ type: "charge", hours: end - t });
        t = end;
      } else if (t < cycleAbsEnd) {
        const end = Math.min(horizonHours, cycleAbsEnd);
        if (end > t) segments.push({ type: "drive", hours: end - t });
        t = end;
      } else {
        t = cycleAbsEnd;
      }
    }
    return { label: g.label, count: g.count, segments };
  });

  const groupCounts = groups.map(g => g.count);
  const minGroupSize = Math.min(...groupCounts);
  const maxGroupSize = Math.max(...groupCounts);

  // Feasibility, §10.3's two INDEPENDENT gates -- surfaced separately so
  // Results can tell the user which proposal (cycle count or group count)
  // didn't fit and was auto-corrected.
  const cycleGateViolated = !rc.cycleGateOk;
  const groupGateViolated = !rc.groupGateOk;
  const dailyChargeHoursUsed = rc.Z_CC * rc.Z_TG * chargeHours;
  const dayHours = rc.TCW / 60;
  const utilizationPct = dayHours > 0 ? Math.min(100, (dailyChargeHoursUsed / dayHours) * 100) : 0;

  return {
    veh, chargerCount, chargeHours, fleetSize, horizonHours,
    ritaseCycle: rc,
    groupCount: groups.length, minGroupSize, maxGroupSize,
    ganttRows,
    stats: {
      groupCount: groups.length, minGroupSize, maxGroupSize,
      utilizationPct,
      infeasibleGroupCount: groupGateViolated ? 1 : 0, // legacy field name, kept for report.jsx compat
      cycleGateViolated, groupGateViolated,
      targetTripsPerDay: fleetSize * rc.Z_RC, // fleet-wide daily ritase target (was tripsPerDay-based, now ritase-derived)
    },
  };
};

// CAPEX breakdown (categories A-E, §7.9)
window.computeCapex = function(s, sizing) {
  if (!sizing) return null;
  const { ecosystemId, effectiveEcosystemId, chargerCount, transformerKva, veh } = sizing;
  const costEcosystemId = effectiveEcosystemId || ecosystemId;
  const uc = window.INFRA_PROFILES.UNIT_COSTS[costEcosystemId] || window.INFRA_PROFILES.UNIT_COSTS.others;
  const ov = s.screen4_valueOverrides || {};
  // v1.7.7 (definitive-DB pass): A_charger_per_unit's real default is now
  // capacity-tiered (Helio Sinar Energi Q2 2026 DC charger price list,
  // interpolated by sizing.chargerRatingKw -- see
  // chargerUnitPriceForKw/HELIO_CHARGER_PRICE_TABLE, infra_profiles.js),
  // replacing the flat gap-filled per-ecosystem constant. Still fully
  // overridable via the same Screen 4 value-override mechanism as every
  // other line here -- only the DEFAULT changed, not the override path.
  const chargerDefault = window.INFRA_PROFILES.chargerUnitPriceForKw(sizing.chargerRatingKw);
  const defaultOf = (key) => key === "A_charger_per_unit" ? chargerDefault : uc[key].value;
  const get = (key) => ov["tab5." + key] ?? defaultOf(key);
  const srcOf = (key) => (ov["tab5." + key] != null)
    ? { source: "user", note: null }
    : key === "A_charger_per_unit"
      ? { source: "internal_quote", note: "Helio Sinar Energi Q2 2026 DC charger price list, interpolated by charger rating (kW)." }
      : { source: uc[key].source, note: uc[key].note };
  const unitOf = (key) => ({ rawValue: get(key), rawDefault: defaultOf(key) });

  const evInfraType = window.EV_INFRA[veh.id] || "charge";
  const chargeApplicable = evInfraType === "charge" || evInfraType === "both";
  const swapApplicable   = evInfraType === "swap"   || evInfraType === "both";

  // Resolved nozzles-per-vehicle for the EV side of this comparison (sizing.veh
  // is always the EV vehicle, per window.getEvVehicle — match it back to slot
  // A or B to pick the right override; default both to window.EV_NOZZLE_COUNT).
  const nozzlesOverride = s.vehA === veh.id ? s.nozzlesPerVehicleOverrideA
    : s.vehB === veh.id ? s.nozzlesPerVehicleOverrideB
    : null;
  const nozzlesPerVehicle = nozzlesOverride ?? (window.EV_NOZZLE_COUNT[veh.id] ?? 1);

  // A — Charger Equipment (dispenser/gun cost scales by simultaneous-nozzle
  // demand per vehicle — fallback path only; Depot Design's own BOM, used
  // once available, is nozzle-aware separately in depot_floorplan).
  const chargerCost   = chargerCount * get("A_charger_per_unit");
  const dispenserCost = chargerCount * nozzlesPerVehicle * get("A_dispenser_per_unit");
  const gunCost        = chargerCount * nozzlesPerVehicle * get("A_gun_per_unit");
  const totalA = chargeApplicable ? (chargerCost + dispenserCost + gunCost) : 0;

  // B — Electrical Infrastructure
  const transformerCost = transformerKva * get("B_transformer_per_kva");
  const switchgearCost  = get("B_switchgear_flat");
  const panelCost       = chargerCount * get("B_panel_per_charger");
  const protectionCost  = get("B_protection_flat");
  const meteringCost    = get("B_metering_flat");
  const totalB = transformerCost + switchgearCost + panelCost + protectionCost + meteringCost;

  // C — Civil Works
  const civilMultiplier = window.INFRA_PROFILES.MASTER_MULTIPLIER(costEcosystemId, "civilWorksMultiplier");
  const foundationCost = chargerCount * get("C_foundation_per_unit");
  const padCost        = chargerCount * get("C_pad_per_unit");
  const shelterCost    = chargerCount * get("C_shelter_per_unit");
  const drainageCost   = get("C_drainage_flat");
  const totalC = (foundationCost + padCost + shelterCost + drainageCost) * civilMultiplier;

  // D — Utility Upgrade
  const utilityMultiplier = window.INFRA_PROFILES.MASTER_MULTIPLIER(costEcosystemId, "utilityUpgradeMultiplier");
  const plnCost = get("D_pln_connection_flat");
  const feederCost = get("D_feeder_per_meter") * get("D_feeder_assumed_m");
  const requiredVa = transformerKva * 1000;
  const plnCapacityCost = requiredVa * get("D_pln_capacity_per_va");
  const plnDepositCost  = requiredVa * get("D_pln_deposit_per_va");
  const totalD = (plnCost + feederCost + plnCapacityCost + plnDepositCost) * utilityMultiplier;

  // E — Software
  const softwareMultiplier = window.INFRA_PROFILES.MASTER_MULTIPLIER(costEcosystemId, "softwareMultiplier");
  const cmsCost       = get("E_cms_flat");
  const emsCost       = get("E_ems_flat");
  const dashboardCost = get("E_dashboard_flat");
  const totalE = (cmsCost + emsCost + dashboardCost) * softwareMultiplier;

  const total = totalA + totalB + totalC + totalD + totalE;
  // TCO scope, locked (v1.7.7 Wave 3, same confirmed rule as Depot Design's
  // BOM_INCLUDE): VKTR bears only the CAPEX of charging electrical equipment
  // (Category A Charger Equipment + Category B Electrical Infrastructure --
  // this fallback's equivalent of bom.js's "electrical" category); Civil
  // Works (C), Utility Upgrade (D), and Software (E) are Helio's, along
  // with 100% of EVCS OPEX (see INFRA_OPEX_RATE below). `total` above stays
  // the full A-E facility figure for informational display.
  const tcoCapex = totalA + totalB;
  // v1.7.7: salvage credit was replacement-project-type-only; platform is
  // Greenfield-only now, so this is permanently 0 (kept as a field, not
  // deleted, since report.jsx/screens.jsx already gate its display on > 0).
  const salvageCredit = 0;

  return {
    chargeApplicable, swapApplicable, nozzlesPerVehicle,
    A: { total: totalA, items: [
      { label: "Chargers", value: chargerCost, key: "A_charger_per_unit", ...srcOf("A_charger_per_unit"), ...unitOf("A_charger_per_unit") },
      { label: "Dispensers", value: dispenserCost, key: "A_dispenser_per_unit", ...srcOf("A_dispenser_per_unit"), ...unitOf("A_dispenser_per_unit") },
      { label: "Connector Guns", value: gunCost, key: "A_gun_per_unit", ...srcOf("A_gun_per_unit"), ...unitOf("A_gun_per_unit") },
    ]},
    B: { total: totalB, items: [
      { label: "Transformer", value: transformerCost, key: "B_transformer_per_kva", ...srcOf("B_transformer_per_kva"), ...unitOf("B_transformer_per_kva") },
      { label: "Switchgear", value: switchgearCost, key: "B_switchgear_flat", ...srcOf("B_switchgear_flat"), ...unitOf("B_switchgear_flat") },
      { label: "Distribution Panels", value: panelCost, key: "B_panel_per_charger", ...srcOf("B_panel_per_charger"), ...unitOf("B_panel_per_charger") },
      { label: "Protection Equipment", value: protectionCost, key: "B_protection_flat", ...srcOf("B_protection_flat"), ...unitOf("B_protection_flat") },
      { label: "Metering", value: meteringCost, key: "B_metering_flat", ...srcOf("B_metering_flat"), ...unitOf("B_metering_flat") },
    ]},
    C: { total: totalC, multiplier: civilMultiplier, items: [
      { label: "Foundations", value: foundationCost * civilMultiplier, key: "C_foundation_per_unit", ...srcOf("C_foundation_per_unit"), ...unitOf("C_foundation_per_unit") },
      { label: "Equipment Pads", value: padCost * civilMultiplier, key: "C_pad_per_unit", ...srcOf("C_pad_per_unit"), ...unitOf("C_pad_per_unit") },
      { label: "Shelters", value: shelterCost * civilMultiplier, key: "C_shelter_per_unit", ...srcOf("C_shelter_per_unit"), ...unitOf("C_shelter_per_unit") },
      { label: "Drainage", value: drainageCost * civilMultiplier, key: "C_drainage_flat", ...srcOf("C_drainage_flat"), ...unitOf("C_drainage_flat") },
    ]},
    D: { total: totalD, multiplier: utilityMultiplier, items: [
      { label: "PLN Connection (NIDI/SLO permit)", value: plnCost * utilityMultiplier, key: "D_pln_connection_flat", ...srcOf("D_pln_connection_flat"), ...unitOf("D_pln_connection_flat") },
      { label: "Feeder Cable", value: feederCost * utilityMultiplier, key: "D_feeder_per_meter", ...srcOf("D_feeder_per_meter"), ...unitOf("D_feeder_per_meter") },
      { label: "PLN Capacity Addition (Tambah Daya)", value: plnCapacityCost * utilityMultiplier, key: "D_pln_capacity_per_va", ...srcOf("D_pln_capacity_per_va"), ...unitOf("D_pln_capacity_per_va") },
      { label: "PLN Connection Deposit", value: plnDepositCost * utilityMultiplier, key: "D_pln_deposit_per_va", ...srcOf("D_pln_deposit_per_va"), ...unitOf("D_pln_deposit_per_va") },
    ]},
    E: { total: totalE, multiplier: softwareMultiplier, items: [
      { label: "Charge Management System (CMS)", value: cmsCost * softwareMultiplier, key: "E_cms_flat", ...srcOf("E_cms_flat"), ...unitOf("E_cms_flat") },
      { label: "Energy Management System (EMS)", value: emsCost * softwareMultiplier, key: "E_ems_flat", ...srcOf("E_ems_flat"), ...unitOf("E_ems_flat") },
      { label: "Dashboard", value: dashboardCost * softwareMultiplier, key: "E_dashboard_flat", ...srcOf("E_dashboard_flat"), ...unitOf("E_dashboard_flat") },
    ]},
    salvageCredit,
    total, tcoCapex,
  };
};

/* ============================================================
   v1.3 — Budget Optimization Recommendation Engine (§8.2)
   ============================================================ */
// v1.7.7 Wave 3: every comparison below uses tcoCapex (electrical A+B
// only), not the full A-E facility total -- the budget cap represents
// what VKTR itself needs to fund, and VKTR no longer funds C/D/E (Helio's).
window.computeBudgetRecommendations = function(s, sizing, capex) {
  const budgetCap = s.infraBudgetCap;
  if (!sizing || !capex || budgetCap == null || capex.tcoCapex <= budgetCap) return [];

  const PRI = window.INFRA_PROFILES.BUDGET_RECOMMENDATION_PRIORITY;
  const ov = s.screen4_valueOverrides || {};
  const recs = [];

  // 1. Extend charging window -- v1.8: the "spread charging over more
  // hours to reduce required power" lever is now pulled via
  // proposedGroupCount (more scheduled groups -> smaller simultaneous
  // batches -> more total scheduled charger-hours/day, the same effect a
  // longer fixed window used to have), since fleetPlanType/
  // fixedChargingWindow no longer exist (Ritase-Cycle Engine, §10
  // CALCULATION_ENGINE.md). id/result field names kept as
  // "extend_window"/addHours/newWindow so screens.jsx's existing card
  // rendering doesn't need to change in this pass -- Screen 4's UI rework
  // should refresh the copy to describe the real lever (group count).
  const extCfg = PRI.find(p => p.id === "extend_window");
  let extResult = null;
  const baseGroups = Math.max(1, Math.round(s.proposedGroupCount) || 1);
  for (let addGroups = 1; addGroups <= 20; addGroups++) {
    const s2 = { ...s, proposedGroupCount: baseGroups + addGroups };
    const sizing2 = window.computeSizing(s2);
    if (!sizing2 || sizing2.chargingWindowHours > extCfg.maxWindowHours) break;
    const capex2 = window.computeCapex(s2, sizing2);
    if (capex2.tcoCapex <= budgetCap) {
      extResult = {
        addHours: Math.round((sizing2.chargingWindowHours - sizing.chargingWindowHours) * 10) / 10,
        newWindow: Math.round(sizing2.chargingWindowHours * 10) / 10,
        newGroupCount: baseGroups + addGroups,
        newChargerCount: sizing2.chargerCount,
        newRequiredPowerKw: sizing2.adjustedRequiredPowerKw,
        newCapexTotal: capex2.tcoCapex,
      };
      break;
    }
  }
  recs.push({ id: "extend_window", available: !!extResult, ...extResult });

  // 2. Downgrade charger rating
  const dcCfg = PRI.find(p => p.id === "downgrade_charger");
  let dcResult = null;
  const lowerRatings = dcCfg.ratingOptions.filter(r => r < sizing.chargerRatingKw).sort((a, b) => b - a);
  for (const rating of lowerRatings) {
    const s2 = { ...s, chargerRatingKw: rating };
    const sizing2 = window.computeSizing(s2);
    const capex2 = window.computeCapex(s2, sizing2);
    if (capex2.tcoCapex <= budgetCap) {
      dcResult = {
        oldRating: sizing.chargerRatingKw, newRating: rating,
        newChargerCount: sizing2.chargerCount,
        suggestedWindowHours: Math.ceil(sizing.chargingWindowHours * sizing.chargerRatingKw / rating),
        newCapexTotal: capex2.tcoCapex,
      };
      break;
    }
  }
  recs.push({ id: "downgrade_charger", available: !!dcResult, ...dcResult });

  // 3. Reduce redundancy factor
  const rrCfg = PRI.find(p => p.id === "reduce_redundancy");
  let rrResult = null;
  const currentRedundancy = ov["tab4.redundancyFactor"] ?? sizing.redundancyFactorDefault;
  for (let factor = currentRedundancy - rrCfg.stepDown; factor >= rrCfg.minRedundancy - 1e-9; factor -= rrCfg.stepDown) {
    const factorRounded = Number(factor.toFixed(2));
    const s2 = { ...s, screen4_valueOverrides: { ...ov, "tab4.redundancyFactor": factorRounded } };
    const sizing2 = window.computeSizing(s2);
    const capex2 = window.computeCapex(s2, sizing2);
    if (capex2.tcoCapex <= budgetCap) {
      rrResult = {
        oldRedundancy: currentRedundancy, newRedundancy: factorRounded,
        newChargerCount: sizing2.chargerCount,
        newCapexTotal: capex2.tcoCapex,
      };
      break;
    }
  }
  recs.push({ id: "reduce_redundancy", available: !!rrResult, ...rrResult });

  // 4. Phase deployment
  let phaseResult = null;
  if (s.plannedFleet5yr != null && s.plannedFleet5yr > sizing.fleetSize) {
    const s2 = { ...s, fleetSize: s.plannedFleet5yr };
    const sizing2 = window.computeSizing(s2);
    const capex2 = window.computeCapex(s2, sizing2);
    phaseResult = {
      phase1: { vehicles: sizing.fleetSize, chargerCount: sizing.chargerCount, capexTotal: capex.tcoCapex },
      phase2: {
        vehicles: s.plannedFleet5yr - sizing.fleetSize,
        chargerCount: Math.max(0, sizing2.chargerCount - sizing.chargerCount),
        capexTotal: Math.max(0, capex2.tcoCapex - capex.tcoCapex),
      },
      totalCapex: capex2.tcoCapex,
      phase1Fits: capex.tcoCapex <= budgetCap,
    };
  }
  recs.push({ id: "phase_deployment", available: !!phaseResult, ...phaseResult });

  // 5. Infeasible — only flagged if nothing above closes the gap
  const anyFits = !!extResult || !!dcResult || !!rrResult || (phaseResult && phaseResult.phase1Fits);
  recs.push({ id: "infeasible", available: !anyFits, gap: capex.tcoCapex - budgetCap });

  return recs.filter(r => r.available);
};
