/**
 * SPCAMO_UnitImport.js  —  Phase B
 * ─────────────────────────────────────────────────────────────────────────────
 * Implements the "IMPORT UNITS / LEADERS (JSON)" workflow.
 *
 * Phase B additions over Phase A:
 *   • Formation-level leader grouping: all units in a formation share the
 *     same leaderRangeFirst/Last range; each unit's S1_LEADER points to its
 *     own assigned leader within that range
 *   • S34_UNIT_ID wired correctly per leader record
 *   • Section 35 (formation records, 60 bytes × 400) written for new formations
 *   • All structural S1 fields (SIDE, FORM_ID, FORM_SUB_ID, LEADER,
 *     CARRIER, VALID, IS_OFF_MAP, LEADER_RANGE_*) are now written explicitly
 *     by the patched _writeUnitsToBuffer in the editor
 *
 * Dependencies (must be loaded before this file):
 *   SPCAMO_ScenData.js    — all S1_* / S34_* constants, nation/class tables,
 *                           parseSections, getSectionData, _rebuildFile
 *   SPCAMO_Translation.js — importUnitsFromJSON, SP1_CLASS_TO_SPWW2_ID
 *   SPCAMO_UnitsWW2.js    — SPWW2_OBAT
 *   SPCAMO_UnitsMBT.js    — SPMBT_OBAT
 *
 * Editor globals used:
 *   _ab, _units, _leaders, _gameType, _unitSnapshot
 *   _markDirty(), renderUnits(), renderLeaders(), _snapshotUnits()
 *   _writeUnitsToBuffer(), _writeUnitLocsToBuffer(), _writeLeadersToBuffer()
 *   parseSections(), getSectionData(), _rebuildFile()
 *   S1_* / S34_* / S17_* constants, SPCAMO_FORMATION_LETTERS
 *   SPWW2_NATIONS, SPMBT_NATIONS, SPWW2_UNIT_CLASSES, SPMBT_UNIT_CLASSES
 */

'use strict';

// ─────────────────────────────────────────────────────────────────────────────
// Module state
// ─────────────────────────────────────────────────────────────────────────────

let _uiSession = null;

// Name-based memo: srcName.toLowerCase() → { obatID, slotNo }
// Persists across imports within the browser session.
const _unitMemo = {};

// ─────────────────────────────────────────────────────────────────────────────
// SP1 Facing → SPCAMO Rotation (degrees)
//
// SP1 JSON exports encode unit facing as one of 8 compass-direction strings.
// SPCAMO (both SPWW2 and SPMBT) encodes facing as a u16 degree value written
// to S1 offset 148 (hull facing) and S1 offset 150 (turret facing).
//
// IMPORTANT: These are NOT simple clock-face degrees.  The values were
// determined empirically by placing units in known orientations in test
// scenarios and reading the resulting byte values from the DAT file.
//
//   SP1 string | SPCAMO degrees
//   ───────────┼───────────────
//   'E'        |    0
//   'SE'       |   63
//   'S'        |   90
//   'SW'       |  117
//   'W'        |  180
//   'NW'       |  243
//   'N'        |  270
//   'NE'       |  290
//
// Used by _resolveFacing() when the source JSON came from SP1.
// ─────────────────────────────────────────────────────────────────────────────
const SP1_FACING_TO_DEGREES = {
  'E':   0,
  'SE':  63,
  'S':   90,
  'SW':  117,
  'W':   180,
  'NW':  243,
  'N':   270,   // was absent from the original table — caused N-facing units
                //   to default to 0 (East) instead of the correct 270
  'NE':  290,
};

// ─────────────────────────────────────────────────────────────────────────────
// SP2 Facing → SPCAMO Rotation (degrees)
//
// SP2 JSON exports encode unit facing as a numeric degree value — but SP2's
// degree values are NOT the same as SPCAMO's for every direction.
// Six of the eight directions happen to share identical values, but N and NE
// use completely different numbers between the two games.
//
// This lookup table translates from SP2's degree space into SPCAMO's.
// It is applied by _resolveFacing() whenever the source JSON came from SP2.
//
//   Direction | SP2 value | SPCAMO value | Note
//   ──────────┼───────────┼──────────────┼─────────────────────────────
//   E         |     0     |      0       | identical — no translation needed
//   SE        |    63     |     63       | identical
//   S         |    90     |     90       | identical
//   SW        |   117     |    117       | identical
//   W         |   180     |    180       | identical
//   NW        |   243     |    243       | identical
//   N         |    14     |    270       | ← DIFFERS — must be translated
//   NE        |    41     |    290       | ← DIFFERS — must be translated
//
// All values verified through test scenarios (real SP2 DAT files compared
// against real SPCAMO/SPWW2/SPMBT DAT files with known unit orientations).
//
// Any SP2 facing value NOT present in this table is passed through unchanged
// as a safe fallback, so future game versions or edge-case values do not
// silently corrupt facing data by snapping everything to 0 (East).
// ─────────────────────────────────────────────────────────────────────────────
const SP2_FACING_TO_SPCAMO = {
    0:   0,    // E   — same in both games
   63:  63,    // SE  — same in both games
   90:  90,    // S   — same in both games
  117: 117,    // SW  — same in both games
  180: 180,    // W   — same in both games
  243: 243,    // NW  — same in both games
   14: 270,    // N   — SP2 stores 14; SPCAMO expects 270
   41: 290,    // NE  — SP2 stores 41; SPCAMO expects 290
};

// ─────────────────────────────────────────────────────────────────────────────
// SP1 JSON uses string mission types; SPCAMO uses u16 values at S1 offset 124.
// ─────────────────────────────────────────────────────────────────────────────
const SP1_BOMBARD_TYPE_MAP = {
  'No Mission':              0xFFFF,
  'HE Bombardment Mission':    1001,
  'SMOKE Bombardment Mission': 1002,
  'Smoke Bombardment Mission': 1002,
  'DPICM Bombardment Mission': 1003,
};

// ─────────────────────────────────────────────────────────────────────────────
// SP2 Bombardment mapping
// SP2 top-level bombardment uses short labels ("HE", "Smoke", "DPICM").
// SP2 per-unit bombardment uses ammoTypeRaw (u16: 401=HE, 402=Smoke, 406=DPICM).
// Both need to map into SPCAMO's S1 bombardType values (1001/1002/1003).
// ─────────────────────────────────────────────────────────────────────────────
const SP2_BOMBARD_RAW_MAP = {
  401:   1001,   // HE
  402:   1002,   // Smoke
  406:   1003,   // DPICM
  65535: 0xFFFF, // No Mission
};
const SP2_BOMBARD_LABEL_MAP = {
  'No Mission': 0xFFFF,
  'HE':         1001,
  'Smoke':      1002,
  'DPICM':      1003,
};

// ─────────────────────────────────────────────────────────────────────────────
// Facing resolver — translates source-game facing into SPCAMO degree values
//
// Called for both hull facing (S1 offset 148) and turret facing (S1 offset 150)
// during the unit-build pass in _commitImport().
//
// Parameters:
//   value      — the raw facing value from the source JSON:
//                  SP1: a direction string ("N","NE","E","SE","S","SW","W","NW")
//                  SP2: a numeric degree value (e.g. 14 for North, 41 for NE)
//   sourceGame — the string identifier set by importUnitsFromJSON(), e.g.
//                  'SP1', 'SP2', 'SP2v2', etc.  Only the prefix matters here.
//
// Returns a u16-safe SPCAMO degree value (0–65535), or 0 as a safe default
// when the value is null/undefined or an unrecognised SP1 direction string.
//
// Why SP2 needs translation:
//   SP2 and SPCAMO agree on six of eight directions (E, SE, S, SW, W, NW)
//   but diverge on N (SP2=14, SPCAMO=270) and NE (SP2=41, SPCAMO=290).
//   Passing SP2 values through unmodified would leave all North- and
//   NE-facing units pointing the wrong way after import.
//
// Fallback behaviour:
//   • Unrecognised SP2 numeric values are passed through unchanged rather
//     than defaulting to 0, so any future facing values that aren't yet in
//     SP2_FACING_TO_SPCAMO don't silently snap to East.
//   • Unrecognised SP1 direction strings return 0 (East) — an SP1 string
//     that isn't in SP1_FACING_TO_DEGREES indicates a data error, and East
//     is the least-damaging fallback for a ground unit.
// ─────────────────────────────────────────────────────────────────────────────

function _resolveFacing(value, sourceGame) {
  if (value == null) return 0;

  if (typeof value === 'number') {
    // SP2 exports numeric degrees that differ from SPCAMO's for N and NE.
    // Look the value up in the translation table; fall back to passing it
    // through unchanged if it isn't listed (future-proofing).
    if (sourceGame && sourceGame.startsWith('SP2')) {
      return (SP2_FACING_TO_SPCAMO[value] ?? value) & 0xFFFF;
    }
    // Any other numeric source: pass through as-is (no known translation needed).
    return value & 0xFFFF;
  }

  // SP1: facing is a compass-direction string — look it up in the table.
  // Unknown strings return 0 (East) as a safe default.
  return SP1_FACING_TO_DEGREES[value] ?? 0;
}

// ─────────────────────────────────────────────────────────────────────────────
// S35 constants  (not in ScenData.js — defined here)
// Section 35: Formation data — 400 × 60-byte records
//   Records 0–199  = P1 formations (indexed by formID)
//   Records 200–399 = P2 formations (indexed by formID + 200)
// ─────────────────────────────────────────────────────────────────────────────

const S35_RECORD_SIZE  = 60;
const S35_TOTAL        = 400;   // records 0–399, sequential across both sides
const S35_NAME         =  0;    // 16 bytes, ASCIIZ (NUL-terminated, NOT 0x0A)
const S35_NAME_LEN     = 16;
const S35_OOBRID       = 16;    // u16 LE — OOB formation type record ID
                                 //   (0=platoon/sub, 1=battalion HQ, 2=company, etc.)
const S35_HCMD         = 18;    // u16 LE — S34 leader index of HIGHER COMMAND leader
                                 //   (battalion CO for companies/platoons; self for BatHQ)
const S35_LEADER       = 20;    // u16 LE — S34 leader index of THIS formation's own leader
const S35_PLAYER       = 28;    // u16 LE — side: 0 = P1, 1 = P2
const S35_FORM_ID      = 30;    // u16 LE — per-side sequential formation ID (resets to 0 per player)
const S35_STATUS       = 32;    // u8  — campaign status (3-bit field)

// Legacy aliases kept so existing read paths don't break immediately
const S35_VP_COST      = S35_OOBRID;
const S35_PARENT_SLOT  = S35_HCMD;
const S35_FIRST_SLOT   = S35_LEADER;
const S35_NATION       = 22;    // inside __data00 — unknown; we zero this block
const S35_FIRST_SLOT2  = 26;    // inside __data00 — unknown; we zero this block
const S35_FORM_IDX     = S35_FORM_ID;
const S35_ACTIVE       = 35;    // __data01[2] — always 1 for active records in observed data

// ─────────────────────────────────────────────────────────────────────────────
// Public entry point
// ─────────────────────────────────────────────────────────────────────────────

