/* global React, ZoneBackdrop, TopBar, Timer, WaveCounter, StateIconGroup, EnemyCard, PartyCard, FloatingNumber, CombatLog, formatNum */
/**
 * Combat V2 — Adaptador de ReplayCombatState → componentes visuales combat_exp.
 *
 * Puente entre el estado del replay engine (`combat_exp_replay.ts`, 3A) y las
 * cards/pantallas existentes. No importa módulos Node.
 *
 * Uso típico:
 *   const map = await window.fetchAbilitiesMapV2('/data/abilities.v2.json');
 *   <ReplayCombatView replayState={state} abilitiesMap={map} zoneName="Zona" />
 */

const { useMemo: useMemoR, useRef, useEffect } = React;
const G = typeof window !== 'undefined' ? window.GAME_DATA : null;

// ─── Constantes visuales / fallbacks ─────────────────────────────────────────

const DEFAULT_ZONE = {
  id: 'replay_zone',
  name: 'Replay — Combat V2',
  tier: 'Motor Worker v2',
  bg: `linear-gradient(180deg, #0b0d14 0%, #141a28 45%, #0a0c12 100%)`,
  silhouette: 'm 0 70 l 8 64 l 14 68 l 22 60 l 30 66 l 38 58 l 46 65 l 54 55 l 64 62 l 72 56 l 82 64 l 92 58 l 100 66 L 100 100 L 0 100 Z',
  horizon: 62,
  mood: 'misty',
};

/** Glifo / tinte por clase (slice vertical + genéricos). */
const CLASS_VISUAL = {
  Wizard: { glyph: '🔮', tint: '#6b8cff' },
  Mago: { glyph: '🔥', tint: '#c87840' },
  Druida: { glyph: '🌿', tint: '#5fb86b' },
  Guerrero: { glyph: '⚔', tint: '#c84860' },
  Mercenario: { glyph: '🛡', tint: '#a89870' },
  Paladin: { glyph: '✝', tint: '#e8d060' },
  'Shadow Knight': { glyph: '💀', tint: '#7080a0' },
  Clérigo: { glyph: '✨', tint: '#f0e8c8' },
  Shaman: { glyph: '🔆', tint: '#50a0c0' },
  Enchanter: { glyph: '💠', tint: '#a070e0' },
  Bard: { glyph: '🎵', tint: '#8a6dd9' },
  Rogue: { glyph: '🗡', tint: '#c8a860' },
  Hunter: { glyph: '🏹', tint: '#7ab060' },
  Monk: { glyph: '👊', tint: '#d0a060' },
  Necromancer: { glyph: '☠', tint: '#708868' },
};

const STATUS_GLYPH = {
  stun: '💫',
  mez: '😵',
  silence: '🤫',
  root: '🌱',
  fear: '😱',
  snare: '🪤',
  blind: '🌫',
  invuln: '🛡',
  poison: '☠',
  burn: '🔥',
};

// ─── Utilidades puras ────────────────────────────────────────────────────────

function safeStr(v, fallback = '') {
  if (v == null) return fallback;
  if (typeof v === 'string') return v;
  return String(v);
}

function humanizeAbilityId(id) {
  if (!id || typeof id !== 'string') return '?';
  const tail = id.includes('_') ? id.slice(id.indexOf('_') + 1) : id;
  return tail.replace(/_/g, ' ');
}

function inferEffectKind(def) {
  if (!def || !Array.isArray(def.effects)) return 'magic';
  const kinds = def.effects.map(e => e && e.kind).filter(Boolean);
  if (kinds.includes('heal')) return 'heal';
  if (kinds.includes('hot')) return 'heal';
  if (kinds.includes('buff')) return 'buff';
  if (kinds.includes('dot')) return 'dot';
  if (kinds.includes('damage')) {
    const d = def.effects.find(e => e.kind === 'damage');
    const school = d && d.school;
    if (school === 'physical') return 'physical';
    return 'magic';
  }
  return 'magic';
}