function importUnitsJSON() {
  if (!_ab) { alert('Load a SPCAMO DAT file first.'); return; }

  const obatTable = _gameType === 'mbt'
    ? (typeof SPMBT_OBAT !== 'undefined' ? SPMBT_OBAT : null)
    : (typeof SPWW2_OBAT !== 'undefined' ? SPWW2_OBAT : null);

  if (!obatTable) {
    alert('Unit database not loaded.\n\nMake sure ' +
      (_gameType === 'mbt' ? 'SPCAMO_UnitsMBT.js' : 'SPCAMO_UnitsWW2.js') +
      ' is included alongside the editor.');
    return;
  }

  const fi = document.getElementById('units-json-input');
  fi.onchange = e => {
    const file = e.target.files[0];
    fi.value = '';
    if (!file) return;
    const r = new FileReader();
    r.onload = ev => {
      try {
        const data = JSON.parse(ev.target.result);
        if (!data.units) throw new Error('No "units" object found. Is this a SP1/SP2 JSON export?');

        // MATCH_CANDIDATES and ALIAS_BOOST are defined in SPCAMO_UnitImport_Names.js
        const result = importUnitsFromJSON(data, obatTable, { topN: MATCH_CANDIDATES });

        // ── SP1 → SPWW2 name alias pre-processing ────────────────────────────
        // Re-run matching with translated names. Because an explicit alias is
        // authoritative, translated-name candidates get a score boost so they
        // outrank anything the original fuzzy pass produced. Scores are also
        // updated (not just merged) so a candidate already present at a low
        // score is correctly promoted if the translated-name search rates it
        // ── Alias re-scoring pass ──────────────────────────────────────────────
        // Apply name aliases (SP1→SPWW2 or SP2→SPMBT) to improve matching.
        const shouldAlias = (result.sourceGame === 'SP1' && _gameType === 'ww2')
                         || (result.sourceGame.startsWith('SP2') && _gameType === 'mbt');
        if (shouldAlias) {
          // ALIAS_BOOST defined in SPCAMO_UnitImport_Names.js

          result.units.forEach(entry => {
            const origName = entry.source.name || '';
            const obatID = result.nationMap[entry.source._nationKey];
            if (obatID == null || !obatTable[obatID]) return;

            // ── Fallthrough-aware alias resolution ────────────────────────────
            //
            // _resolveAliasTargets returns an ordered list of OBAT name targets
            // for this unit.  For standard [regex, name] aliases the list has one
            // entry and behaviour is identical to the old _translateName path.
            // For fallthrough [regex, primary, fallback] aliases the list has
            // multiple entries and we select the first one that is year-available
            // in the OBAT — so a scenario set in 1965 gets IS-2 while the same
            // source unit in a 1985 scenario gets T-55AM automatically.
            const rawTable = (result.sourceGame && result.sourceGame.startsWith('SP2'))
              ? (typeof _SP2_NAME_ALIASES !== 'undefined' ? _SP2_NAME_ALIASES : { _common: [] })
              : _SP1_NAME_ALIASES;
            const { targets, matched: aliasMatched, note: aliasNote } = _resolveAliasTargets(origName, rawTable, obatID);
            if (!aliasMatched) return;  // no alias pattern fired for this unit

            // Store the note (if any) on the entry so the detail pane can display it.
            // This is the only place _aliasNote is written — it's set once per unit
            // during the alias pass and read later by _selectUnit() for rendering.
            if (aliasNote) entry._aliasNote = aliasNote;

            const year      = result.scenario.year || 1944;
            const nationArr = obatTable[obatID];

            // For fallthrough entries: walk the target list and pick the first
            // name that has a year-available OBAT entry for this nation.
            // For standard single-target entries: skip the loop, use target[0].
            // If no fallthrough candidate passes the year filter, the primary
            // (targets[0]) is used as a safe fallback so fuzzy candidates still
            // appear in the import modal for manual selection.
            let translatedName = targets[0];
            if (targets.length > 1) {
              for (const candidate of targets) {
                const key = candidate.trim().toLowerCase();
                const yearAvail = Object.values(nationArr).some(u =>
                  u && (u[1] || '').trim().toLowerCase() === key && _yearInRange(u, year)
                );
                if (yearAvail) { translatedName = candidate; break; }
              }
            }
            const synth     = { ...entry.source, name: translatedName };
            const fromAlias = _findCandidates(synth, obatID, obatTable[obatID], year, MATCH_CANDIDATES);

            // Explicit exact-name scan of the full OBAT nation array.
            // _findCandidates only returns the top N fuzzy results — if the true
            // alias target lost to a near-neighbour (e.g. "SdKfz 250/1" buried
            // under "SdKfz 251/1") it may not appear in fromAlias at all, making
            // any score boost irrelevant.  This guarantees the exact target is present.
            // Note: nationArr was already declared above during the year-filter pass.
            const exactKey  = translatedName.trim().toLowerCase();
            const aliasSlots = new Set(fromAlias.map(c => c.slot));
            Object.entries(nationArr).forEach(([slotStr, unit]) => {
              if (!unit) return;
              if ((unit[1] || '').trim().toLowerCase() !== exactKey) return;
              const slot = parseInt(slotStr, 10);
              if (!aliasSlots.has(slot)) {
                fromAlias.push({ obatID, slot, unit, score: 0, reasons: ['exact name (injected)'] });
                aliasSlots.add(slot);
              }
            });

            // Build a lookup: slot → boosted score for alias candidates.
            // Exact matches get EXACT_MATCH_BOOST on top of ALIAS_BOOST so they
            // always outrank fuzzy near-neighbours regardless of score gaps.
            const aliasBySlot = new Map(
              fromAlias.map(c => {
                const isExact = (c.unit[1] || '').trim().toLowerCase() === exactKey;
                const bonus   = ALIAS_BOOST + (isExact ? EXACT_MATCH_BOOST : 0);
                const reasons = isExact ? [...c.reasons, 'exact name match'] : c.reasons;
                return [c.slot, { score: c.score + bonus, reasons }];
              })
            );

            // Update scores for candidates already in the list
            entry.candidates.forEach(c => {
              const hit = aliasBySlot.get(c.slot);
              if (hit && hit.score > c.score) {
                c.score   = hit.score;
                c.reasons = [...c.reasons, 'alias re-score'];
              }
            });

            // Add candidates that were not in the list at all
            const existingSlots = new Set(entry.candidates.map(c => c.slot));
            fromAlias.forEach(c => {
              if (!existingSlots.has(c.slot)) {
                const isExact = (c.unit[1] || '').trim().toLowerCase() === exactKey;
                const bonus   = ALIAS_BOOST + (isExact ? EXACT_MATCH_BOOST : 0);
                entry.candidates.push({
                  ...c,
                  score:   c.score + bonus,
                  reasons: [...c.reasons, isExact ? 'exact name match' : 'alias match'],
                });
              }
            });

            // Re-sort and trim
            entry.candidates.sort((a, b) => b.score - a.score);
            entry.candidates = entry.candidates.slice(0, MATCH_CANDIDATES);

            // If alias injection added candidates but chosen was still -1,
            // auto-select the top candidate
            if (entry.chosen === -1 && entry.candidates.length > 0) entry.chosen = 0;
          });
        }

        // ── HQ slot injection (slots 243-245 for the first unit of each side) ─
        // These slots are reserved for HQ/command units in every OBAT nation.
        // The first unit in each side's list is always the battalion HQ.
        // SP1 uses index 0 (P1) / 100 (P2); SP2 indices vary per scenario.
        const firstP1 = result.units.find(e => e.source._side === 'P1');
        const firstP2 = result.units.find(e => e.source._side === 'P2');
        const sideFirstEntries = [firstP1, firstP2].filter(Boolean);
        sideFirstEntries.forEach(entry => {
          const src = entry.source;

          const obatID = result.nationMap[src._nationKey];
          if (obatID == null || !obatTable[obatID]) return;

          const existingSlots = new Set(entry.candidates.map(c => c.slot));
          const year = result.scenario.year || 1944;

          // Inject slots 243, 244, 245 if present in this OBAT and not already listed
          [243, 244, 245].forEach(hqSlot => {
            if (existingSlots.has(hqSlot)) return;
            const unit = obatTable[obatID][hqSlot];
            if (!unit) return;
            // Build a minimal candidate entry with a fixed "HQ slot" score
            entry.candidates.push({
              obatID,
              slot: hqSlot,
              unit,
              score: unit[3] === 55 ? 60 : 30,  // class 55 = HQ → higher score
              reasons: ['HQ reserved slot'],
            });
          });

          // Re-sort so injected HQ slots appear near the top if they score well
          entry.candidates.sort((a, b) => b.score - a.score);
          entry.candidates = entry.candidates.slice(0, MATCH_CANDIDATES + 3); // allow a few extra for HQ

          // If injection added candidates but chosen was still -1 (no prior matches),
          // auto-select the top candidate so the unit isn't silently skipped.
          if (entry.chosen === -1 && entry.candidates.length > 0) entry.chosen = 0;
        });

        // ── Apply existing memos ──────────────────────────────────────────────
        // For each unit whose name matches a stored memo, force the memo'd
        // candidate to be selected. If the memo'd slot isn't in the candidate
        // list (didn't score high enough), inject it directly from the OBAT so
        // the memo is never silently ignored.
        result.units.forEach(entry => {
          const key = (entry.source.name || '').trim().toLowerCase();
          const memo = _unitMemo[key];
          if (!memo) return;

          // Try to find the memo'd candidate in the existing list
          let mi = entry.candidates.findIndex(
            c => c.obatID === memo.obatID && c.slot === memo.slotNo
          );

          // If not found, inject it from the OBAT so the memo is honoured
          if (mi < 0 && obatTable[memo.obatID]?.[memo.slotNo]) {
            const unit = obatTable[memo.obatID][memo.slotNo];
            entry.candidates.push({
              obatID:  memo.obatID,
              slot:    memo.slotNo,
              unit,
              score:   0,  // was below threshold, injected via memo
              reasons: ['memo (injected)'],
            });
            mi = entry.candidates.length - 1;
          }

          if (mi >= 0) entry.chosen = mi;
        });

        _openUnitImportModal(result, data);
      } catch (err) {
        alert('Unit JSON import error:\n' + err.message);
      }
    };
    r.readAsText(file);
  };
  fi.click();
}

// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────

function _resolveRankIdx(rankName) {
  const idx = S34_RANK_NAMES.indexOf(rankName);
  return idx >= 0 ? idx : 3; // default Sergeant
}

function _parseFormation(formStr) {
  const m = (formStr || '').match(/^([A-Z]+)(\d+)$/);
  if (!m) return { formLetter: 'A', formSubID: 0 };
  return { formLetter: m[1], formSubID: parseInt(m[2]) };
}

function _countFreeSlots(side) {
  const start = side === 'p1' ? 0 : S1_P2_OFFSET;
  const end   = side === 'p1' ? S1_P2_OFFSET : S1_TOTAL_UNITS;
  const used  = new Set([...(_units.p1||[]), ...(_units.p2||[])].map(u => u.index));
  let n = 0;
  for (let i = start; i < end; i++) if (!used.has(i)) n++;
  return n;
}