function glyphForAbilityDef(def) {
  if (!def) return '✦';
  const k = inferEffectKind(def);
  if (k === 'heal') return '🌿';
  if (k === 'buff') return '✨';
  if (k === 'dot') return '☠';
  if (k === 'physical') return '⚔';
  const d = def.effects && def.effects.find(e => e.kind === 'damage');
  const school = d && d.school;
  const map = { fire: '🔥', cold: '❄', nature: '🌿', shadow: '👁', holy: '✨', magic: '🔮', mental: '🧠', sonic: '〰' };
  return map[school] || '🔮';
}

/**
 * Construye un `Map<string, AbilityDef>` desde el array JSON de abilities.v2.
 * @param {unknown[]} abilitiesArray
 * @returns {Map<string, object>}
 */
function buildAbilitiesMapFromJson(abilitiesArray) {
  const m = new Map();
  if (!Array.isArray(abilitiesArray)) return m;
  for (const a of abilitiesArray) {
    if (a && typeof a === 'object' && typeof a.id === 'string') m.set(a.id, a);
  }
  return m;
}

/**
 * Carga abilities.v2.json y devuelve el Map. Tolerante a fallos de red.
 * @param {string} [url]
 */
function fetchAbilitiesMapV2(url) {
  const u = url || '/data/abilities.v2.json';
  return fetch(u)
    .then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    })
    .then(buildAbilitiesMapFromJson)
    .catch(() => new Map());
}

/**
 * Convierte una definición V2 al shape mínimo que esperan `CastBar` / `PartyCard`
 * (compatible con `G.ABILITIES` del demo: name, glyph, kind, desc, cd en segundos).
 * @param {object | undefined} def
 */
function abilityDefToUiAbility(def) {
  if (!def || typeof def !== 'object') {
    return {
      id: 'unknown',
      name: '?',
      glyph: '?',
      kind: 'magic',
      desc: '',
      cd: 5,
      cost: 0,
      costType: 'mp',
      tag: 'V2',
    };
  }
  const cdSec = typeof def.cd_ms === 'number' && def.cd_ms > 0 ? def.cd_ms / 1000 : 5;
  const costPct = def.cost && typeof def.cost.mp_pct === 'number' ? def.cost.mp_pct : 0;
  const desc = safeStr(def.description, '');
  const shortName = desc.split('.')[0].trim().slice(0, 44) || humanizeAbilityId(def.id);
  return {
    id: def.id,
    name: shortName,
    glyph: glyphForAbilityDef(def),
    kind: inferEffectKind(def),
    desc: desc.slice(0, 120),
    cd: cdSec,
    cost: costPct,
    costType: 'mp',
    tag: safeStr(def.category, 'of').toUpperCase(),
  };
}

/**
 * @param {object} member — ReplayMember (shape del replay engine 3A)
 * @param {Map<string, object>} abilitiesMap
 */
function mapMemberToHero(member, abilitiesMap) {
  const m = member && typeof member === 'object' ? member : {};
  const cls = safeStr(m.class, 'Mercenario');
  const vis = CLASS_VISUAL[cls] || { glyph: '⚔', tint: '#8890a8' };
  const ofId = m.equippedAbilities && m.equippedAbilities.of && m.equippedAbilities.of.ability_id;
  const primaryDef = ofId ? abilitiesMap.get(ofId) : undefined;

  const buffs = [];
  const statuses = Array.isArray(m.statuses) ? m.statuses : [];
  for (const s of statuses) {
    if (!s || typeof s !== 'object') continue;
    if (s.isDebuff) continue; // PartyCard solo pinta badges tipo buff; debuffs aliados → 3C si hace falta.
    const key = safeStr(s.key, '');
    const glyph = STATUS_GLYPH[key.split(':')[0]] || STATUS_GLYPH[key] || '◇';
    buffs.push({
      name: safeStr(s.label, key),
      glyph,
      dmgMod: 0,
      remain: typeof s.durationRemainingMs === 'number' ? s.durationRemainingMs / 1000 : 1,
    });
  }

  return {
    id: safeStr(m.id, 'hero'),
    name: safeStr(m.name, 'Héroe'),
    cls,
    lvl: 20,
    subtitle: '',
    hp: typeof m.hp === 'number' ? m.hp : 0,
    hpMax: typeof m.hpMax === 'number' && m.hpMax > 0 ? m.hpMax : 1,
    mp: typeof m.mp === 'number' ? m.mp : 0,
    mpMax: typeof m.mpMax === 'number' && m.mpMax > 0 ? m.mpMax : 1,
    resource: 'mp',
    portraitGlyph: vis.glyph,
    portraitTint: vis.tint,
    portraitImg: null,
    buffs,
    abilityId: ofId || 'unknown',
    _primaryUiAbility: abilityDefToUiAbility(primaryDef),
    autoCast: false,
    cd: 0,
    casting: null,
    castProgress: 0,
    fur: 0,
    furMax: 100,
    ene: 0,
    eneMax: 100,
    role: 'Replay',
    atb: typeof m.atb === 'number' ? m.atb : 0,
    isDead: !!m.isDead,
    _equipped: m.equippedAbilities || { of: null, df: null, si: null },
  };
}