function _allocateSlot(side, usedSet) {
  const start = side === 'p1' ? 0 : S1_P2_OFFSET;
  const end   = side === 'p1' ? S1_P2_OFFSET : S1_TOTAL_UNITS;
  for (let i = start; i < end; i++) {
    if (!usedSet.has(i)) { usedSet.add(i); return i; }
  }
  return -1;
}

function _esc(s) {
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _kv(k, v) {
  return `<div class="uimp-kv"><span class="uimp-k">${_esc(k)}</span><span class="uimp-v">${_esc(String(v))}</span></div>`;
}
function _cs(k, v) {
  return `<div class="uimp-cand-stat"><span class="sk">${_esc(k)}</span> ${_esc(String(v ?? '—'))}</div>`;
}

// ─────────────────────────────────────────────────────────────────────────────
// Unit name alias translation
// SP1/SP2 use different abbreviations for equipment that SPWW2/SPMBT renamed.
// Applied as a secondary matching pass so original names are still tried first.
// ─────────────────────────────────────────────────────────────────────────────

// _SP1_NAME_ALIASES, _SP2_NAME_ALIASES, _getAliasesForNation,
// _NATION_ALIAS_GROUPS, MATCH_CANDIDATES, and ALIAS_BOOST
// are defined in SPCAMO_UnitImport_Names.js.

/**
 * Translate a SP1 unit name into its likely SPWW2 equivalent for better matching.
 * Returns the original name unchanged if no aliases matched.
 *
 * @param {string} name   - SP1 unit name
 * @param {number} obatID - SPWW2 OBAT nation index (e.g. 16 = Germany, 11 = Soviet Union)
 */
function _translateSP1Name(name, obatID) {
  const aliases = _getAliasesForNation(_SP1_NAME_ALIASES, obatID);
  let result = name;
  for (const [pattern, replacement] of aliases) {
    result = result.replace(pattern, replacement);
  }
  return result;
}

/**
 * Translate a SP2 unit name into its likely SPMBT equivalent for better matching.
 * Returns the original name unchanged if no aliases matched.
 *
 * @param {string} name   - SP2 unit name
 * @param {number} obatID - SPMBT OBAT nation index (e.g. 12 = USA, 11 = Russia, 44 = Germany)
 */
function _translateSP2Name(name, obatID) {
  if (typeof _SP2_NAME_ALIASES === 'undefined') return name;
  const aliases = _getAliasesForNation(_SP2_NAME_ALIASES, obatID);
  if (!aliases.length) return name;
  let result = name;
  for (const [pattern, replacement] of aliases) {
    result = result.replace(pattern, replacement);
  }
  return result;
}

/**
 * Pick the right alias translator based on source format.
 *
 * @param {string} name       - unit name from source JSON
 * @param {string} sourceGame - 'SP1' or 'SP2...'
 * @param {number} obatID     - OBAT nation index for nation-specific alias selection
 */
function _translateName(name, sourceGame, obatID) {
  if (sourceGame && sourceGame.startsWith('SP2')) return _translateSP2Name(name, obatID);
  return _translateSP1Name(name, obatID);
}

/**
 * Check if any alias pattern matched the name (even if the output is identical).
 * Needed because same-name aliases like "M3A1 Bradley" → "M3A1 Bradley" exist
 * specifically to trigger the EXACT_MATCH_BOOST for cross-class matching.
 *
 * @param {string} name       - unit name from source JSON
 * @param {string} sourceGame - 'SP1' or 'SP2...'
 * @param {number} obatID     - OBAT nation index for nation-specific alias selection
 */
function _didAliasMatch(name, sourceGame, obatID) {
  const rawTable = (sourceGame && sourceGame.startsWith('SP2'))
    ? (typeof _SP2_NAME_ALIASES !== 'undefined' ? _SP2_NAME_ALIASES : { _common: [] })
    : _SP1_NAME_ALIASES;
  const table = _getAliasesForNation(rawTable, obatID);
  for (const [pattern] of table) {
    pattern.lastIndex = 0;  // reset regex state for /g flag
    if (pattern.test(name)) {
      pattern.lastIndex = 0;  // reset again for subsequent use
      return true;
    }
  }
  return false;
}

// ─────────────────────────────────────────────────────────────────────────────
// Modal open / close
// ─────────────────────────────────────────────────────────────────────────────

function _openUnitImportModal(result, rawJSON) {
  _uiSession = { result, rawJSON };
  let overlay = document.getElementById('uimp-overlay');
  if (overlay) overlay.remove();
  overlay = document.createElement('div');
  overlay.id = 'uimp-overlay';
  overlay.innerHTML = _buildModalHTML(result);
  document.body.appendChild(overlay);
  _updateProgress();
  // Restore light-blue highlights for any memos already active from a previous import
  _applyAllNameMemos();
}

function _closeUnitImportModal() {
  const el = document.getElementById('uimp-overlay');
  if (el) el.remove();
  _uiSession = null;
}

// ─── Icon set radio button toggle ────────────────────────────────────────────
function _uimpIconToggle(side) {
  const sel = document.querySelector(`input[name="uimp-${side}-icon"]:checked`)?.value || 'summer';
  const wPct = document.getElementById(`uimp-${side}-winter-pct`);
  const dPct = document.getElementById(`uimp-${side}-desert-pct`);
  if (wPct) wPct.disabled = (sel !== 'winter');
  if (dPct) dPct.disabled = (sel !== 'desert');
}

// ─────────────────────────────────────────────────────────────────────────────
// Modal HTML builder
// ─────────────────────────────────────────────────────────────────────────────

function _buildModalHTML(result) {
  const { units, sourceGame, leaders } = result;
  const p1  = units.filter(e => e.source._side === 'P1').length;
  const p2  = units.filter(e => e.source._side === 'P2').length;
  const unm = units.filter(e => e.candidates.length === 0).length;

  return `
<style>
#uimp-overlay {
  position:fixed;inset:0;z-index:9000;background:rgba(28,25,23,.55);
  display:flex;align-items:stretch;justify-content:center;
  padding:24px;font-family:var(--mono);font-size:12px;backdrop-filter:blur(2px);
}
#uimp-modal {
  background:var(--bg2);border:1px solid var(--border2);border-radius:4px;
  display:flex;flex-direction:column;width:100%;max-width:1120px;
  overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.18);
}
#uimp-header {
  flex-shrink:0;background:var(--bg3);border-bottom:1px solid var(--border2);
  padding:10px 16px;display:flex;align-items:center;gap:12px;
}
.uimp-title { font-family:var(--disp);font-size:11px;font-weight:700;color:var(--green);letter-spacing:.1em; }
.uimp-stats { font-size:10px;color:var(--text-dim); }
.uimp-stats b { color:var(--text); }
.uimp-spacer { flex:1; }
.uimp-warn { font-size:10px;color:var(--amber);font-weight:700; }
.uimp-close-btn, .uimp-accept-btn {
  padding:4px 12px;font-family:var(--disp);font-size:9px;font-weight:700;
  letter-spacing:.08em;text-transform:uppercase;cursor:pointer;border-radius:2px;
}
.uimp-close-btn  { background:var(--bg2);border:1px solid var(--border2);color:var(--text-dim); }
.uimp-close-btn:hover { border-color:var(--red);color:var(--red); }
.uimp-accept-btn { background:var(--green-lo);border:1px solid var(--green);color:var(--green); }
.uimp-accept-btn:hover { background:var(--green);color:var(--bg2); }
#uimp-body { flex:1;display:flex;overflow:hidden; }
#uimp-list {
  width:230px;flex-shrink:0;border-right:1px solid var(--border2);
  overflow-y:auto;background:var(--bg);
}
.uimp-side-hdr {
  padding:5px 8px;font-family:var(--disp);font-size:9px;font-weight:700;
  letter-spacing:.08em;color:var(--text-dim);
  background:var(--bg3);border-bottom:1px solid var(--border2);
  position:sticky;top:0;z-index:1;
}
.uimp-unit-row {
  display:flex;align-items:center;gap:6px;padding:5px 8px;cursor:pointer;
  border-bottom:1px solid var(--border);font-size:11px;
}
.uimp-unit-row:hover { background:var(--bg3); }
.uimp-unit-row.active { background:var(--green-lo);border-left:3px solid var(--green);padding-left:5px; }
.uimp-unit-row.name-memo { background:#dbeafe; }               /* light blue — all rows sharing a memo'd name */
.uimp-unit-row.name-memo:hover { background:#bfdbfe; }
.uimp-unit-row.name-memo.active { background:var(--green-lo);border-left:3px solid var(--green);padding-left:5px; }
.uimp-uname { flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.uimp-form  { font-size:9px;color:var(--text-dim);flex-shrink:0; }
.uimp-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--green-dim); }
.uimp-dot.warn { background:var(--amber); }
.uimp-dot.err  { background:var(--red); }
.uimp-dot.memo { background:var(--blue); }
.uimp-dot.note { background:var(--amber);outline:2px solid var(--amber-dim);outline-offset:1px; }
.uimp-dot.skip { background:var(--neutral); }
#uimp-detail { flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:12px; }
.uimp-sec-hdr {
  font-family:var(--disp);font-size:9px;font-weight:700;letter-spacing:.08em;
  color:var(--green-dim);text-transform:uppercase;
  border-bottom:1px solid var(--border2);padding-bottom:4px;margin-bottom:6px;
}
.uimp-src-grid { display:grid;grid-template-columns:repeat(4,1fr);gap:4px 12px; }
.uimp-kv { display:flex;flex-direction:column; }
.uimp-k { font-size:9px;color:var(--text-dim); }
.uimp-v { font-size:11px;color:var(--text); }
.uimp-candidates { display:flex;flex-direction:column;gap:6px; }
.uimp-cand {
  border:1px solid var(--border2);border-radius:3px;background:var(--bg);
  padding:8px 10px;cursor:pointer;transition:border-color .1s;
  display:flex;gap:10px;align-items:flex-start;
}
.uimp-cand:hover { border-color:var(--green-dim); }
.uimp-cand.sel  { border-color:var(--green);background:var(--green-lo); }
.uimp-cand.none { border-style:dashed;opacity:.7; }
.uimp-cand input[type=radio] { margin-top:2px;flex-shrink:0;accent-color:var(--green); }
.uimp-cbody { flex:1; }
.uimp-cname { font-weight:700;font-size:12px;color:var(--text); }
.uimp-cscore { font-weight:400;font-size:10px;color:var(--text-dim);margin-left:6px; }
.uimp-cmeta { font-size:10px;color:var(--text-dim);margin-top:2px; }
.uimp-creasons { font-size:9px;color:var(--green-dim);margin-top:2px; }
.uimp-cstats { display:grid;grid-template-columns:repeat(5,1fr);gap:2px 8px;margin-top:5px; }
.uimp-cstat { font-size:10px; }
.uimp-cstat .sk { color:var(--text-dim); }
.uimp-cwpn { font-size:9px;color:var(--text-dim);margin-top:3px; }
.uimp-memo-badge { font-size:9px;padding:1px 5px;border-radius:2px;background:var(--blue-dim);color:var(--blue);flex-shrink:0; }
.uimp-memo-row { display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px;color:var(--text-dim); }
.uimp-memo-row input[type=checkbox] { accent-color:var(--green); }
.uimp-ldrs-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:5px; }
.uimp-ldr { border:1px solid var(--border2);border-radius:3px;background:var(--bg);padding:5px 8px;font-size:10px; }
.uimp-ldr-name { font-weight:700;color:var(--text); }
.uimp-ldr-meta { color:var(--text-dim); }
#uimp-footer {
  flex-shrink:0;background:var(--bg3);border-top:1px solid var(--border2);
  padding:8px 16px;display:flex;align-items:center;gap:10px;font-size:10px;
}
#uimp-prog { flex:1; }
.uimp-prog-wrap { height:6px;background:var(--border2);border-radius:3px;overflow:hidden;margin-top:2px; }
.uimp-prog-bar  { height:100%;background:var(--green);border-radius:3px;transition:width .2s; }

/* Icon set controls */
.uimp-icon-bar {
  flex-shrink:0;background:var(--bg3);border-bottom:1px solid var(--border2);
  padding:6px 16px;display:flex;gap:24px;align-items:center;font-size:10px;color:var(--text-dim);
}
.uimp-icon-grp { display:flex;align-items:center;gap:6px; }
.uimp-icon-grp label { display:flex;align-items:center;gap:3px;cursor:pointer; }
.uimp-icon-grp b { color:var(--text);font-size:10px;margin-right:4px; }
.uimp-icon-grp input[type=radio] { margin:0; }
.uimp-pct-input {
  width:36px;background:var(--bg2);border:1px solid var(--border2);color:var(--text);
  font-family:var(--mono);font-size:10px;padding:1px 3px;border-radius:2px;text-align:center;
}
.uimp-pct-input:disabled { opacity:.35;background:var(--bg3); }
/* Alias note callout — amber, shown above candidates when an alias carries commentary */
.uimp-alias-note {
  border:1px solid var(--amber-dim);border-radius:3px;background:var(--bg3);
  padding:7px 10px;margin-bottom:6px;font-size:10px;color:var(--amber);
  display:flex;gap:8px;align-items:flex-start;
}
.uimp-alias-note-icon { flex-shrink:0;font-size:13px;line-height:1.3; }
.uimp-alias-note-body { flex:1; }
.uimp-alias-note-title { font-family:var(--disp);font-size:9px;font-weight:700;
  letter-spacing:.07em;text-transform:uppercase;margin-bottom:3px; }
.uimp-alias-note-text  { color:var(--text-dim);line-height:1.5;font-size:12px; }
</style>

<div id="uimp-modal">
  <div id="uimp-header">
    <div class="uimp-title">IMPORT UNITS / LEADERS</div>
    <div class="uimp-stats">
      Source: <b>${result.sourceGame}</b> &nbsp;·&nbsp;
      Units: <b>${units.length}</b> (${p1}&nbsp;P1 / ${p2}&nbsp;P2) &nbsp;·&nbsp;
      Leaders: <b>${leaders.length}</b> &nbsp;·&nbsp;
      Free slots: P1 <b>${_countFreeSlots('p1')}</b> / P2 <b>${_countFreeSlots('p2')}</b>
    </div>
    ${unm ? `<div class="uimp-warn">⚠ ${unm} unmatched</div>` : ''}
    <div class="uimp-spacer"></div>
    <button class="uimp-close-btn" onclick="_closeUnitImportModal()">CANCEL</button>
    &nbsp;
    <button class="uimp-accept-btn" onclick="_acceptImport()">✓ ACCEPT ALL &amp; WRITE</button>
  </div>

  <div class="uimp-icon-bar">
    <div class="uimp-icon-grp">
      <b>P1 Icons:</b>
      <label><input type="radio" name="uimp-p1-icon" value="summer" checked onchange="_uimpIconToggle('p1')"> Summer</label>
      <label><input type="radio" name="uimp-p1-icon" value="winter" onchange="_uimpIconToggle('p1')"> Winter</label>
      <input type="number" id="uimp-p1-winter-pct" class="uimp-pct-input" min="0" max="100" value="0" disabled>%
      <label><input type="radio" name="uimp-p1-icon" value="desert" onchange="_uimpIconToggle('p1')"> Desert</label>
      <input type="number" id="uimp-p1-desert-pct" class="uimp-pct-input" min="0" max="100" value="0" disabled>%
    </div>
    <div class="uimp-icon-grp">
      <b>P2 Icons:</b>
      <label><input type="radio" name="uimp-p2-icon" value="summer" checked onchange="_uimpIconToggle('p2')"> Summer</label>
      <label><input type="radio" name="uimp-p2-icon" value="winter" onchange="_uimpIconToggle('p2')"> Winter</label>
      <input type="number" id="uimp-p2-winter-pct" class="uimp-pct-input" min="0" max="100" value="0" disabled>%
      <label><input type="radio" name="uimp-p2-icon" value="desert" onchange="_uimpIconToggle('p2')"> Desert</label>
      <input type="number" id="uimp-p2-desert-pct" class="uimp-pct-input" min="0" max="100" value="0" disabled>%
    </div>
    <div style="margin-left:auto;display:flex;align-items:center;gap:16px;">
      <div style="display:flex;align-items:center;gap:5px;font-size:10px;color:var(--text-dim);">
        <span style="font-family:var(--disp);font-size:9px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;">
          Scenario Date:
        </span>
        <span style="color:var(--amber);font-weight:700;font-size:15px;">
          ${result.scenario.month ? _esc(result.scenario.month) + ' ' : ''}${result.scenario.year || '—'}
        </span>
      </div>
      <div style="display:flex;align-items:center;gap:16px;">
        <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-weight:700;color:var(--text);">
          <input type="checkbox" id="uimp-keep-names-p1" style="accent-color:var(--p1);margin:0;">
          Keep P1 Unit Names
        </label>
        <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-weight:700;color:var(--text);">
          <input type="checkbox" id="uimp-keep-names-p2" style="accent-color:var(--p2);margin:0;">
          Keep P2 Unit Names
        </label>
      </div>
    </div>
  </div>

  <div id="uimp-body">
    <div id="uimp-list">${_buildListHTML(units)}</div>
    <div id="uimp-detail" style="color:var(--text-dim);padding:24px">
      ← Select a unit on the left to review match candidates.
    </div>
  </div>

  <div id="uimp-footer">
    <div id="uimp-prog">
      <span id="uimp-prog-text"></span>
      <div class="uimp-prog-wrap"><div class="uimp-prog-bar" id="uimp-prog-bar" style="width:0%"></div></div>
    </div>
    <button class="uimp-close-btn" onclick="_closeUnitImportModal()">CANCEL</button>
    <button class="uimp-accept-btn" onclick="_acceptImport()">✓ ACCEPT ALL &amp; WRITE</button>
  </div>
</div>`;
}

// ─────────────────────────────────────────────────────────────────────────────
// Unit list (left panel)
// ─────────────────────────────────────────────────────────────────────────────

function _buildListHTML(units) {
  let html = '', lastSide = null;
  units.forEach((e, i) => {
    const side = e.source._side;
    if (side !== lastSide) {
      html += `<div class="uimp-side-hdr">${side} — ${side === 'P1' ? 'Player 1' : 'Player 2'}</div>`;
      lastSide = side;
    }
    const key  = (e.source.name || '').trim().toLowerCase();
    const memo = !!_unitMemo[key];
    const dot  = e.candidates.length === 0 ? 'err'
               : e.chosen === -1           ? 'skip'
               : memo                      ? 'memo'
               : e.chosen > 0             ? 'warn'
               : e._aliasNote             ? 'note'  // amber ring — alias remapped this unit; see detail pane
               :                            '';
    html += `<div class="uimp-unit-row" data-ui="${i}" onclick="_selectUnit(${i})">
      <div class="uimp-dot ${dot}"></div>
      <div class="uimp-uname">${_esc(e.source.name || '?')}</div>
      <div class="uimp-form">${_esc(e.source.formation || '')}</div>
    </div>`;
  });
  return html;
}

// ─────────────────────────────────────────────────────────────────────────────
// Detail pane (right panel)
// ─────────────────────────────────────────────────────────────────────────────

function _selectUnit(unitIdx) {
  if (!_uiSession) return;
  const { result } = _uiSession;
  const entry = result.units[unitIdx];
  _uiSession.activeUnit = unitIdx;

  document.querySelectorAll('.uimp-unit-row').forEach(r =>
    r.classList.toggle('active', parseInt(r.dataset.ui) === unitIdx)
  );

  const pane = document.getElementById('uimp-detail');
  if (!pane) return;
  const src  = entry.source;
  const key  = (src.name || '').trim().toLowerCase();
  const memo = !!_unitMemo[key];

  const myLeaders = (result.leaders || []).filter(l => {
    const lUnitRef = l.unitId ?? l.unitRecordId;
    const srcRef   = src.unitNum ?? src.index;
    if (lUnitRef !== srcRef) return false;
    // SP1 leaders have .side; SP2 don't — skip side check if absent
    if (l.side != null && l.side !== src._side) return false;
    return true;
  });

  pane.innerHTML = `
    <div>
      <div class="uimp-sec-hdr">Source Unit (${result.sourceGame}) — ${_esc(src._side)}</div>
      <div class="uimp-src-grid">
        ${_kv('Name',       src.name || '—')}
        ${_kv('Formation',  src.formation || '—')}
        ${_kv('Class',      src.unitClass || '—')}
        ${_kv('Experience', src.experience ?? '—')}
        ${_kv('Morale',     src.morale ?? '—')}
        ${_kv('Speed',      src.speed ?? '—')}
        ${_kv('Location',   src.mapLocation || '—')}
        ${_kv('Hull Facing', src.hullFacing || '—')}
        ${_kv('Turret Facing', src.turretFacing || '—')}
        ${_kv('OOB#',       src.oobNum ?? '—')}
        ${_kv('Nation',     src.nation || (src.countryIdx ?? '—'))}
        ${_kv('unitNum',    src.unitNum ?? '—')}
      </div>
      ${src.weapons?.length ? `<div style="margin-top:5px;font-size:9px;color:var(--text-dim)">Weapons: ${src.weapons.map(w=>_esc(w.name||w.id||'?')).join(', ')}</div>` : ''}
    </div>

    <div>
      <div class="uimp-sec-hdr">SPCAMO Match Candidates</div>
      ${entry._aliasNote ? `
      <div class="uimp-alias-note">
        <div class="uimp-alias-note-icon">⚠</div>
        <div class="uimp-alias-note-body">
          <div class="uimp-alias-note-title">Alias note — why this unit was remapped</div>
          <div class="uimp-alias-note-text">${_esc(entry._aliasNote)}</div>
        </div>
      </div>` : ''}
      ${memo ? `<div style="margin-bottom:6px;font-size:10px;color:var(--blue)">ℹ Memo active — preferred match pre-selected.</div>` : ''}
      ${entry.candidates.length === 0
        ? `<div style="color:var(--red);font-size:11px">No matching OBAT units found for this nation/class combination. Unit will be skipped.</div>`
        : _buildCandCards(entry, unitIdx, key, memo)
      }
    </div>

    ${myLeaders.length ? `
    <div>
      <div class="uimp-sec-hdr">Leaders assigned to this unit (${myLeaders.length})</div>
      <div class="uimp-ldrs-grid">
        ${myLeaders.map(l=>`
          <div class="uimp-ldr">
            <div class="uimp-ldr-name">${_esc(l.name)}</div>
            <div class="uimp-ldr-meta">${_esc(l.rank)} · ${_esc(l.assignedUnit||'')}</div>
            <div class="uimp-ldr-meta">Rly ${l.rally} · Inf ${l.infantry} · Art ${l.artillery} · Arm ${l.armor}</div>
          </div>`).join('')}
      </div>
    </div>` : ''}
  `;
}

function _buildCandCards(entry, unitIdx, nameKey, hasMemo) {
  const nations = _gameType === 'mbt' ? SPMBT_NATIONS : SPWW2_NATIONS;
  const classes = _gameType === 'mbt' ? SPMBT_UNIT_CLASSES : SPWW2_UNIT_CLASSES;

  let html = '<div class="uimp-candidates">';

  entry.candidates.forEach((c, ci) => {
    const u    = c.unit;
    const sel  = entry.chosen === ci;
    const isMemo = hasMemo &&
      _unitMemo[nameKey]?.obatID === c.obatID &&
      _unitMemo[nameKey]?.slotNo === c.slot;
    const wpns = (u[28]||[]).filter(w=>w[0]).map(w=>w[1]).join(', ');
    const fy   = u[15] + 1900, ly = u[17] + 1900;

    html += `
    <div class="uimp-cand${sel?' sel':''}" data-ci="${ci}" onclick="_selectCandidate(${unitIdx},${ci})">
      <input type="radio" name="cand-${unitIdx}" ${sel?'checked':''} onchange="_selectCandidate(${unitIdx},${ci})">
      <div class="uimp-cbody">
        <div class="uimp-cname">${_esc(u[1])}<span class="uimp-cscore">score ${c.score}</span>${isMemo?' <span class="uimp-memo-badge">MEMO</span>':''}</div>
        <div class="uimp-cmeta">OBAT ${c.obatID} (${_esc(nations[c.obatID]||'?')}) · slot ${c.slot} · ${_esc(classes[u[3]]||'?')} · ${fy}–${ly}</div>
        <div class="uimp-creasons">${c.reasons.join(' · ')||'low confidence'}</div>
        <div class="uimp-cstats">
          ${_cs('Speed',u[7])} ${_cs('Cost',u[4])} ${_cs('Size',u[5])}
          ${_cs('Vision',u[20])} ${_cs('Crew',u[6])}
          ${_cs('FC',u[18])} ${_cs('Stab',u[19])} ${_cs('RoF',u[22])}
          ${_cs('Swim',u[8])} ${_cs('Surv',u[11])}
        </div>
        ${wpns?`<div class="uimp-cwpn">Wpns: ${_esc(wpns)}</div>`:''}
      </div>
    </div>`;
  });

  // Skip option
  html += `
    <div class="uimp-cand none${entry.chosen===-1?' sel':''}" data-ci="-1" onclick="_selectCandidate(${unitIdx},-1)">
      <input type="radio" name="cand-${unitIdx}" ${entry.chosen===-1?'checked':''} onchange="_selectCandidate(${unitIdx},-1)">
      <div class="uimp-cbody">
        <div class="uimp-cname" style="color:var(--text-dim)">— Skip / omit this unit</div>
        <div class="uimp-cmeta">This unit will not be written to the DAT file.</div>
      </div>
    </div>
  </div>`;

  // Memo checkbox
  html += `
  <div class="uimp-memo-row">
    <input type="checkbox" id="memo-cb-${unitIdx}" ${hasMemo?'checked':''}
           onchange="_toggleMemo(${unitIdx},this.checked)">
    <label for="memo-cb-${unitIdx}">Use this match for all future "<b>${_esc(entry.source.name)}</b>" units</label>
    ${hasMemo?'<span class="uimp-memo-badge">MEMO ACTIVE</span>':''}
  </div>`;

  return html;
}

// ─────────────────────────────────────────────────────────────────────────────
// UI event handlers
// ─────────────────────────────────────────────────────────────────────────────

function _selectCandidate(unitIdx, ci) {
  if (!_uiSession) return;
  _uiSession.result.units[unitIdx].chosen = ci;

  document.querySelectorAll('.uimp-cand').forEach(card => {
    const cardCi = parseInt(card.dataset.ci);
    card.classList.toggle('sel', cardCi === ci);
    const radio = card.querySelector('input[type=radio]');
    if (radio) radio.checked = (cardCi === ci);
  });

  _refreshDot(unitIdx);
  _updateProgress();
}

function _toggleMemo(unitIdx, checked) {
  if (!_uiSession) return;
  const entry  = _uiSession.result.units[unitIdx];
  const key    = (entry.source.name || '').trim().toLowerCase();
  const chosen = entry.candidates[entry.chosen];

  if (checked && chosen) {
    _unitMemo[key] = { obatID: chosen.obatID, slotNo: chosen.slot };
  } else {
    delete _unitMemo[key];
  }

  // Propagate the selection to ALL other units in this session with the same name.
  // This ensures ACCEPT uses the memo'd match for every same-name unit, not just
  // the one whose checkbox was ticked.
  _applyMemoToAllSameName(key);

  // Refresh visuals
  _refreshDot(unitIdx);
  _refreshNameMemoRows(key);
}

/**
 * For every unit in the current session whose source name matches key,
 * apply the active memo (or clear the forced selection if memo was removed).
 */
function _applyMemoToAllSameName(key) {
  if (!_uiSession) return;
  const memo  = _unitMemo[key];
  const units = _uiSession.result.units;

  units.forEach((entry, idx) => {
    const entryKey = (entry.source.name || '').trim().toLowerCase();
    if (entryKey !== key) return;

    if (memo) {
      // Find or inject the memo'd candidate
      let mi = entry.candidates.findIndex(
        c => c.obatID === memo.obatID && c.slot === memo.slotNo
      );
      if (mi < 0) {
        // Inject from OBAT if not already a candidate
        const obatTable = _gameType === 'mbt'
          ? (typeof SPMBT_OBAT !== 'undefined' ? SPMBT_OBAT : null)
          : (typeof SPWW2_OBAT !== 'undefined' ? SPWW2_OBAT : null);
        if (obatTable?.[memo.obatID]?.[memo.slotNo]) {
          entry.candidates.push({
            obatID:  memo.obatID,
            slot:    memo.slotNo,
            unit:    obatTable[memo.obatID][memo.slotNo],
            score:   0,
            reasons: ['memo (injected)'],
          });
          mi = entry.candidates.length - 1;
        }
      }
      if (mi >= 0) entry.chosen = mi;
    } else {
      // Memo cleared — revert to top scorer (index 0) unless already skipped
      if (entry.chosen !== -1) entry.chosen = 0;
    }
  });
}

function _refreshDot(unitIdx) {
  if (!_uiSession) return;
  const e   = _uiSession.result.units[unitIdx];
  const key = (e.source.name || '').trim().toLowerCase();
  const dot = document.querySelector(`.uimp-unit-row[data-ui="${unitIdx}"] .uimp-dot`);
  if (!dot) return;
  dot.className = 'uimp-dot ' + (
    e.candidates.length === 0 ? 'err'
    : e.chosen === -1          ? 'skip'
    : _unitMemo[key]           ? 'memo'
    : e.chosen > 0             ? 'warn'
    : e._aliasNote             ? 'note'   // amber ring — alias note present; see detail pane
    :                            ''
  );
}

/**
 * Sweep all unit rows in the list and apply/remove the name-memo background
 * for every row whose source name matches the given key.
 * Called after a memo is set or cleared.
 */
function _refreshNameMemoRows(changedKey) {
  if (!_uiSession) return;
  const units = _uiSession.result.units;
  const memoActive = !!_unitMemo[changedKey];

  document.querySelectorAll('.uimp-unit-row[data-ui]').forEach(row => {
    const idx   = parseInt(row.dataset.ui);
    const entry = units[idx];
    if (!entry) return;
    const rowKey = (entry.source.name || '').trim().toLowerCase();
    if (rowKey !== changedKey) return;

    row.classList.toggle('name-memo', memoActive);
    // Also update the dot for every sibling row
    _refreshDot(idx);
  });
}

/** Apply name-memo backgrounds for all currently active memos on initial open. */
function _applyAllNameMemos() {
  if (!_uiSession) return;
  Object.keys(_unitMemo).forEach(key => _refreshNameMemoRows(key));
}

function _updateProgress() {
  if (!_uiSession) return;
  const units   = _uiSession.result.units;
  const writing = units.filter(e => e.chosen >= 0 && e.candidates.length > 0).length;
  const total   = units.length;
  const pct     = total ? Math.round(writing / total * 100) : 0;
  const bar = document.getElementById('uimp-prog-bar');
  const txt = document.getElementById('uimp-prog-text');
  if (bar) bar.style.width = pct + '%';
  if (txt) txt.textContent =
    `${writing} of ${total} units will be written  ·  ${total - writing} skipped/unmatched`;
}

// ─────────────────────────────────────────────────────────────────────────────
// ACCEPT — build objects and write to all four sections
// ─────────────────────────────────────────────────────────────────────────────

function _acceptImport() {
  if (!_uiSession) return;
  const { result } = _uiSession;

  const toWrite = result.units.filter(e => e.chosen >= 0 && e.candidates.length > 0);
  if (!toWrite.length) { alert('No units selected for import.'); return; }

  // ── "Keep imported unit names" toggles (per side) ─────────────────────────
  const keepNamesP1 = !!document.getElementById('uimp-keep-names-p1')?.checked;
  const keepNamesP2 = !!document.getElementById('uimp-keep-names-p2')?.checked;

  // ── Guard against double-import ────────────────────────────────────────────
  const existingCount = (_units.p1||[]).length + (_units.p2||[]).length;
  if (existingCount > 1) {  // >1 because blank scenarios have 1 HQ stub
    const newP1 = toWrite.filter(e => e.source._side === 'P1').length;
    const newP2 = toWrite.filter(e => e.source._side === 'P2').length;
    if (!confirm(
      'This scenario already has ' + existingCount + ' units.\n\n' +
      'Import will ADD ' + toWrite.length + ' more (' + newP1 + ' P1 / ' + newP2 + ' P2).\n\n' +
      'If you already imported units, clicking OK will duplicate them.\n\n' +
      'Continue?'
    )) return;
  }

  // ── Slot allocation ───────────────────────────────────────────────────────
  const usedSlots    = new Set([...(_units.p1||[]), ...(_units.p2||[])].map(u => u.index));
  const unitNumToIdx   = {};  // SP1 unitNum  (1-based) → allocated absolute S1 index
  const unitIndexToIdx = {};  // SP1 unit.index (0-based) → allocated absolute S1 index

  // formID allocation — S1_FORM_ID is a GLOBAL index into S35, not per-side.
  // P2 formIDs must continue past ALL P1 formIDs (existing and newly allocated),
  // so P2 units point at their own S35 records rather than colliding with P1's.
  // We set nextFID.p2 AFTER P1 units are built, once we know the full P1 range.
  const existingFIDs = {
    p1: new Set((_units.p1||[]).map(u => u.formID).filter(f => f != null)),
    p2: new Set((_units.p2||[]).map(u => u.formID).filter(f => f != null)),
  };
  const globalMaxExisting = Math.max(-1, ...existingFIDs.p1, ...existingFIDs.p2);
  const nextFID = {
    p1: existingFIDs.p1.size ? Math.max(...existingFIDs.p1) + 1 : 0,
    p2: -1,  // set after P1 pass — see below
  };
  // letter → formID maps per side (built as we encounter new formations)
  const formLetterToFID = { p1: {}, p2: {} };

  function getFormID(sk, letter) {
    if (formLetterToFID[sk][letter] === undefined)
      formLetterToFID[sk][letter] = nextFID[sk]++;
    return formLetterToFID[sk][letter];
  }

  // ── unitMode allocation ───────────────────────────────────────────────────
  // unitMode is a sequential purchase-group ID across the WHOLE scenario.
  // P1 units occupy groups 0..N, P2 units continue from N+1.
  // The game uses this (in addition to S1_SIDE) to determine side and nationality
  // flag display — P2 units must have unitMode values strictly above all P1 values.
  //
  // We assign one unitMode per formation letter (same formation = same type = same group).
  // New groups start past the highest existing unitMode across both sides.
  const existingMaxUnitMode = Math.max(
    0,
    ...(_units.p1||[]).map(u => u.unitMode ?? 0),
    ...(_units.p2||[]).map(u => u.unitMode ?? 0),
  );
  let nextUnitMode = { p1: existingMaxUnitMode + 1, p2: 0 }; // p2 offset set after P1 pass
  const formLetterToUnitMode = { p1: {}, p2: {} };

  function getUnitMode(sk, letter, classID) {
    // HQ class (55) always gets group 0 for P1, (maxP1+1) for P2 — handled after P1 pass
    if (formLetterToUnitMode[sk][letter] === undefined)
      formLetterToUnitMode[sk][letter] = nextUnitMode[sk]++;
    return formLetterToUnitMode[sk][letter];
  }

  // ── First pass: build new unit objects ────────────────────────────────────
  // Process P1 first, then P2, so P2 unitModes start after all P1 groups are known.
  const newUnits = { p1: [], p2: [] };

  function buildUnit(entry) {
    const src    = entry.source;
    const cand   = entry.candidates[entry.chosen];
    const obUnit = cand.unit;
    const sk     = src._side.toLowerCase();
    const sideNum = sk === 'p1' ? 0 : 1;
    // Honor the per-side "Keep Unit Names" toggle for this unit's side.
    const keepNames = (sk === 'p1') ? keepNamesP1 : keepNamesP2;

    const slotIdx = _allocateSlot(sk, usedSlots);
    if (slotIdx < 0) { console.warn(`No free ${src._side} slot — skipping ${src.name}`); return; }
    unitNumToIdx[src.unitNum] = slotIdx;
    if (src.index != null) unitIndexToIdx[src.index] = slotIdx;

    const { formLetter, formSubID } = _parseFormation(src.formation || 'A0');
    const formID    = getFormID(sk, formLetter);
    const unitMode  = getUnitMode(sk, formLetter, obUnit[3]);

    // Determine position from mapLocation string (most reliable signal).
    // SP1 uses 255,255 for both off-map AND carried units so we can't rely on
    // coords alone — mapLocation is the authoritative discriminator.
    // SP2 uses empty/comma mapLocation for carried units (no "Passenger" prefix).
    const isOffMap  = src.mapLocation === 'OFF MAP';
    const mapLoc    = String(src.mapLocation || '');
    const isCarried = !isOffMap && src.transporterId != null && (
      mapLoc.startsWith('Passenger') ||                 // SP1 format
      mapLoc === ',' || mapLoc === ''                     // SP2 format: empty coords
    );
    // Carried units get 0xFFFF coords (removed from map); off-map get 255.
    const xloc = (isOffMap || isCarried) ? 255 : (src.xloc ?? 255);
    const yloc = (isOffMap || isCarried) ? 255 : (src.yloc ?? 255);

    // Status: 9 = Passenger if carried, 0 = Ready otherwise
    const status = 0;

    // Altitude: 1 = Nap of Earth for helicopters, 0 = Ground/Landed otherwise
    const heliClasses = new Set([53, 203, 204, 205, 221]);
    const altitude = heliClasses.has(obUnit[3]) ? 1 : 0;

    // Armor from OBAT array [29] (21 values: 0-6=steel, 7-13=heat, 14-20=ERA)
    const ar = obUnit[29] || [];
    const armor = {
      fhAp:  ar[0]||0, fhHeat:  ar[7]||0,  fhEra:  ar[14]||0,
      shAp:  ar[1]||0, shHeat:  ar[8]||0,  shEra:  ar[15]||0,
      rhAp:  ar[2]||0, rhHeat:  ar[9]||0,  rhEra:  ar[16]||0,
      ftAp:  ar[3]||0, ftHeat:  ar[10]||0, ftEra:  ar[17]||0,
      stAp:  ar[4]||0, stHeat:  ar[11]||0, stEra:  ar[18]||0,
      rtAp:  ar[5]||0, rtHeat:  ar[12]||0, rtEra:  ar[19]||0,
      topAp: ar[6]||0, topHeat: ar[13]||0, topEra: ar[20]||0,
    };

    // Weapons from OBAT array [28] (each weapon: [id, name, he, ap, sound, heat, sabot])
    const obWpns  = (obUnit[28] || []).slice(0, 4);
    const weapons = Array.from({ length: 4 }, (_, i) => {
      const w = obWpns[i];
      if (!w || !w[0]) return { id:0, sound:0, he:0, ap:0, sabot:0, heat:0, smoke:0 };
      return { id:w[0], sound:w[4]||0, he:w[2]||0, ap:w[3]||0, sabot:w[6]||0, heat:w[5]||0, smoke:0 };
    });
    if (src.ammo?.smoke != null) weapons[0].smoke = src.ammo.smoke;

    // ── Max fire range — highest range across all equipped weapon slots ───────
    // Looks up each weapon ID in SPMBT_OB_Weapons / SPWW2_OB_Weapons and takes
    // the max, which is then written to S1 offset 127 (S1_MAX_FIRE_RANGE).
    const _wpnRangeTable = (_gameType === 'mbt')
      ? (typeof SPMBT_OB_Weapons !== 'undefined' ? SPMBT_OB_Weapons : null)
      : (typeof SPWW2_OB_Weapons !== 'undefined' ? SPWW2_OB_Weapons : null);
    const _nationWpns = _wpnRangeTable?.[cand.obatID] ?? {};
    const maxFireRange = weapons.reduce((best, wp) => {
      if (!wp.id) return best;
      const r = _nationWpns[wp.id]?.[1] ?? 0;
      return r > best ? r : best;
    }, 0);

    newUnits[sk].push({
      // ── Core identity ──
      index:            slotIdx,
      side:             sideNum,       // S1_SIDE (written by patched writeSub)
      // ── Formation ──
      formID,                           // S1_FORM_ID
      formSubID,                        // S1_FORM_SUB_ID
      get formLetter()    { return SPCAMO_FORMATION_LETTERS[this.formID] || ('Form#' + this.formID); },
      get formationCode() { return (SPCAMO_FORMATION_LETTERS[this.formID] || ('Form#' + this.formID)) + this.formSubID; },
      // ── Leader / carrier (defaults — overwritten in later passes) ──
      leader:           0xFFFF,         // S1_LEADER
      leaderRangeFirst: 0xFFFF,         // S1_LEADER_RANGE_FIRST
      leaderRangeLast:  0xFFFF,         // S1_LEADER_RANGE_LAST
      carrier:          isCarried ? 0xFFFE : 0xFFFF,  // placeholder
      passengerLink:    0xFFFF,    // no passenger chain
      loadedMen:        0,         // no passengers loaded
      // ── Flags ──
      valid:            1,
      entr:             0,
      isOffMap,
      isOnMap:          !isCarried,  // mirrors readSPCAMOUnits: carrier === 0xFFFF
      // ── State ──
      status,
      statusName:       S1_STATUS_NAMES[status] || ('Status#' + status),
      altitude,
      altitudeName:     S1_ALTITUDE_NAMES[altitude] || ('Alt#' + altitude),
      // leaderStatus mirrors readSPCAMOUnits: computed from leader slot value
      get leaderStatus() { return this.leader === 0xFFFF ? 'Abandoned' : 'Manned'; },
      // ── OBAT identity ──
      name:             (keepNames ? (src.name || obUnit[1] || '') : (obUnit[1] || src.name || '')).slice(0, S1_NAME_LEN),
      _keepImportedName: keepNames && !!(src.name),  // force stat_modified so game preserves custom name
      classID:          obUnit[3] || 0,
      obatNationID:     cand.obatID,
      nationID:         cand.obatID,
      // S1_OBAT_ID (offset 163) = slot number within the nation's OBAT.
      // obUnit[2] is the nation code inside the OBAT array — NOT the slot.
      // Writing obUnit[2] here causes the game to look up the wrong unit
      // (e.g. Germany obUnit[2]=16 → OBAT[16][16] = Dicker Max, not PzKw IV C).
      obatID:           cand.slot,
      // S1_SLOT_ID (offset 168, u16) = 0xFFFF for all scenario units (non-purchased).
      // Real scenarios universally use 0xFFFF here; cand.slot belongs in S1_OBAT_ID above.
      slotID:           0xFFFF,
      // ── Position ──
      xloc, yloc,
      // ── Stats from OBAT ──
      normalIcon:       obUnit[26] || 0,
      winterIcon:       obUnit[26] || 0,
      desertIcon:       obUnit[26] || 0,
      picture:          obUnit[27] || 0,
      rotation1:        _resolveFacing(src.hullFacing, result.sourceGame),
      rotation2:        _resolveFacing(src.turretFacing, result.sourceGame),
      radio:            obUnit[10] || 0,
      size:             obUnit[5]  || 0,
      damageUnit:       0,
      smokeDischarger:  obUnit[21] || 0,
      men:              obUnit[6]  || 0,
      speed:            obUnit[7]  || 0,
      suppression:      0,
      waterSpeed:       obUnit[8]  || 0,
      experience:       Math.max(1, Math.min(255, src.experience || 60)),
      morale:           Math.max(1, Math.min(255, src.morale    || 60)),
      vision:           obUnit[20] || 0,
      fireControl:      obUnit[18] || 0,
      cost:             obUnit[4]  || 0,
      rateOfFire:       obUnit[22] || 0,
      stabilizer:       obUnit[19] || 0,
      rangeFinder:      obUnit[24] || 0,
      ew:               obUnit[23] || 0,
      carryCapacity:    obUnit[12] || 0,
      costToCarry:      obUnit[13] || 0,
      survivability:    obUnit[11] || 0,
      unitMode,
      armor, weapons,
      maxFireRange,                     // S1_MAX_FIRE_RANGE (offset 127)
      // ── Bombardment (defaults — overwritten from JSON bombardment data) ──
      bombardType:      0xFFFF,    // No Mission
      bombardTypeName:  'No Mission',
      bombardXloc:      0,
      bombardYloc:      0,
      bombardSpotter:   0,
      // ── Scratch (removed before merge) ──
      _sk:              sk,
      _formLetter:      formLetter,
      _srcUnitNum:      src.unitNum ?? src.index,
      _isCarried:       isCarried,
      // transporterId in SP1 JSON is the 0-based unit index, not unitNum
      _transportSrcIdx: isCarried ? src.transporterId : null,
      // SP2 per-unit bombardment data (if present)
      _srcBombardment:  src.bombardment || null,
    });
  }  // end buildUnit

  // Build P1 first so we know the full P1 unitMode range before starting P2
  const p1Entries = toWrite.filter(e => e.source._side === 'P1');
  const p2Entries = toWrite.filter(e => e.source._side === 'P2');

  nextUnitMode.p1 = existingMaxUnitMode + 1;
  p1Entries.forEach(buildUnit);

  // P2 formIDs must continue past ALL P1 formIDs (existing + newly allocated).
  // nextFID.p1 now holds the next free P1 formID, so P2 starts there.
  // Also account for any pre-existing P2 formIDs.
  nextFID.p2 = Math.max(nextFID.p1, globalMaxExisting + 1);

  // P2 unitModes must be strictly greater than all P1 values
  nextUnitMode.p2 = nextUnitMode.p1;  // continues from where P1 ended
  p2Entries.forEach(buildUnit);

  const allNew = [...newUnits.p1, ...newUnits.p2];

  // ── Second pass: resolve carriers & build loading chains ─────────────────
  // transporterId in SP1 JSON is the 0-based unit index — use unitIndexToIdx
  //
  // SPCAMO loading is a doubly-linked list:
  //   carrier (+175):        passenger → next passenger → … → transport (forward)
  //   passengerLink (+129):  transport → last passenger → prev → … → 0xFFFF (reverse)
  //   loadedMen (+131):      transport: sum of passenger men; passenger: 3 (cost flag)
  //   S17 xloc/yloc:         0xFFFF for all loaded passengers

  // Group passengers by their resolved transport slot index
  const transportPassengers = {};  // transportSlotIdx → [passenger units in order]
  allNew.forEach(u => {
    if (!u._isCarried || u._transportSrcIdx == null) return;
    const transportSlot = unitIndexToIdx[u._transportSrcIdx];
    if (transportSlot == null) {
      // Transport wasn't imported — drop unit back to ground
      u.carrier       = 0xFFFF;
      u.passengerLink = 0xFFFF;
      u.loadedMen     = 0;
      u.isOnMap       = true;
      return;
    }
    if (!transportPassengers[transportSlot]) transportPassengers[transportSlot] = [];
    transportPassengers[transportSlot].push(u);
  });

  // Wire each transport's passenger chain
  for (const [tSlotStr, passengers] of Object.entries(transportPassengers)) {
    const tSlot = Number(tSlotStr);
    const transport = allNew.find(x => x.index === tSlot);
    if (!transport) continue;

    // Build forward chain: first → second → … → last → transport
    // Build reverse chain: transport → last → … → first → 0xFFFF
    let totalMen = 0;
    for (let i = 0; i < passengers.length; i++) {
      const p = passengers[i];
      // Forward: each passenger's carrier points to the next, last points to transport
      p.carrier       = (i < passengers.length - 1) ? passengers[i + 1].index : tSlot;
      // Reverse: each passenger's passengerLink points to previous, first = 0xFFFF
      p.passengerLink = (i > 0) ? passengers[i - 1].index : 0xFFFF;
      p.loadedMen     = 3;   // carry-cost flag for passengers
      p.isOnMap       = false;
      p.xloc          = 0xFFFF;
      p.yloc          = 0xFFFF;
      totalMen       += p.men || 0;
    }
    // Transport: passengerLink = last passenger (chain head), loadedMen = total
    transport.passengerLink = passengers[passengers.length - 1].index;
    transport.loadedMen     = totalMen;
  }

  // ── Icon resolution pass ─────────────────────────────────────────────────
  // Look up winter/desert icons from the dedicated icon databases
  // (SPWW2_Icons / SPMBT_Icons) and apply based on user's radio button selection.
  {
    const iconDB = (_gameType === 'mbt')
      ? (typeof SPMBT_Icons !== 'undefined' ? SPMBT_Icons : null)
      : (typeof SPWW2_Icons !== 'undefined' ? SPWW2_Icons : null);

    ['p1', 'p2'].forEach(side => {
      const sel = document.querySelector(`input[name="uimp-${side}-icon"]:checked`)?.value || 'summer';
      const pctInput = document.getElementById(`uimp-${side}-${sel === 'winter' ? 'winter' : 'desert'}-pct`);
      const summerPct = (sel === 'winter' || sel === 'desert')
        ? Math.max(0, Math.min(100, parseInt(pctInput?.value) || 0))
        : 0;

      const sideUnits = newUnits[side];
      if (!sideUnits.length) return;

      // Decide which units get summer override (random selection by percentage)
      let summerOverrideSet = new Set();
      if (summerPct > 0 && sideUnits.length > 0) {
        const count = Math.round(sideUnits.length * summerPct / 100);
        // Shuffle indices and pick first N
        const indices = sideUnits.map((_, i) => i);
        for (let i = indices.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [indices[i], indices[j]] = [indices[j], indices[i]];
        }
        indices.slice(0, count).forEach(i => summerOverrideSet.add(i));
      }

      sideUnits.forEach((u, idx) => {
        // Look up icons from the icon database
        const nationIcons = iconDB ? iconDB[u.obatNationID] : null;
        const entry = nationIcons ? nationIcons[u.obatID] : null;
        // entry = [name, summerIcon, winterIcon, desertIcon] or undefined

        const summerIcon = entry ? entry[1] : (u.normalIcon || 0);
        const winterIcon = entry ? entry[2] : summerIcon;
        const desertIcon = entry ? entry[3] : summerIcon;

        // Determine which icon to use as the base
        let chosenIcon;
        if (sel === 'summer' || summerOverrideSet.has(idx)) {
          chosenIcon = summerIcon;
        } else if (sel === 'winter') {
          chosenIcon = winterIcon;
        } else {
          chosenIcon = desertIcon;
        }

        // Write the chosen icon into all three slots
        u.normalIcon = chosenIcon;
        u.winterIcon = chosenIcon;
        u.desertIcon = chosenIcon;
      });
    });
  }

  // ── Third pass: leader assignment ────────────────────────────────────────
  //
  // S1_LEADER  → S34 slot of this unit's own assigned leader (0xFFFF if none).
  // S1_LEADER_RANGE_FIRST / LAST → complex formation-tree chain pointers whose
  //   exact semantics depend on battalion structure we don't have.
  //   Setting both to 0xFFFF is confirmed safe (game handles it, Shelter Bunkers
  //   in real scenarios use this exact value with no issues).
  //
  // S34_UNIT_ID → absolute S1 unit index of the unit this leader is assigned to.

  const usedLeaderSlots = new Set((_leaders || []).map(l => l.index));
  function allocLeader() {
    for (let i = 0; i < S34_SLOT_COUNT; i++) {
      if (!usedLeaderSlots.has(i)) { usedLeaderSlots.add(i); return i; }
    }
    return -1;
  }

  const newLeaders = [];

  // Group new units by side+formationLetter (still needed to identify which
  // source leaders belong to which imported unit)
  const formGroups = {};
  allNew.forEach(u => {
    const gkey = `${u._sk}:${u._formLetter}`;
    if (!formGroups[gkey]) formGroups[gkey] = { units: [], sk: u._sk };
    formGroups[gkey].units.push(u);
  });

  // formLeaderMap: { 'sk:formLetter' → { ownLeader: s34slot, isHQ: bool } }
  // Used by _writeFormationsToBuffer to fill S35_LEADER and S35_HCMD.
  const formLeaderMap = {};

  // For each unit: find its SP1 source leaders, allocate S34 slots, wire S1_LEADER.
  // leaderRangeFirst/Last are always left at 0xFFFF — safe, confirmed by real scenarios.
  Object.values(formGroups).forEach(group => {
    group.units.forEach(u => {
      const srcSide = u._sk === 'p1' ? 'P1' : 'P2';
      const myLeaders = (result.leaders || []).filter(l => {
        const lUnitRef = l.unitId ?? l.unitRecordId;
        if (lUnitRef !== u._srcUnitNum) return false;
        if (l.side != null && l.side !== srcSide) return false;
        return true;
      });
      if (!myLeaders.length) return;

      myLeaders.forEach((srcLeader, i) => {
        const s34slot = allocLeader();
        if (s34slot < 0) return;

        // Wire S1_LEADER to first allocated slot for this unit
        if (i === 0) {
          u.leader = s34slot;
          // Track the first-unit leader for each formation (formation leader)
          const gkey = `${u._sk}:${u._formLetter}`;
          if (!formLeaderMap[gkey]) {
            const isHQ = u.classID === 55;
            formLeaderMap[gkey] = { ownLeader: s34slot, isHQ };
          }
        }

        newLeaders.push({
          index:      s34slot,
          name:       (srcLeader.name || '').slice(0, S34_NAME_LEN - 1),
          rank:       _resolveRankIdx(srcLeader.rank),
          rankName:   srcLeader.rank || 'Sergeant',
          unitID:     u.index,       // absolute S1 index → S34_UNIT_ID
          rally:      Math.max(0, Math.min(255, srcLeader.rally     || 50)),
          infantry:   Math.max(0, Math.min(255, srcLeader.infantry  || 50)),
          artillery:  Math.max(0, Math.min(255, srcLeader.artillery || 50)),
          armor:      Math.max(0, Math.min(255, srcLeader.armor     || 50)),
          kills:      0,
          status:     1,
          statusName: S34_STATUS_NAMES[1],
        });
      });
    });
  });

  // ── Apply bombardment missions ───────────────────────────────────────────
  // Two sources of bombardment data:
  //   1. Per-unit: src.bombardment (SP2 embeds bombardment in each unit object)
  //      Fields: ammoType, ammoTypeRaw, targetX, targetY, spotterId
  //   2. Top-level: rawJSON.bombardment[] (SP1 uses this; SP2 also exports it)
  //      Fields: index, missionType, targetX, targetY, spotterIdx
  // Strategy: apply per-unit first (most reliable), then top-level as fallback.
  const rawJSON = _uiSession.rawJSON;
  const isSP2   = result.sourceGame.startsWith('SP2');

  // Helper to apply a resolved bombardment to a unit
  function _applyBombard(u, spType, targetX, targetY, spotterSrcIdx) {
    if (!u || spType == null || spType === 0xFFFF) return;
    u.bombardType     = spType;
    u.bombardTypeName = S1_BOMBARD_TYPE_NAMES[spType] || ('Type#' + spType);
    u.bombardXloc     = targetX ?? 0;
    u.bombardYloc     = targetY ?? 0;
    u.bombardSpotter  = unitIndexToIdx[spotterSrcIdx] ?? 0;
  }

  // ── Pass 1: per-unit bombardment (SP2 format, also safe if SP1 ever adds it)
  allNew.forEach(u => {
    // _srcBombardment was stashed during buildUnit (see below)
    const b = u._srcBombardment;
    if (!b) return;

    // SP2 per-unit: { ammoType, ammoTypeRaw, targetX, targetY, spotterId }
    if (b.ammoTypeRaw != null) {
      const spType = SP2_BOMBARD_RAW_MAP[b.ammoTypeRaw];
      _applyBombard(u, spType, b.targetX, b.targetY, b.spotterId);
      return;
    }
    // SP2 per-unit with label only (fallback)
    if (b.ammoType != null) {
      const spType = SP2_BOMBARD_LABEL_MAP[b.ammoType];
      _applyBombard(u, spType, b.targetX, b.targetY, b.spotterId);
      return;
    }
  });

  // ── Pass 2: top-level bombardment array (SP1 format; also SP2 fallback)
  // Only apply if per-unit didn't already set this unit's bombardment.
  if (rawJSON.bombardment && Array.isArray(rawJSON.bombardment)) {
    rawJSON.bombardment.forEach(b => {
      // Resolve the bombardment type from the missionType string
      let spType;
      if (isSP2) {
        spType = SP2_BOMBARD_LABEL_MAP[b.missionType];
      } else {
        spType = SP1_BOMBARD_TYPE_MAP[b.missionType];
      }
      if (spType == null || spType === 0xFFFF) return;  // skip No Mission

      const slotIdx = unitIndexToIdx[b.index];
      if (slotIdx == null) return;  // source unit was not imported

      // Find the unit — skip if already set by per-unit pass
      const u = allNew.find(x => x.index === slotIdx);
      if (!u || u.bombardType !== 0xFFFF) return;

      _applyBombard(u, spType, b.targetX, b.targetY, b.spotterIdx);
    });
  }

  // ── Clean scratch fields ──────────────────────────────────────────────────
  allNew.forEach(u => {
    delete u._sk; delete u._formLetter; delete u._srcUnitNum;
    delete u._isCarried; delete u._transportSrcIdx; delete u._srcBombardment;
  });

  // ── Merge into editor in-memory state ────────────────────────────────────
  _units.p1 = [...(_units.p1||[]), ...newUnits.p1];
  _units.p2 = [...(_units.p2||[]), ...newUnits.p2];
  _leaders  = [...(_leaders ||[]), ...newLeaders];

  // ── Write S1 / S17 / S34 / S35 back to _ab ───────────────────────────────
  try {
    _ab = _writeUnitsToBuffer(_ab);        // S1 — now includes all structural fields
    _ab = _writeUnitLocsToBuffer(_ab);     // S17
    _ab = _writeLeadersToBuffer(_ab);      // S34 — now includes S34_UNIT_ID
    _ab = _writeFormationsToBuffer(_ab, allNew, formLetterToFID, formLeaderMap);  // S35
  } catch (err) {
    alert('Write-back error: ' + err.message +
          '\nIn-memory state updated; save the file to preserve changes.');
    console.error(err);
  }

  _unitSnapshot = _snapshotUnits(_units);
  _markDirty();
  renderUnits();
  renderLeaders();

  const nU = allNew.length;
  const nL = newLeaders.length;
  const nF = Object.keys(formLetterToFID.p1).length + Object.keys(formLetterToFID.p2).length;

  _closeUnitImportModal();

  const btn = document.getElementById('units-import-btn');
  if (btn) {
    btn.textContent = `✓ ${nU} units · ${nL} leaders · ${nF} formations written`;
    btn.style.color = 'var(--amber)';
    setTimeout(() => { btn.textContent = 'IMPORT UNITS / LEADERS (JSON)'; btn.style.color = ''; }, 4500);
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Section 35 writer
// ─────────────────────────────────────────────────────────────────────────────
//
// S35: 400 × 60 bytes.  Records 0–199 = P1 formations, 200–399 = P2.
// We read the existing section data, patch in-place for each new formation,
// and rebuild via _rebuildFile so the RLE compression is re-applied.
//
// Parameters:
//   buf              — current ArrayBuffer
//   newUnitsFlat     — flat array of new unit objects (scratch fields already removed)
//   formLetterToFID  — { p1: { letter → formID }, p2: { letter → formID } }
//   formLeaderMap    — { 'sk:letter' → { ownLeader: s34slot, isHQ: bool } }

function _writeFormationsToBuffer(buf, newUnitsFlat, formLetterToFID, formLeaderMap) {
  const sections = parseSections(buf);
  if (!sections[35]) return buf;

  const s35 = getSectionData(sections[35]);
  if (!s35) return buf;

  const needed = S35_TOTAL * S35_RECORD_SIZE;
  const out    = new Uint8Array(Math.max(s35.length, needed));
  out.set(s35);

  // ── Find the next free S35 record slot ──────────────────────────────────
  // Records are sequential across both sides — P2 follows P1.
  // Detect via S35_ACTIVE byte (offset 35 = __data01[2], always 1 for live records).
  let nextRec = 0;
  for (let fi = S35_TOTAL - 1; fi >= 0; fi--) {
    if (out[fi * S35_RECORD_SIZE + S35_ACTIVE]) { nextRec = fi + 1; break; }
  }

  // ── Build formID → info map ──────────────────────────────────────────────
  const formMap = {};
  newUnitsFlat.forEach(u => {
    const sk  = u.side === 0 ? 'p1' : 'p2';
    const fid = u.formID;
    if (fid == null) return;
    const key = `${sk}:${fid}`;
    if (!formMap[key]) {
      const letterMap = formLetterToFID[sk] || {};
      const letter = Object.keys(letterMap).find(l => letterMap[l] === fid)
                     || SPCAMO_FORMATION_LETTERS[fid]
                     || String.fromCharCode(65 + fid);
      formMap[key] = { fid, sk, letter, units: [] };
    }
    formMap[key].units.push(u);
  });

  // ── Find battalion HQ leader per side (needed for S35_HCMD) ─────────────
  // S35_HCMD = S34 leader index of this formation's higher command.
  // For BatHQ: self (own leader). For all others: the battalion CO's S34 slot.
  const batHQLeader = { p1: 0xFFFF, p2: 0xFFFF };
  Object.entries(formMap).forEach(([key, { sk, units }]) => {
    if (units.length === 1 && units[0].classID === 55) {
      const letter = key.split(':')[1] || '';
      const linfo = (formLeaderMap || {})[`${sk}:${letter}`];
      if (linfo) batHQLeader[sk] = linfo.ownLeader;
    }
  });

  // ── Per-side sequential S35_FORM_ID counter (resets to 0 per player) ────
  const formIdCounter = { p1: 0, p2: 0 };

  // Process HQs first so batHQLeader is populated before non-HQ formations
  const formEntries = Object.entries(formMap)
    .map(([key, val]) => ({ key, ...val }))
    .sort((a, b) => {
      // P1 must come before P2 — S1 formID is a direct S35 record index,
      // so the write order determines which record each formID lands on.
      if (a.sk !== b.sk) return a.sk === 'p1' ? -1 : 1;
      // Within each side: HQ first, then by formID
      const aHQ = (a.units.length === 1 && a.units[0].classID === 55) ? 0 : 1;
      const bHQ = (b.units.length === 1 && b.units[0].classID === 55) ? 0 : 1;
      return aHQ - bHQ || a.fid - b.fid;
    });

  formEntries.forEach(({ key, fid, sk, letter, units }) => {
    if (nextRec >= S35_TOTAL) {
      console.warn(`S35: out of formation record slots — skipped ${letter}`);
      return;
    }
    const recIdx = nextRec++;

    const isHQ = units.length === 1 && units[0].classID === 55;

    // S35_OOBRID: OOB formation type. BatHQ=1, sub-formations=0.
    const oobrid = isHQ ? 1 : 0;

    // S35_PLAYER: 0 = P1, 1 = P2 — key field game uses to assign formations to sides
    const player = sk === 'p1' ? 0 : 1;

    // S35_FORM_ID: per-side sequential, resetting to 0 for each player
    const formId = formIdCounter[sk]++;

    // S35_LEADER: S34 slot of this formation's own leader
    const linfo = (formLeaderMap || {})[`${sk}:${letter}`];
    const ownLeader = linfo ? linfo.ownLeader : 0xFFFF;

    // S35_HCMD: S34 slot of the higher command leader
    // BatHQ → self; all others → battalion CO
    const hcmd = isHQ ? ownLeader : batHQLeader[sk];

    const base = recIdx * S35_RECORD_SIZE;

    // Zero the whole record first
    out.fill(0, base, base + S35_RECORD_SIZE);

    // Name: ASCIIZ, NUL-padded
    const nameChars = [...letter.slice(0, S35_NAME_LEN - 1)].map(c => c.charCodeAt(0));
    for (let i = 0; i < S35_NAME_LEN; i++) {
      out[base + S35_NAME + i] = i < nameChars.length ? nameChars[i] : 0;
    }

    // S35_OOBRID (offset 16) u16 LE
    out[base + S35_OOBRID]     =  oobrid       & 0xFF;
    out[base + S35_OOBRID + 1] = (oobrid >> 8) & 0xFF;

    // S35_HCMD (offset 18) u16 LE — higher command leader S34 index
    out[base + S35_HCMD]     =  hcmd       & 0xFF;
    out[base + S35_HCMD + 1] = (hcmd >> 8) & 0xFF;

    // S35_LEADER (offset 20) u16 LE — this formation's own leader S34 index
    out[base + S35_LEADER]     =  ownLeader       & 0xFF;
    out[base + S35_LEADER + 1] = (ownLeader >> 8) & 0xFF;

    // __data00 (offsets 22–27) — confirmed constants from SpWW2Scn0477 + Formations.dat:
    //   +22 u16 = 0x000C (always 12)
    //   +24 u16 = 0x000A (always 10)
    //   +26 u16 = mirror of leader (offset 20) — same value, every record
    out[base + 22] = 0x0C; out[base + 23] = 0x00;
    out[base + 24] = 0x0A; out[base + 25] = 0x00;
    out[base + 26] =  ownLeader        & 0xFF;
    out[base + 27] = (ownLeader >> 8)  & 0xFF;

    // S35_PLAYER (offset 28) u16 LE — 0=P1, 1=P2
    out[base + S35_PLAYER]     =  player       & 0xFF;
    out[base + S35_PLAYER + 1] = (player >> 8) & 0xFF;

    // S35_FORM_ID (offset 30) u16 LE — per-side sequential ID
    out[base + S35_FORM_ID]     =  formId       & 0xFF;
    out[base + S35_FORM_ID + 1] = (formId >> 8) & 0xFF;

    // __data01 (offsets 33–59) — confirmed from SpWW2Scn0477:
    //   +33 = 0x63 (99) — active sentinel
    //   +34 = 0x00
    //   +35 = 0x01
    //   +36..+51 = 0x00
    //   +52..+55 = 0xFF 0xFF 0xFF 0xFF (always present in every live record)
    //   +56..+59 = 0x00
    out[base + 33] = 0x63;
    out[base + 34] = 0x00;
    out[base + 35] = 0x01;
    out[base + 52] = 0xFF; out[base + 53] = 0xFF;
    out[base + 54] = 0xFF; out[base + 55] = 0xFF;
  });

  return _rebuildFile(buf, sections, { 35: out });
}