/**
 * @param {object} enemy — ReplayEnemy
 */
function mapEnemyToFoe(enemy) {
  const e = enemy && typeof enemy === 'object' ? enemy : {};
  const name = safeStr(e.name, 'Enemigo');
  const hpMax = typeof e.hpMax === 'number' && e.hpMax > 0 ? e.hpMax : 1;
  const debuffs = [];
  const statuses = Array.isArray(e.statuses) ? e.statuses : [];
  for (const s of statuses) {
    if (!s || typeof s !== 'object' || !s.isDebuff) continue;
    const key = safeStr(s.key, '');
    debuffs.push({
      name: safeStr(s.label, key),
      glyph: STATUS_GLYPH[key.split(':')[0]] || STATUS_GLYPH[key] || '★',
      dotPerSec: 0,
      remain: typeof s.durationRemainingMs === 'number' ? s.durationRemainingMs / 1000 : 1,
    });
  }

  return {
    id: safeStr(e.id, 'enemy'),
    hp: typeof e.hp === 'number' ? e.hp : 0,
    cd: 0,
    casting: null,
    castProgress: 0,
    debuffs,
    tpl: {
      name,
      glyph: '👾',
      tint: '#9aa0b0',
      hpMax,
      lvl: 1,
      rank: 'Normal',
      ability: 'Ataque',
      imageUrl: null,
    },
  };
}

function mapFloatyKind(type) {
  if (type === 'heal') return 'heal';
  if (type === 'miss') return 'miss';
  if (type === 'crit') return 'crit';
  return 'dmg';
}

function hashToInt(str) {
  let h = 0;
  const s = safeStr(str, '0');
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return h || 1;
}

/**
 * @param {object} replayState
 * @param {Map<string, object>} abilitiesMap
 * @param {{ onAbandon?: () => void, onLogToggle?: () => void }} [handlers]
 * @param {{ zone?: object, zoneName?: string }} [options]
 */
function mapReplayStateToScreenProps(replayState, abilitiesMap, handlers, options) {
  const rs = replayState && typeof replayState === 'object' ? replayState : {};
  const map = abilitiesMap instanceof Map ? abilitiesMap : new Map();
  const h = handlers && typeof handlers === 'object' ? handlers : {};
  const opt = options && typeof options === 'object' ? options : {};
  const zone = opt.zone && typeof opt.zone === 'object' ? { ...DEFAULT_ZONE, ...opt.zone } : {
    ...DEFAULT_ZONE,
    name: safeStr(opt.zoneName, DEFAULT_ZONE.name),
  };

  const members = Array.isArray(rs.members) ? rs.members : [];
  const enemies = Array.isArray(rs.enemies) ? rs.enemies : [];

  const party = members.map(m => mapMemberToHero(m, map));
  const foes = enemies.map(mapEnemyToFoe);

  const floats = (Array.isArray(rs.floaties) ? rs.floaties : []).map(f => {
    if (!f || typeof f !== 'object') return null;
    return {
      id: f.id,
      kind: mapFloatyKind(f.type),
      value: typeof f.value === 'number' ? f.value : 0,
      targetId: safeStr(f.targetId, ''),
    };
  }).filter(Boolean);

  const logRaw = Array.isArray(rs.log) ? rs.log : [];
  const logEntries = logRaw.map((line, i) => ({
    id: `replay-log-${i}`,
    system: true,
    text: typeof line === 'string' ? line : safeStr(line, ''),
  }));

  return {
    zone,
    party,
    foes,
    floats,
    logEntries,
    castingBars: Array.isArray(rs.castingBars) ? rs.castingBars : [],
    auras: Array.isArray(rs.auras) ? rs.auras : [],
    elapsedMs: typeof rs.elapsedMs === 'number' ? rs.elapsedMs : 0,
    onAbandon: typeof h.onAbandon === 'function' ? h.onAbandon : () => {},
    onLogToggle: typeof h.onLogToggle === 'function' ? h.onLogToggle : () => {},
  };
}

// ─── AbilityIcon (CD circular SVG) ───────────────────────────────────────────

const ICON_PIXEL = 44;

/**
 * @param {{ slot: object | null, abilitiesMap: Map<string, object>, slotLabel?: string }} props
 */
function AbilityIcon({ slot, abilitiesMap, slotLabel }) {
  const map = abilitiesMap instanceof Map ? abilitiesMap : new Map();
  if (!slot || typeof slot !== 'object' || !slot.ability_id) {
    return <div className="replay-ability-icon replay-ability-icon--empty" title="" />;
  }
  const def = map.get(slot.ability_id);
  const ui = abilityDefToUiAbility(def);
  const cdMs = typeof slot.cdMs === 'number' && slot.cdMs > 0
    ? slot.cdMs
    : (def && typeof def.cd_ms === 'number' ? def.cd_ms : 20000);
  const rem = typeof slot.cooldownRemainingMs === 'number' ? Math.max(0, slot.cooldownRemainingMs) : 0;
  const ratio = cdMs > 0 ? Math.min(1, rem / cdMs) : 0;
  const r = 18;
  const c = 2 * Math.PI * r;
  const dash = c * ratio;

  const iconUrl = `icons/${slot.ability_id}.png`;
  const [imgErr, setImgErr] = React.useState(false);

  return (
    <div
      className={`replay-ability-icon ${rem > 0 ? 'on-cd' : 'ready'}`}
      title={`${ui.name}${rem > 0 ? ` (${Math.ceil(rem / 100) / 10}s)` : ''}`}
      data-slot={slotLabel || ''}
    >
      <div className="replay-ability-icon__inner" style={{ opacity: rem > 0 ? 0.55 : 1 }}>
        {!imgErr ? (
          <img
            src={iconUrl}
            alt=""
            width={ICON_PIXEL}
            height={ICON_PIXEL}
            style={{ borderRadius: 8, objectFit: 'cover', display: 'block' }}
            onError={() => setImgErr(true)}
          />
        ) : (
          <div className="replay-ability-fallback-glyph" style={{
            width: ICON_PIXEL,
            height: ICON_PIXEL,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            fontSize: 22,
            background: 'var(--bg-1)',
            borderRadius: 8,
          }}>{ui.glyph}</div>
        )}
      </div>
      {rem > 0 && cdMs > 0 && (
        <svg className="replay-ability-cd-ring" width={ICON_PIXEL + 8} height={ICON_PIXEL + 8} viewBox="-4 -4 52 52">
          <circle
            cx={22}
            cy={22}
            r={r}
            fill="none"
            stroke="rgba(0,0,0,0.45)"
            strokeWidth="4"
          />
          <circle
            cx={22}
            cy={22}
            r={r}
            fill="none"
            stroke="var(--accent, #c8a860)"
            strokeWidth="4"
            strokeDasharray={`${dash} ${c}`}
            strokeLinecap="round"
            transform="rotate(-90 22 22)"
          />
        </svg>
      )}
    </div>
  );
}

// ─── Vista contenedora ───────────────────────────────────────────────────────

/**
 * @param {{
 *   replayState: object,
 *   abilitiesMap: Map<string, object>,
 *   zone?: object,
 *   zoneName?: string,
 *   showLog?: boolean,
 *   onLogToggle?: () => void,
 *   onCombatEnd?: () => void,
 *   onAbandon?: () => void,
 * }} props
 */
function ReplayCombatView({
  replayState,
  abilitiesMap,
  zone: zoneProp,
  zoneName,
  showLog = true,
  onLogToggle,
  onAbandon,
}) {
  const am = useMemoR(
    () => (abilitiesMap instanceof Map ? abilitiesMap : new Map()),
    [abilitiesMap],
  );

  const abandonRef = useRef(onAbandon);
  const logToggleRef = useRef(onLogToggle);
  useEffect(() => {
    abandonRef.current = onAbandon;
    logToggleRef.current = onLogToggle;
  }, [onAbandon, onLogToggle]);

  const stableHandlers = useMemoR(
    () => ({
      onAbandon: () => {
        const fn = abandonRef.current;
        if (typeof fn === 'function') fn();
      },
      onLogToggle: () => {
        const fn = logToggleRef.current;
        if (typeof fn === 'function') fn();
      },
    }),
    [],
  );

  const props = useMemoR(() => mapReplayStateToScreenProps(
    replayState,
    am,
    stableHandlers,
    { zone: zoneProp, zoneName },
  ), [replayState, am, zoneProp, zoneName, stableHandlers]);

  const activeIdx = useMemoR(() => {
    const party = props.party;
    const i = party.findIndex(p => p.hp > 0);
    return i < 0 ? 0 : i;
  }, [props.party]);

  const logLines = useMemoR(() => props.logEntries, [props.logEntries]);

  const zoneBuffs = G && Array.isArray(G.ZONE_BUFFS) ? G.ZONE_BUFFS : [];
  const zoneDebuffs = G && Array.isArray(G.ZONE_DEBUFFS) ? G.ZONE_DEBUFFS : [];

  return (
    <div className="combat-screen replay-combat-view" data-screen-label="Replay V2" style={{ position: 'absolute', inset: 0 }}>
      <style>{`
        .replay-combat-view .replay-ability-icon { position: relative; width: ${ICON_PIXEL + 8}px; height: ${ICON_PIXEL + 8}px; flex: 0 0 auto; }
        .replay-combat-view .replay-ability-cd-ring { position: absolute; left: 0; top: 0; pointer-events: none; }
        .replay-combat-view .replay-ability-dock-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
        .replay-combat-view .replay-member-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 160px; }
        .replay-combat-view .replay-cast-under { width: 100%; max-width: 220px; height: 4px; background: rgba(0,0,0,0.35); border-radius: 2px; overflow: hidden; margin-top: 2px; }
        .replay-combat-view .replay-cast-under > div { height: 100%; background: var(--accent, #c8a860); transition: width 0.08s linear; }
        .replay-combat-view .replay-auras-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 16px; z-index: 6; position: relative; }
        .replay-combat-view .replay-aura-pill { font-size: 11px; padding: 3px 8px; border-radius: 999px; background: rgba(0,0,0,0.35); border: 1px solid var(--ink-3, #444); color: var(--ink-1, #ccc); }
        .replay-combat-view .replay-float-wrap { position: absolute; left: 0; top: 0; right: 0; bottom: 0; pointer-events: none; overflow: visible; }
      `}</style>

      <ZoneBackdrop zone={props.zone} />

      <TopBar
        zone={props.zone}
        wave={1}
        totalWaves={1}
        isBoss={false}
        time={props.elapsedMs / 1000}
        onAbandon={props.onAbandon}
      />

      {props.auras.length > 0 && (
        <div className="replay-auras-bar">
          {props.auras.map((a, i) => (
            <span key={i} className="replay-aura-pill" title={a.flavorText || ''}>
              Aura · {safeStr(a.class, '?')}
            </span>
          ))}
        </div>
      )}

      <div className={`arena ${showLog ? 'with-log' : ''}`}>
        <span className="row-label enemies">Enemigos · Replay</span>
        <div className="row-enemies">
          {props.foes.map((e, i) => (
            <div key={e.id} style={{ position: 'relative' }} data-unit-id={e.id}>
              <EnemyCard
                unit={e}
                isCasting={false}
                castProgress={0}
                isTargeted={i === 0 && e.hp > 0}
              />
              <div className="replay-float-wrap">
                {props.floats.filter(f => f.targetId === e.id).map(f => (
                  <FloatingNumber
                    key={f.id}
                    id={hashToInt(String(f.id))}
                    kind={f.kind}
                    value={f.value}
                    x={135 - (f.kind === 'crit' ? 60 : 40) + (hashToInt(String(f.id)) % 5) * 14 - 28}
                    y={120 + (hashToInt(String(f.id)) % 3) * 10 - 10}
                  />
                ))}
              </div>
            </div>
          ))}
        </div>

        <div className="vs-divider">
          <div className="vs-line" />
          <div className="vs-badge">VS</div>
          <div className="vs-line" />
        </div>

        <span className="row-label party">Tu grupo · Replay</span>
        <div className="row-party">
          {props.party.map((p, i) => {
            const bar = props.castingBars.find(b => b && b.casterId === p.id);
            const def = bar && bar.ability_id ? am.get(bar.ability_id) : null;
            const castAbility = bar && def ? abilityDefToUiAbility(def) : p._primaryUiAbility;
            const prog = bar && typeof bar.progress === 'number' ? bar.progress : 0;
            const memberRaw = (replayState.members || []).find(m => m && m.id === p.id) || {};

            return (
              <div key={p.id} style={{ position: 'relative' }} data-unit-id={p.id}>
                <PartyCard
                  unit={p}
                  ability={castAbility}
                  isActive={i === activeIdx}
                  isCasting={!!bar}
                  castProgress={prog}
                  slotLabel={`replay ${p.name}`}
                />
                {bar && (
                  <div className="replay-cast-under">
                    <div style={{ width: `${Math.max(0, Math.min(1, prog)) * 100}%` }} />
                  </div>
                )}
                <div className="replay-float-wrap">
                  {props.floats.filter(f => f.targetId === p.id).map(f => (
                    <FloatingNumber
                      key={f.id}
                      id={hashToInt(String(f.id))}
                      kind={f.kind}
                      value={f.value}
                      x={135 - 40 + (hashToInt(String(f.id)) % 5) * 12 - 24}
                      y={130 + (hashToInt(String(f.id)) % 3) * 8 - 8}
                    />
                  ))}
                </div>
                <div className="replay-ability-dock-row" style={{ justifyContent: 'center', marginTop: 8 }}>
                  <div className="replay-member-slot">
                    <span style={{ fontSize: 11, color: 'var(--ink-2)' }}>{p.name}</span>
                    <div style={{ display: 'flex', gap: 6 }}>
                      <AbilityIcon slot={memberRaw.equippedAbilities && memberRaw.equippedAbilities.of} abilitiesMap={am} slotLabel="of" />
                      <AbilityIcon slot={memberRaw.equippedAbilities && memberRaw.equippedAbilities.df} abilitiesMap={am} slotLabel="df" />
                      <AbilityIcon slot={memberRaw.equippedAbilities && memberRaw.equippedAbilities.si} abilitiesMap={am} slotLabel="si" />
                    </div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>

      <div className={`ability-dock replay-ability-dock ${showLog ? 'with-log' : ''}`} style={{ flexWrap: 'wrap', justifyContent: 'center', gap: 16 }}>
        <div className="dock-side">
          <div className="lbl-stack">
            <span className="l1">Replay V2</span>
            <span className="l2">Solo lectura</span>
          </div>
        </div>
        <div className="dock-side">
          <div className="lbl-stack">
            <span className="l1">Estado</span>
            <span className="l2">{Math.round(props.elapsedMs)} ms sim</span>
          </div>
        </div>
      </div>

      {showLog && (
        <CombatLog log={logLines} show={showLog} onToggle={onLogToggle || (() => {})} />
      )}
      {!showLog && (
        <button
          type="button"
          className="log-collapse-btn"
          style={{ position: 'absolute', right: 32, bottom: 100, zIndex: 9 }}
          onClick={onLogToggle}
        >▲ Mostrar registro</button>
      )}
    </div>
  );
}

Object.assign(window, {
  buildAbilitiesMapFromJson,
  fetchAbilitiesMapV2,
  abilityDefToUiAbility,
  mapMemberToHero,
  mapEnemyToFoe,
  mapReplayStateToScreenProps,
  AbilityIcon,
  ReplayCombatView,
});

/*
=== CONTRATO DE INTEGRACIÓN 3B ===

Componentes existentes reutilizados SIN modificar:
- ZoneBackdrop — `zone={props.zone}` (objeto con bg, silhouette, horizon, name, tier, mood).
- TopBar — `zone`, `wave={1}`, `totalWaves={1}`, `isBoss={false}`, `time={elapsedMs/1000}`, `onAbandon`.
- Timer / WaveCounter / StateIconGroup — vía TopBar; iconos de zona usan `GAME_DATA.ZONE_BUFFS` / `ZONE_DEBUFFS` si existen.
- EnemyCard — `unit` con forma `{ id, hp, cd, casting, castProgress, debuffs[], tpl:{ name, glyph, tint, hpMax, lvl, rank, ability, imageUrl? } }`.
- PartyCard — `unit` (héroe mapeado), `ability` (objeto estilo demo: name, glyph, kind, desc, cd…), `isActive`, `isCasting`, `castProgress`, `slotLabel`.
- FloatingNumber — `kind` en { dmg, heal, miss, crit }; `value`, `x`, `y`, `id` numérico derivado del id del floaty.
- CombatLog — entradas `{ id, system:true, text }` construidas desde `replayState.log[]` (strings).

Componentes existentes que NECESITARÍAN ajuste menor (pendiente 3C/3D/3E):
- PartyCard — solo muestra `unit.buffs` en Portrait (badges siempre `kind: 'buff'`). Los debuffs aliados del replay se omiten en el mapeo para no confundir con buffs; 3C puede añadir canal debuff en PartyCard.
- EnemyCard — asume `unit.tpl.hpMax` fijo para HpBar; coherente si el tpl se construye con el hpMax actual del replay.
- AbilityBlock / dock original — no usado en replay; el dock de 1 habilidad por PJ no encaja con 3 slots V2 sin duplicar UI. 3E podría unificar dock.
- TopBar Timer — en replay `time` es simTime acumulado, no tiempo real de partida; OK para demo.

Props nuevas opcionales añadidas (si alguna):
- Ninguna en archivos existentes. `ReplayCombatView` acepta solo props nuevas en el adaptador.

Data shapes mapeados:
- ReplayMember → HeroShape (PartyCard / dock): id, name, cls, lvl, hp, hpMax, mp, mpMax, resource:'mp', portraitGlyph/Tint, buffs[], abilityId (slot of), autoCast:false, _primaryUiAbility, _equipped (raw slots), role, atb.
- ReplayEnemy → FoeShape (EnemyCard): id, hp, tpl.hpMax/name/…, debuffs[] con glyph.
- ReplayCombatState.floaties → floats[] { id, kind:(dmg|heal|miss|crit), value, targetId }.
- ReplayCombatState.log → CombatLog entries { id, system, text }.
- ReplayCombatState.castingBars → barra bajo card + CastBar en PartyCard vía isCasting/castProgress/ability.
- ReplayCombatState.auras → barra de pills “Aura · Clase”.

Carga de datos:
- `fetchAbilitiesMapV2(url)` por defecto `/data/abilities.v2.json` (mismo origen que sirva `public/`). Si se abre el HTML como file://, puede fallar; pasar URL absoluta o embed del JSON en 3F.
*/
