// bracket.js — knockout bracket. R32 slots come from bracket-config.json
// (group positions resolved via computeStandings(), best-third slots via
// thirdPlaceAssignment); every later round is generated by sequential pairing
// of winners (nodes 0-1 → next 0, 2-3 → next 1, …). SF losers feed the
// third-place match. Unresolvable slots render as placeholders, unplayed
// matches as TBD. Interactions land in step 8, simulation in step 9.

import { getData, flagSrc, matchDateUTC } from './app.js';
import { get as storageGet, set as storageSet, getFavorites } from './storage.js';
import { computeStandings, isGroupFinished } from './groups.js';
import { t, translatePhase, getLocale } from './i18n.js';
import { openMatchModal } from './modal.js';

const ROUNDS = [
  { id: 'R32', phase: 'Round of 32', size: 16 },
  { id: 'R16', phase: 'Round of 16', size: 8 },
  { id: 'QF', phase: 'Quarterfinals', size: 4 },
  { id: 'SF', phase: 'Semifinals', size: 2 },
  { id: 'FINAL', phase: 'Final', size: 1 },
];

// ---------------------------------------------------------------- tree

let cachedTree = null;

// results are static per page load; simulation (step 9) will call this
export function invalidateBracket() {
  cachedTree = null;
}

export function getBracketTree() {
  if (!cachedTree) cachedTree = buildTree();
  return cachedTree;
}

function buildTree() {
  const { matches, resultByMatchId, bracketConfig } = getData();
  const simulation = storageGet('simulation', {});
  const standings = computeStandings();
  const matchByRef = new Map(matches.filter((m) => m.bracketRef).map((m) => [m.bracketRef, m]));
  const nodesByRef = new Map();
  const rounds = [];
  let previous = null;

  for (const round of ROUNDS) {
    const nodes = [];
    for (let i = 0; i < round.size; i += 1) {
      const ref = round.id === 'FINAL' ? 'FINAL' : `${round.id}-${i + 1}`;
      const node = {
        ref,
        round: round.id,
        phase: round.phase,
        match: matchByRef.get(ref) ?? null,
        home: null,
        away: null,
        winner: null,
        loser: null,
        result: null,
      };
      if (round.id === 'R32') {
        const entry = bracketConfig.round32[i];
        node.home = configSlot(entry.home, standings, bracketConfig);
        node.away = configSlot(entry.away, standings, bracketConfig);
      } else {
        node.home = previous[i * 2].winner ? { teamId: previous[i * 2].winner } : { ph: { kind: 'tbd' } };
        node.away = previous[i * 2 + 1].winner ? { teamId: previous[i * 2 + 1].winner } : { ph: { kind: 'tbd' } };
      }
      decide(node, resultByMatchId);
      applySimulation(node, simulation);
      nodes.push(node);
      nodesByRef.set(ref, node);
    }
    rounds.push({ ...round, nodes });
    previous = nodes;
  }

  const [sf1, sf2] = rounds.find((r) => r.id === 'SF').nodes;
  const third = {
    ref: 'THIRD-PLACE',
    round: 'THIRD',
    phase: 'Third Place',
    match: matchByRef.get('THIRD-PLACE') ?? null,
    home: sf1.loser ? { teamId: sf1.loser } : { ph: { kind: 'tbd' } },
    away: sf2.loser ? { teamId: sf2.loser } : { ph: { kind: 'tbd' } },
    winner: null,
    loser: null,
    result: null,
  };
  decide(third, resultByMatchId);
  applySimulation(third, simulation);
  nodesByRef.set('THIRD-PLACE', third);

  return { rounds, third, nodesByRef, champion: nodesByRef.get('FINAL').winner };
}

function configSlot(spec, standings, config) {
  if (spec.type === 'third') {
    const group = config.thirdPlaceAssignment?.[String(spec.slot)];
    if (group && isGroupFinished(group)) return { teamId: standings[group][2].teamId };
    return { ph: { kind: 'third', n: spec.slot } };
  }
  if (isGroupFinished(spec.ref)) return { teamId: standings[spec.ref][spec.pos - 1].teamId };
  return { ph: { kind: spec.pos === 1 ? 'groupWinner' : 'groupRunnerUp', g: spec.ref } };
}

// real finished results only — the simulation overlay is applySimulation()'s
// job and always loses to a real result
function decide(node, resultByMatchId) {
  const result = node.match ? resultByMatchId.get(node.match.id) : null;
  node.result = result ?? null;
  node.simulated = false;
  node.simScore = null;
  if (!result || result.status !== 'finished' || !node.home.teamId || !node.away.teamId) return;
  let side = result.homeScore > result.awayScore ? 'home'
    : result.homeScore < result.awayScore ? 'away'
    : null;
  if (!side && result.penalties) {
    side = result.penalties.home > result.penalties.away ? 'home' : 'away';
  }
  if (!side) return;
  node.winner = node[side].teamId;
  node.loser = node[side === 'home' ? 'away' : 'home'].teamId;
}

function isSimulatable(node) {
  const realLocked = node.result && node.result.status !== 'scheduled';
  return Boolean(node.match) && !realLocked && Boolean(node.home.teamId) && Boolean(node.away.teamId);
}

// User simulation overlays matches without a real final result. Entries whose
// winner no longer matches either resolved team (stale picks after upstream
// changes) are silently ignored — same validation the prediction import uses.
function applySimulation(node, simulation) {
  if (node.winner || !node.home.teamId || !node.away.teamId) return;
  if (node.result?.status === 'finished') return;
  const entry = simulation[node.ref];
  if (!entry || (entry.winner !== node.home.teamId && entry.winner !== node.away.teamId)) return;
  node.winner = entry.winner;
  node.loser = entry.winner === node.home.teamId ? node.away.teamId : node.home.teamId;
  node.simulated = true;
  const parsed = /^(\d+)-(\d+)$/.exec(entry.score ?? '');
  node.simScore = parsed ? { home: Number(parsed[1]), away: Number(parsed[2]) } : null;
}

// ------------------------------------------------------ display helpers

function slotDisplay(slot) {
  if (slot?.teamId) {
    const team = getData().teamById.get(slot.teamId);
    return { team, label: team.name };
  }
  const ph = slot?.ph ?? { kind: 'tbd' };
  switch (ph.kind) {
    case 'groupWinner': return { team: null, label: t('bracket.groupWinner').replace('{g}', ph.g) };
    case 'groupRunnerUp': return { team: null, label: t('bracket.groupRunnerUp').replace('{g}', ph.g) };
    case 'third': return { team: null, label: t('bracket.bestThird').replace('{n}', ph.n) };
    default: return { team: null, label: t('app.tbd') };
  }
}

// Shared by schedule.js and modal.js: given a match (group or knockout) or a
// bracketRef string, returns { home, away } as { team: Team|null, label }.
export function resolveBracketTeams(matchOrRef) {
  const ref = typeof matchOrRef === 'string' ? matchOrRef : matchOrRef.bracketRef;
  if (!ref) {
    const { teamById } = getData();
    const make = (id) => {
      const team = teamById.get(id) ?? null;
      return { team, label: team?.name ?? t('app.tbd') };
    };
    return { home: make(matchOrRef.homeTeam), away: make(matchOrRef.awayTeam) };
  }
  const node = getBracketTree().nodesByRef.get(ref);
  if (!node) {
    const tbd = { team: null, label: t('app.tbd') };
    return { home: tbd, away: tbd };
  }
  return { home: slotDisplay(node.home), away: slotDisplay(node.away) };
}

// ------------------------------------------------ shared favorites helper

// Matches involving a favorited team — shared by schedule.js and bracket.js.
// Uses resolved slots so knockout matches count once their teams are known.
export function getFavoriteMatches(matches, favorites) {
  if (!favorites.length) return [];
  const favSet = new Set(favorites);
  return matches.filter((match) => {
    const slots = resolveBracketTeams(match);
    return favSet.has(slots.home.team?.id) || favSet.has(slots.away.team?.id);
  });
}

// --------------------------------------------------- challenge (step 12)

// For every knockout match with a real finished result, compares the actual
// winner with the user's simulated pick. Recalculated on every render — no
// persistence of its own.
export function calculateChallengeScore(simulation, results, bracketTree) {
  const resultById = new Map(results.map((r) => [r.matchId, r]));
  const byPhaseCount = {};
  let correct = 0;
  let total = 0;
  for (const node of bracketTree.nodesByRef.values()) {
    if (!node.match || !node.home.teamId || !node.away.teamId) continue;
    const result = resultById.get(node.match.id);
    if (result?.status !== 'finished') continue;
    let side = result.homeScore > result.awayScore ? 'home'
      : result.homeScore < result.awayScore ? 'away'
      : null;
    if (!side && result.penalties) {
      side = result.penalties.home > result.penalties.away ? 'home' : 'away';
    }
    if (!side) continue;
    const realWinner = node[side].teamId;
    total += 1;
    byPhaseCount[node.phase] ??= { correct: 0, total: 0 };
    byPhaseCount[node.phase].total += 1;
    if (simulation[node.ref]?.winner === realWinner) {
      correct += 1;
      byPhaseCount[node.phase].correct += 1;
    }
  }
  const byPhase = Object.fromEntries(
    Object.entries(byPhaseCount).map(([phase, c]) => [phase, `${c.correct}/${c.total}`]),
  );
  return { correct, total, byPhase };
}

// ------------------------------------------- share prediction (step 12)

export function getShareableLink() {
  const url = new URL(location.href);
  url.searchParams.set('prediction', btoa(JSON.stringify(getSimulation())));
  return url.toString();
}

// Reads ?prediction= on load. Applies only if every key exists in the current
// bracket tree and the user confirms; invalid/incompatible data is silently
// ignored. The param is removed from the URL either way.
export function loadPredictionFromURL() {
  const param = new URLSearchParams(location.search).get('prediction');
  if (!param) return;
  const cleanURL = new URL(location.href);
  cleanURL.searchParams.delete('prediction');
  history.replaceState(null, '', cleanURL);
  try {
    const data = JSON.parse(atob(param));
    if (typeof data !== 'object' || data === null || Array.isArray(data)) return;
    const entries = Object.entries(data);
    if (!entries.length) return;
    const tree = getBracketTree();
    const valid = entries.every(([ref, entry]) =>
      tree.nodesByRef.has(ref) && entry && typeof entry.winner === 'string');
    if (!valid) return;
    if (window.confirm(t('share.confirm'))) {
      storageSet('simulation', data);
      refreshAfterSimChange();
    }
  } catch {
    // malformed base64/JSON — ignore silently per spec
  }
}

// -------------------------------------------------------------- render

export function initBracket() {
  render();
  document.addEventListener('langchange', render);
  document.addEventListener('favchange', render);
  document.addEventListener('datachange', render); // tree already invalidated by the poll → rebuilds
  document.addEventListener('timemodechange', render); // kickoff microlines follow the Local/Stadium toggle
  // no explicit view choice → default follows the breakpoint (mobile = rounds pager)
  window.matchMedia('(max-width: 767px)').addEventListener('change', () => {
    if (!VIEW_IDS.includes(storageGet('prefs', {}).bracketView)) render();
  });
  loadPredictionFromURL();

  const root = document.getElementById('bracket-root');
  const activate = (el) => {
    if (simMode) {
      const node = getBracketTree().nodesByRef.get(el.dataset.ref);
      if (node && isSimulatable(node)) {
        openSimEditor(el.dataset.ref);
        return;
      }
    }
    openMatchModal(Number(el.dataset.matchId));
  };
  root.addEventListener('click', (event) => {
    const node = event.target.closest('[data-match-id]');
    if (node) activate(node);
  });
  root.addEventListener('keydown', (event) => {
    if (event.key !== 'Enter' && event.key !== ' ') return;
    const node = event.target.closest('[data-match-id]');
    if (node) {
      event.preventDefault();
      activate(node);
    }
  });
}

function challengeCardHTML(challenge) {
  const phases = Object.entries(challenge.byPhase)
    .map(([phase, score]) => `<span class="challenge-phase">${translatePhase(phase)}: ${score}</span>`)
    .join('');
  const summary = t('challenge.correct')
    .replace('{x}', challenge.correct)
    .replace('{y}', challenge.total);
  return `
    <div class="challenge-card glass">
      <span class="challenge-title">${t('challenge.title')}</span>
      <span class="challenge-score">${summary}</span>
      ${phases}
    </div>`;
}

// ------------------------------------------- wallchart layout engine
// Center-out "wallchart": R32 1–8 flow left→center, 9–16 right→center,
// with the Final + champion centerpiece in the middle column and the
// third-place match beneath it. All positions are computed here (absolute
// px inside the canvas); connectors are generated as SVG bezier paths from
// the same numbers, so cards and lines can never drift apart. This engine
// replaced the old flex-column + CSS-stub system (its equal-height
// invariant could not express a mirrored right half).

const GEO = {
  gap: 42, // horizontal gap between adjacent columns
  centerGap: 46, // SF column ↔ center column
  rowGap: 20, // vertical gap between R32 cards
  padX: 28,
  padTop: 62, // leaves room for the round title row
  padBottom: 30,
  tiers: {
    R32: { w: 184, h: 78 },
    R16: { w: 196, h: 82 },
    QF: { w: 208, h: 88 },
    SF: { w: 220, h: 94 },
    FINAL: { w: 264, h: 142 },
    THIRD: { w: 208, h: 88 },
  },
  championH: 118,
  championGap: 20, // champion box ↔ final card
  thirdGap: 26, // final card ↔ third-place title
  thirdTitleH: 22,
};

function refName(roundId, index) {
  return roundId === 'FINAL' ? 'FINAL' : `${roundId}-${index + 1}`;
}

function computeWallchartLayout() {
  const G = GEO;
  const { R32: t32, R16: t16, QF: tQF, SF: tSF, FINAL: tF, THIRD: tT } = G.tiers;
  const cards = new Map(); // ref → { x, y, w, h, tier, side }

  // column x positions, left → right (right half mirrors the left)
  const xL32 = G.padX;
  const xL16 = xL32 + t32.w + G.gap;
  const xLQF = xL16 + t16.w + G.gap;
  const xLSF = xLQF + tQF.w + G.gap;
  const xC = xLSF + tSF.w + G.centerGap;
  const xRSF = xC + tF.w + G.centerGap;
  const xRQF = xRSF + tSF.w + G.gap;
  const xR16 = xRQF + tQF.w + G.gap;
  const xR32 = xR16 + t16.w + G.gap;
  const w = xR32 + t32.w + G.padX;
  const h = G.padTop + 8 * t32.h + 7 * G.rowGap + G.padBottom;

  for (let i = 0; i < 16; i += 1) {
    const left = i < 8;
    const row = left ? i : i - 8;
    cards.set(refName('R32', i), {
      x: left ? xL32 : xR32,
      y: G.padTop + row * (t32.h + G.rowGap),
      w: t32.w, h: t32.h, tier: 'R32', side: left ? 'L' : 'R',
    });
  }

  // each later node sits at the vertical midpoint of its two feeders
  const place = (roundId, prevId, count, tier, xLeft, xRight) => {
    for (let i = 0; i < count; i += 1) {
      const a = cards.get(refName(prevId, i * 2));
      const b = cards.get(refName(prevId, i * 2 + 1));
      const cy = (a.y + a.h / 2 + b.y + b.h / 2) / 2;
      const left = i < count / 2;
      cards.set(refName(roundId, i), {
        x: left ? xLeft : xRight,
        y: cy - tier.h / 2,
        w: tier.w, h: tier.h, tier: roundId, side: left ? 'L' : 'R',
      });
    }
  };
  place('R16', 'R32', 8, t16, xL16, xR16);
  place('QF', 'R16', 4, tQF, xLQF, xRQF);
  place('SF', 'QF', 2, tSF, xLSF, xRSF);

  const sf1 = cards.get('SF-1');
  const sf2 = cards.get('SF-2');
  const midY = (sf1.y + sf1.h / 2 + sf2.y + sf2.h / 2) / 2;
  const final = { x: xC, y: midY - tF.h / 2, w: tF.w, h: tF.h, tier: 'FINAL', side: 'C' };
  cards.set('FINAL', final);
  const champion = {
    x: xC, y: final.y - G.championGap - G.championH, w: tF.w, h: G.championH,
  };
  const thirdTitle = { x: xC + (tF.w - tT.w) / 2, y: final.y + tF.h + G.thirdGap, w: tT.w };
  cards.set('THIRD-PLACE', {
    x: thirdTitle.x, y: thirdTitle.y + G.thirdTitleH, w: tT.w, h: tT.h, tier: 'THIRD', side: 'C',
  });

  // connectors: two bezier flow lines into every non-R32 node
  const links = [];
  const addLink = (fromRef, toRef) => {
    const a = cards.get(fromRef);
    const b = cards.get(toRef);
    const rightward = a.side === 'L'; // left half flows →, right half flows ←
    const x1 = rightward ? a.x + a.w : a.x;
    const x2 = rightward ? b.x : b.x + b.w;
    const y1 = a.y + a.h / 2;
    const y2 = b.y + b.h / 2;
    const mx = (x1 + x2) / 2;
    links.push({
      from: fromRef, to: toRef, tier: b.tier,
      d: `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`,
    });
  };
  for (const [roundId, prevId, count] of [['R16', 'R32', 8], ['QF', 'R16', 4], ['SF', 'QF', 2], ['FINAL', 'SF', 1]]) {
    for (let i = 0; i < count; i += 1) {
      addLink(refName(prevId, i * 2), refName(roundId, i));
      addLink(refName(prevId, i * 2 + 1), refName(roundId, i));
    }
  }
  // glowing stem from the final card up into the champion box
  const stemX = xC + tF.w / 2;
  const stem = `M ${stemX} ${final.y} L ${stemX} ${champion.y + champion.h}`;

  const ty = G.padTop - 40;
  const labels = [
    { x: xL32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' },
    { x: xL16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' },
    { x: xLQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' },
    { x: xLSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' },
    { x: xC, y: ty, w: tF.w, tier: 'FINAL', phase: 'Final' },
    { x: xRSF, y: ty, w: tSF.w, tier: 'SF', phase: 'Semifinals' },
    { x: xRQF, y: ty, w: tQF.w, tier: 'QF', phase: 'Quarterfinals' },
    { x: xR16, y: ty, w: t16.w, tier: 'R16', phase: 'Round of 16' },
    { x: xR32, y: ty, w: t32.w, tier: 'R32', phase: 'Round of 32' },
  ];

  const scenery = { type: 'pitch', cx: stemX, cy: final.y + tF.h / 2, r: Math.min(230, (h - G.padTop) / 2.6) };
  return { id: 'wallchart', w, h, cards, links, stem, labels, scenery, champion, thirdTitle };
}

// --------------------------------------------- radial layout engine
// Second chart view — the "orbit": circular flag tokens on concentric
// rings converging on the trophy. Outermost ring = the 32 entrants; each
// ring inward holds the winner slots of a round (a match's winner slot
// doubles as its next match's participant). Elbow lines trace each team's
// route; gold = real advancement, dashed blue = simulated pick. Clicking
// any token opens its match (winner slots = the match they decide), so the
// modal / sim-editor delegation is unchanged. Names and scores live in the
// app tooltip (has-tip/data-tip) and the modal.

const TGEO = {
  pad: 40,
  outerR: 388, // 32 entrant tokens
  outerD: 44,
  winners: { // ring where each round's winner slot sits (its diameter grows inward)
    R32: { r: 322, d: 48 },
    R16: { r: 252, d: 54 },
    QF: { r: 178, d: 60 },
    SF: { r: 96, d: 68 }, // = the two finalists flanking the trophy
  },
  championD: 84,
  glowR: 190, // radius of the gold glow pool behind the trophy
  thirdDy: 64, // third-place group below the circle
  thirdGap: 72, // distance between the two third-place tokens
  thirdD: 48,
};

function computeRadialLayout() {
  const G = TGEO;
  const R = G.outerR + G.outerD / 2 + 8; // circle's outer edge
  const cx = G.pad + R;
  const cy = G.pad + R;
  const w = (G.pad + R) * 2;
  const thirdY = cy + R + G.thirdDy;
  const h = thirdY + G.thirdD / 2 + G.pad;
  return { id: 'radial', w, h, cx, cy, thirdY };
}

// ------------------------------------------------------------- render

// compact kickoff line ("Jul 4, 17:00") honoring the Local/Stadium toggle —
// formatMatchTime()'s medium dateStyle (with year) is too wide for the cards
function kickoffShort(match) {
  const stadium = getData().stadiumByName.get(match.stadium);
  const mode = storageGet('prefs', {}).timeMode ?? 'local';
  const options = { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' };
  if (mode === 'stadium' && stadium?.timezone) options.timeZone = stadium.timezone;
  return new Intl.DateTimeFormat(getLocale(), options).format(matchDateUTC(match));
}

// ------------------------------------------------------ view switching
// Three views: 'rounds' (swipeable pager, mobile default), 'wallchart'
// (desktop default) and 'radial'. Explicit choice persists in
// wc2026_prefs.bracketView; without one the default follows the breakpoint.

const VIEW_IDS = ['rounds', 'wallchart', 'radial'];

function activeView() {
  const stored = storageGet('prefs', {}).bracketView;
  if (VIEW_IDS.includes(stored)) return stored;
  return window.matchMedia('(max-width: 767px)').matches ? 'rounds' : 'wallchart';
}

function setBracketView(id) {
  const prefs = storageGet('prefs', {});
  prefs.bracketView = id;
  storageSet('prefs', prefs);
}

function viewSwitchHTML(active) {
  const labels = {
    rounds: t('bracket.viewRounds'),
    wallchart: t('bracket.viewWallchart'),
    radial: t('bracket.viewRadial'),
  };
  return `
    <div class="bk-viewswitch" role="group" aria-label="${t('bracket.viewLabel')}">
      ${VIEW_IDS.map((id) => `
        <button class="view-btn ${id === active ? 'active' : ''}" data-view="${id}"
                aria-pressed="${id === active}">${labels[id]}</button>`).join('')}
    </div>`;
}

function render() {
  const tree = getBracketTree();
  const simulation = getSimulation();
  const challenge = calculateChallengeScore(simulation, getData().results, tree);
  const hasPicks = Object.keys(simulation).length > 0;
  const viewId = activeView();
  const chart = viewId !== 'rounds';
  const L = chart ? (viewId === 'radial' ? computeRadialLayout() : computeWallchartLayout()) : null;

  // the fit observer from the previous render watches removed DOM — reset
  if (fitRO) { fitRO.disconnect(); fitRO = null; }

  document.getElementById('bracket-root').innerHTML = `
    ${challenge.total ? challengeCardHTML(challenge) : ''}
    <div class="bracket-toolbar">
      <div class="bracket-tools-left">
        ${viewSwitchHTML(viewId)}
        <button class="zoom-btn sim-toggle ${simMode ? 'active' : ''}" id="sim-toggle"
                aria-pressed="${simMode}">${t('sim.mode')}</button>
        <button class="zoom-btn" id="sim-reset">${t('sim.reset')}</button>
        <button class="zoom-btn" id="share-pred" ${hasPicks ? '' : 'disabled'}>${t('share.button')}</button>
      </div>
      ${chart ? `
      <div class="bracket-tools-right">
        <button class="zoom-btn" id="zoom-out" aria-label="${t('bracket.zoomOut')}">−</button>
        <button class="zoom-btn zoom-reset" id="zoom-reset" aria-label="${t('bracket.zoomReset')}">100%</button>
        <button class="zoom-btn" id="zoom-in" aria-label="${t('bracket.zoomIn')}">+</button>
      </div>` : ''}
    </div>
    ${simMode ? `<p class="sim-note">${t('sim.hint')}</p>` : ''}
    ${chart ? chartHTML(tree, L, viewId) : pagerHTML(tree)}`;

  for (const btn of document.querySelectorAll('#bracket-root .view-btn')) {
    btn.addEventListener('click', () => {
      if (btn.dataset.view === viewId) return;
      setBracketView(btn.dataset.view);
      render();
    });
  }
  document.getElementById('sim-toggle').addEventListener('click', () => {
    simMode = !simMode;
    render();
  });
  document.getElementById('sim-reset').addEventListener('click', () => {
    storageSet('simulation', {});
    refreshAfterSimChange();
  });
  document.getElementById('share-pred').addEventListener('click', async (event) => {
    const btn = event.currentTarget;
    const link = getShareableLink();
    try {
      await navigator.clipboard.writeText(link);
      const original = btn.textContent;
      btn.textContent = t('share.copied');
      setTimeout(() => { btn.textContent = original; }, 2000);
    } catch {
      window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually
    }
  });
  if (chart) initInteractions(L);
  else initPager(tree);
}

function chartHTML(tree, L, viewId) {
  const inner = viewId === 'radial' ? radialInnerHTML(tree, L) : wallchartInnerHTML(tree, L);
  return `
    <div class="bracket-wrap" id="bracket-wrap" style="aspect-ratio:${L.w}/${L.h}">
      <div class="bracket-zoom" id="bracket-zoom">
        <div class="bracket ${viewId} ${simMode ? 'sim-on' : ''}" id="bracket-canvas"
             style="width:${L.w}px;height:${L.h}px">
          ${inner}
        </div>
      </div>
    </div>`;
}

function wallchartInnerHTML(tree, L) {
  let cardsHTML = '';
  for (const round of tree.rounds) {
    for (const node of round.nodes) cardsHTML += matchNodeHTML(node, L.cards.get(node.ref));
  }
  cardsHTML += matchNodeHTML(tree.third, L.cards.get('THIRD-PLACE'));
  return `
    ${sceneryHTML(L)}
    ${linksHTML(L)}
    ${L.labels.map((lb) => `
      <span class="bk-title bk-title-${lb.tier.toLowerCase()}"
            style="left:${lb.x}px;top:${lb.y}px;width:${lb.w}px">
        ${translatePhase(lb.phase)}</span>`).join('')}
    ${cardsHTML}
    ${centerHTML(tree, L)}`;
}

// -------------------------------------------------- radial (orbit) view

// short tooltip line for a match: score when played, kickoff otherwise
function matchTip(node) {
  if (!node.match) return '';
  const home = slotDisplay(node.home);
  const away = slotDisplay(node.away);
  const { result } = node;
  if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) {
    const pens = result.penalties ? ` (${result.penalties.home}-${result.penalties.away})` : '';
    return `${home.label} ${result.homeScore}–${result.awayScore} ${away.label}${pens}`;
  }
  if (node.simulated && node.simScore) {
    return `${home.label} ${node.simScore.home}–${node.simScore.away} ${away.label} · ${t('sim.chip')}`;
  }
  return `${home.label} ${t('hero.vs')} ${away.label} · ${kickoffShort(node.match)}`;
}

function radialTokenHTML(pos, d, display, node, opts = {}) {
  const classes = [
    'bk-token',
    opts.ring ? `tk-${opts.ring}` : '',
    display.team ? '' : 'tk-tbd',
    opts.out ? 'tk-out' : '',
    opts.winner ? (node.simulated ? 'tk-sim' : 'tk-winner') : '',
    opts.live ? 'tk-live' : '',
    opts.fav ? 'tk-fav' : '',
    opts.slot && simMode && isSimulatable(node) ? 'simulatable' : '',
  ].filter(Boolean).join(' ');
  const tip = opts.slot ? matchTip(node) : display.label;
  const interactive = node.match
    ? `data-match-id="${node.match.id}" tabindex="0" role="button"
       aria-label="${matchTip(node) || display.label} — ${translatePhase(node.phase)}"`
    : '';
  const flag = display.team
    ? `<img src="${flagSrc(display.team)}" alt="" width="${d}" height="${d}" loading="lazy">`
    : '';
  return `
    <article class="${classes} ${tip ? 'has-tip' : ''}" data-ref="${node.ref}" ${interactive}
             ${tip ? `data-tip="${tip}"` : ''}
             style="left:${pos.x - d / 2}px;top:${pos.y - d / 2}px;width:${d}px;height:${d}px">
      ${flag}
    </article>`;
}

function radialInnerHTML(tree, L) {
  const G = TGEO;
  const { cx, cy } = L;
  const favorites = getFavorites();
  const roundsById = new Map(tree.rounds.map((r) => [r.id, r]));
  const polar = (slot, n, r) => {
    const th = ((slot + 0.5) / n) * 2 * Math.PI;
    return { x: cx + r * Math.sin(th), y: cy - r * Math.cos(th), th, r };
  };

  const tokens = [];
  const lines = [];
  const dots = [];
  // elbow: out of the participant radially, then straight to the winner slot
  const addLine = (from, to, node, sideTeamId, fromRef) => {
    const rMid = to.r != null ? to.r + (from.r - (to.r ?? 0)) * 0.5 : from.r * 0.5;
    const bend = { x: cx + rMid * Math.sin(from.th), y: cy - rMid * Math.cos(from.th) };
    const adv = Boolean(node.winner && node.winner === sideTeamId);
    const state = adv ? (node.simulated ? 'is-sim' : 'is-adv') : '';
    lines.push(`
      <path class="bk-le ${state}" pathLength="1" data-from="${fromRef}" data-to="${node.ref}"
            d="M ${from.x} ${from.y} L ${bend.x} ${bend.y} L ${to.x} ${to.y}"/>`);
    dots.push(`<circle class="bk-dot ${state}" cx="${bend.x}" cy="${bend.y}" r="2.2"/>`);
  };

  // entrants (outer ring) + one winner slot per match, ring by ring inward
  const winnerPos = new Map();
  roundsById.get('R32').nodes.forEach((node, m) => {
    const wPos = polar(m, 16, G.winners.R32.r);
    winnerPos.set(node.ref, wPos);
    [['home', 2 * m], ['away', 2 * m + 1]].forEach(([side, slot]) => {
      const pos = polar(slot, 32, G.outerR);
      const teamId = node[side].teamId;
      tokens.push(radialTokenHTML(pos, G.outerD, slotDisplay(node[side]), node, {
        ring: 'ent',
        out: Boolean(node.winner && teamId && node.winner !== teamId),
        fav: favorites.includes(teamId),
      }));
      addLine(pos, wPos, node, teamId, node.ref);
    });
  });
  const winnerSlot = (node, ring, pos) => {
    const display = node.winner ? slotDisplay({ teamId: node.winner }) : { team: null, label: t('app.tbd') };
    return radialTokenHTML(pos, G.winners[ring].d, display, node, {
      ring: ring.toLowerCase(),
      slot: true, // this token IS the match's winner slot — sim affordance lives here
      winner: Boolean(node.winner),
      live: node.result?.status === 'live',
      fav: Boolean(node.winner) && favorites.includes(node.winner),
    });
  };
  roundsById.get('R32').nodes.forEach((node) => tokens.push(winnerSlot(node, 'R32', winnerPos.get(node.ref))));
  for (const [roundId, prevId, n, ring] of [['R16', 'R32', 8, 'R16'], ['QF', 'R16', 4, 'QF'], ['SF', 'QF', 2, 'SF']]) {
    roundsById.get(roundId).nodes.forEach((node, i) => {
      const wPos = polar(i, n, G.winners[ring].r);
      winnerPos.set(node.ref, wPos);
      for (const [side, childRef] of [['home', refName(prevId, i * 2)], ['away', refName(prevId, i * 2 + 1)]]) {
        addLine(winnerPos.get(childRef), wPos, node, node[side].teamId, childRef);
      }
      tokens.push(winnerSlot(node, ring, wPos));
    });
  }

  // trophy center = the FINAL's winner slot
  const finalNode = tree.nodesByRef.get('FINAL');
  const centerPos = { x: cx, y: cy, th: 0, r: 0 };
  addLine(winnerPos.get('SF-1'), centerPos, finalNode, finalNode.home.teamId, 'SF-1');
  addLine(winnerPos.get('SF-2'), centerPos, finalNode, finalNode.away.teamId, 'SF-2');
  const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
  const simChampion = Boolean(tree.champion && finalNode.simulated);
  const centerTip = matchTip(finalNode);
  const centerHTMLStr = `
    <article class="bk-center ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}
                    ${simMode && isSimulatable(finalNode) ? 'simulatable' : ''} ${centerTip ? 'has-tip' : ''}"
             data-ref="FINAL" data-match-id="${finalNode.match.id}" tabindex="0" role="button"
             aria-label="${centerTip} — ${translatePhase('Final')}" ${centerTip ? `data-tip="${centerTip}"` : ''}
             style="left:${cx - G.championD / 2}px;top:${cy - G.championD / 2}px;width:${G.championD}px;height:${G.championD}px">
      ${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
      <span class="bk-center-trophy" aria-hidden="true">🏆</span>
      ${champion?.team ? `<img src="${flagSrc(champion.team)}" alt="" width="${G.championD}" height="${G.championD}">` : ''}
      ${champion ? `<span class="bk-center-name">${champion.label}</span>` : ''}
    </article>`;

  // third place: a small labeled pair below the circle (SF losers)
  const third = tree.third;
  const thirdTip = matchTip(third);
  const thirdToken = (side) => {
    const display = slotDisplay(third[side]);
    const win = Boolean(third.winner && third[side].teamId === third.winner);
    const flag = display.team
      ? `<img src="${flagSrc(display.team)}" alt="" width="${G.thirdD}" height="${G.thirdD}" loading="lazy">`
      : '';
    return `
      <span class="bk-token ${display.team ? '' : 'tk-tbd'} ${win ? (third.simulated ? 'tk-sim' : 'tk-winner') : ''}"
            style="width:${G.thirdD}px;height:${G.thirdD}px">${flag}</span>`;
  };
  const thirdHTML = `
    <div class="bk-third-group ${thirdTip ? 'has-tip' : ''}" data-ref="THIRD-PLACE"
         ${third.match ? `data-match-id="${third.match.id}" tabindex="0" role="button"
         aria-label="${thirdTip} — ${translatePhase(third.phase)}"` : ''}
         ${thirdTip ? `data-tip="${thirdTip}"` : ''}
         style="left:${cx - 110}px;top:${L.thirdY - G.thirdD / 2 - 26}px;width:220px">
      <span class="bk-third-title">${translatePhase('Third Place')}</span>
      <span class="bk-third-tokens">${thirdToken('home')}${thirdToken('away')}</span>
    </div>`;

  const orbits = [G.outerR, G.winners.R32.r, G.winners.R16.r, G.winners.QF.r]
    .map((r) => `<circle class="bk-orbit" cx="${cx}" cy="${cy}" r="${r}"/>`).join('');
  return `
    <svg class="bk-links" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
      <defs>
        <radialGradient id="bk-glow-grad">
          <stop offset="0%" stop-color="rgba(212, 175, 55, 0.30)"/>
          <stop offset="55%" stop-color="rgba(212, 175, 55, 0.10)"/>
          <stop offset="100%" stop-color="rgba(212, 175, 55, 0)"/>
        </radialGradient>
      </defs>
      <circle cx="${cx}" cy="${cy}" r="${G.glowR}" fill="url(#bk-glow-grad)"/>
      ${orbits}
      <circle class="bk-orbit-accent" cx="${cx}" cy="${cy}" r="${G.winners.SF.r + 42}"/>
      ${lines.join('')}
      ${dots.join('')}
    </svg>
    ${tokens.join('')}
    ${centerHTMLStr}
    ${thirdHTML}`;
}

// faint pitch geometry echoing the wallchart layout: halfway line through
// the center column, center circle around the Final — the stadium floor.
// (The radial view draws its own orbit scenery inside radialInnerHTML.)
function sceneryHTML(L) {
  const { cx, cy, r } = L.scenery;
  return `
    <svg class="bk-scenery" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
      <line x1="${cx}" y1="0" x2="${cx}" y2="${L.h}"/>
      <circle cx="${cx}" cy="${cy}" r="${r}"/>
      <circle class="bk-scenery-dot" cx="${cx}" cy="${cy}" r="3.5"/>
    </svg>`;
}

function linksHTML(L) {
  const paths = L.links.map((link) => `
    <path class="bk-l-${link.tier.toLowerCase()}" d="${link.d}" pathLength="1"
          data-from="${link.from}" data-to="${link.to}"/>`).join('');
  return `
    <svg class="bk-links" width="${L.w}" height="${L.h}" viewBox="0 0 ${L.w} ${L.h}" aria-hidden="true">
      ${paths}
      <path class="bk-stem" d="${L.stem}" pathLength="1" data-from="FINAL" data-to="FINAL"/>
    </svg>`;
}

// champion centerpiece + third-place block (center column).
// A simulated champion renders in the sim-blue treatment with a SIM chip —
// a user's picks must never be mistakable for a real result (same rule the
// stats verdict follows).
function centerHTML(tree, L) {
  const finalNode = tree.nodesByRef.get('FINAL');
  const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
  const simChampion = Boolean(tree.champion && finalNode?.simulated);
  const flag = champion?.team
    ? `<img class="flag" src="${flagSrc(champion.team)}" alt="" width="26" height="18">`
    : '';
  const c = L.champion;
  const tt = L.thirdTitle;
  return `
    <div class="bk-champion ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}"
         style="left:${c.x}px;top:${c.y}px;width:${c.w}px;height:${c.h}px">
      ${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
      <span class="champion-trophy" aria-hidden="true">🏆</span>
      <span class="champion-label">${t('bracket.champion')}</span>
      <span class="champion-name">${champion ? `${flag}<span>${champion.label}</span>` : t('app.tbd')}</span>
    </div>
    <span class="bk-third-title" style="left:${tt.x}px;top:${tt.y}px;width:${tt.w}px">
      ${translatePhase('Third Place')}</span>`;
}

function matchNodeHTML(node, pos) {
  const home = slotDisplay(node.home);
  const away = slotDisplay(node.away);
  const live = node.result?.status === 'live';
  const favorites = getFavorites();
  const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId);
  const classes = [
    'bracket-match',
    `bk-${pos.tier.toLowerCase()}`,
    live ? 'is-live' : '',
    node.simulated ? 'is-sim' : '',
    hasFav ? 'has-fav' : '',
    simMode && isSimulatable(node) ? 'simulatable' : '',
  ].filter(Boolean).join(' ');
  const interactive = node.match
    ? `data-match-id="${node.match.id}" tabindex="0" role="button"
       aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"`
    : '';
  return `
    <article class="${classes}" data-ref="${node.ref}" ${interactive}
             style="left:${pos.x}px;top:${pos.y}px;width:${pos.w}px;height:${pos.h}px">
      ${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
      <div class="bk-teams">
        ${teamRowHTML(home, node, 'home')}
        ${teamRowHTML(away, node, 'away')}
      </div>
      ${metaHTML(node)}
    </article>`;
}

// status fragment shared by chart microlines and pager cards
function statusLine(node) {
  const status = node.result?.status;
  if (status === 'live') return `<span class="bk-meta-live">${t('hero.live')}</span>`;
  if (status === 'finished') return `<span class="bk-meta-ft">${t('bracket.ft')}</span>`;
  return node.match ? `<span>${kickoffShort(node.match)}</span>` : '';
}

// microline at the foot of a card: kickoff (upcoming) / LIVE pulse / FT.
// The Final's hero card also carries its venue.
function metaHTML(node) {
  if (!node.match) return '';
  const venue = node.ref === 'FINAL'
    ? `<span class="bk-venue">${node.match.stadium} · ${node.match.city}</span>`
    : '';
  return `<div class="bk-meta">${venue}${statusLine(node)}</div>`;
}

// ------------------------------------------------------- rounds pager
// Round-by-round view: chip buttons switch the visible page (R32 → R16 →
// QF → SF → Finals) — button navigation only, no horizontal scrolling.
// Cards are the fuller variant (venue + city). Same data-ref/data-match-id
// contract, so the root delegation (modal + sim editor) works unchanged.

let pagerIndex = null; // survives re-renders (langchange etc.), not reloads

const PAGER_PAGES = [
  { id: 'R32', phase: 'Round of 32' },
  { id: 'R16', phase: 'Round of 16' },
  { id: 'QF', phase: 'Quarterfinals' },
  { id: 'SF', phase: 'Semifinals' },
  { id: 'FINALS', phase: 'Final' },
];

// first round that still has an unfinished match — the page worth opening on
function firstOpenPage(tree) {
  for (let i = 0; i < 4; i += 1) {
    const round = tree.rounds.find((r) => r.id === PAGER_PAGES[i].id);
    if (round.nodes.some((n) => n.result?.status !== 'finished')) return i;
  }
  return 4;
}

function pcardHTML(node, hero = false) {
  const home = slotDisplay(node.home);
  const away = slotDisplay(node.away);
  const live = node.result?.status === 'live';
  const favorites = getFavorites();
  const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId);
  const classes = [
    'bk-pcard',
    hero ? 'bk-pcard-hero' : '',
    live ? 'is-live' : '',
    node.simulated ? 'is-sim' : '',
    hasFav ? 'has-fav' : '',
    simMode && isSimulatable(node) ? 'simulatable' : '',
  ].filter(Boolean).join(' ');
  const interactive = node.match
    ? `data-match-id="${node.match.id}" tabindex="0" role="button"
       aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"`
    : '';
  const top = node.match
    ? `<div class="bk-pcard-top">
         <span class="bk-pcard-venue">${node.match.stadium} · ${node.match.city}</span>
         ${statusLine(node)}
       </div>`
    : '';
  return `
    <article class="${classes}" data-ref="${node.ref}" ${interactive}>
      ${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
      ${top}
      ${teamRowHTML(home, node, 'home')}
      ${teamRowHTML(away, node, 'away')}
    </article>`;
}

function pagerFinalsHTML(tree) {
  const finalNode = tree.nodesByRef.get('FINAL');
  const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
  const simChampion = Boolean(tree.champion && finalNode?.simulated);
  const flag = champion?.team
    ? `<img class="flag" src="${flagSrc(champion.team)}" alt="" width="26" height="18">`
    : '';
  return `
    <div class="bk-champion bk-flow ${champion ? 'has-champion' : ''} ${simChampion ? 'is-sim' : ''}">
      ${simChampion ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
      <span class="champion-trophy" aria-hidden="true">🏆</span>
      <span class="champion-label">${t('bracket.champion')}</span>
      <span class="champion-name">${champion ? `${flag}<span>${champion.label}</span>` : t('app.tbd')}</span>
    </div>
    ${pcardHTML(finalNode, true)}
    <h4 class="bk-page-sub">${translatePhase('Third Place')}</h4>
    ${pcardHTML(tree.third)}`;
}

function pagerHTML(tree) {
  const roundsById = new Map(tree.rounds.map((r) => [r.id, r]));
  const chips = PAGER_PAGES.map((page, i) => `
    <button class="bk-page-btn" data-page="${i}" aria-pressed="false">
      ${translatePhase(page.phase)}</button>`).join('');
  const pages = PAGER_PAGES.map((page) => {
    const body = page.id === 'FINALS'
      ? `<div class="bk-page-list bk-page-finals">${pagerFinalsHTML(tree)}</div>`
      : `<div class="bk-page-list">${roundsById.get(page.id).nodes.map((n) => pcardHTML(n)).join('')}</div>`;
    return `<section class="bk-page" aria-label="${translatePhase(page.phase)}">${body}</section>`;
  }).join('');
  return `
    <div class="bk-pager ${simMode ? 'sim-on' : ''}">
      <nav class="bk-pager-nav" id="bk-pager-nav" aria-label="${t('bracket.viewRounds')}">${chips}</nav>
      <div class="bk-pages" id="bk-pages">${pages}</div>
    </div>`;
}

function initPager(tree) {
  const pages = [...document.querySelectorAll('#bk-pages .bk-page')];
  const chips = [...document.querySelectorAll('#bk-pager-nav .bk-page-btn')];
  if (pagerIndex === null) pagerIndex = firstOpenPage(tree);

  const show = (i) => {
    pagerIndex = Math.max(0, Math.min(PAGER_PAGES.length - 1, i));
    pages.forEach((page, k) => { page.hidden = k !== pagerIndex; });
    chips.forEach((chip, k) => {
      chip.classList.toggle('active', k === pagerIndex);
      chip.setAttribute('aria-pressed', String(k === pagerIndex));
    });
  };
  chips.forEach((chip, i) => chip.addEventListener('click', () => show(i)));
  show(pagerIndex);
}

// ----------------------------------------------------- simulation (step 9)

let simMode = false;

function getSimulation() {
  return storageGet('simulation', {});
}

function refreshAfterSimChange() {
  invalidateBracket();
  render();
  document.dispatchEvent(new CustomEvent('simchange'));
}

function ensureSimDialog() {
  if (document.getElementById('sim-dialog')) return;
  document.getElementById('modal-root').insertAdjacentHTML(
    'beforeend',
    '<dialog class="match-modal sim-modal" id="sim-dialog"></dialog>',
  );
  const dialog = document.getElementById('sim-dialog');
  dialog.addEventListener('click', (event) => {
    if (event.target === dialog) dialog.close();
  });
}

function openSimEditor(ref) {
  ensureSimDialog();
  const dialog = document.getElementById('sim-dialog');
  const node = getBracketTree().nodesByRef.get(ref);
  const existing = getSimulation()[ref] ?? null;
  const home = slotDisplay(node.home);
  const away = slotDisplay(node.away);
  const [scoreHome, scoreAway] = (existing?.score ?? '').split('-');
  let selected = existing?.winner ?? null;

  const teamButton = (slot, display) => `
    <button class="sim-team ${selected === slot.teamId ? 'active' : ''}" data-team="${slot.teamId}">
      <img class="flag" src="${flagSrc(display.team)}" alt="" width="34" height="23">
      <span>${display.label}</span>
    </button>`;

  dialog.innerHTML = `
    <div class="modal-content">
      <header class="modal-top">
        <p class="modal-phase">${t('sim.title')} — ${translatePhase(node.phase)}</p>
        <button class="modal-close" data-close aria-label="${t('modal.close')}">✕</button>
      </header>
      <p class="sim-hint">${t('sim.pickWinner')}</p>
      <div class="sim-teams">
        ${teamButton(node.home, home)}
        ${teamButton(node.away, away)}
      </div>
      <div class="sim-scores">
        <label>${home.label}
          <input class="filter-control" type="number" id="sim-score-home" min="0" max="99"
                 inputmode="numeric" value="${scoreHome ?? ''}">
        </label>
        <label>${away.label}
          <input class="filter-control" type="number" id="sim-score-away" min="0" max="99"
                 inputmode="numeric" value="${scoreAway ?? ''}">
        </label>
      </div>
      <div class="sim-actions">
        <button class="link-btn" id="sim-clear" ${existing ? '' : 'hidden'}>${t('sim.clear')}</button>
        <button class="btn-primary" id="sim-save" ${selected ? '' : 'disabled'}>${t('sim.save')}</button>
      </div>
    </div>`;

  const saveBtn = dialog.querySelector('#sim-save');
  const homeInput = dialog.querySelector('#sim-score-home');
  const awayInput = dialog.querySelector('#sim-score-away');

  const select = (teamId) => {
    selected = teamId;
    for (const btn of dialog.querySelectorAll('.sim-team')) {
      btn.classList.toggle('active', btn.dataset.team === teamId);
    }
    saveBtn.disabled = !selected;
  };

  for (const btn of dialog.querySelectorAll('.sim-team')) {
    btn.addEventListener('click', () => select(btn.dataset.team));
  }

  // an unequal score implies the winner; a draw needs an explicit pick (pens)
  const onScoreInput = () => {
    const h = homeInput.value === '' ? null : Number(homeInput.value);
    const a = awayInput.value === '' ? null : Number(awayInput.value);
    if (h === null || a === null || h === a) return;
    select(h > a ? node.home.teamId : node.away.teamId);
  };
  homeInput.addEventListener('input', onScoreInput);
  awayInput.addEventListener('input', onScoreInput);

  dialog.setAttribute('aria-label', `${t('sim.title')} — ${home.label} ${t('hero.vs')} ${away.label}`);
  dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
  dialog.querySelector('#sim-clear').addEventListener('click', () => {
    const simulation = getSimulation();
    delete simulation[ref];
    storageSet('simulation', simulation);
    dialog.close();
    refreshAfterSimChange();
  });
  saveBtn.addEventListener('click', () => {
    if (!selected) return;
    const h = homeInput.value === '' ? (selected === node.home.teamId ? 1 : 0) : Number(homeInput.value);
    const a = awayInput.value === '' ? (selected === node.away.teamId ? 1 : 0) : Number(awayInput.value);
    const simulation = getSimulation();
    simulation[ref] = { winner: selected, score: `${h}-${a}` };
    storageSet('simulation', simulation);
    dialog.close();
    refreshAfterSimChange();
  });

  dialog.showModal();
}

// ------------------------------------------------- interactions (step 8)

const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL'];
// Zoom state survives re-renders (language switch) but not reloads.
// The fit scale (whole chart visible) is the resting point and the zoom
// label's "100%"; it's recomputed by a ResizeObserver because the panel is
// usually hidden when render() first runs (clientWidth 0 — gotcha class).
const view = { scale: 1, fit: 0, natW: 0, natH: 0, userZoomed: false };
const MAX_SCALE = 2;
let fitRO = null;

function initInteractions(layout) {
  const wrap = document.getElementById('bracket-wrap');
  const zoomBox = document.getElementById('bracket-zoom');
  const canvas = document.getElementById('bracket-canvas');
  if (view.layoutId !== layout.id) { // wallchart ↔ radial: fresh fit, not inherited zoom
    view.layoutId = layout.id;
    view.userZoomed = false;
    view.fit = 0;
  }
  view.natW = layout.w; // geometry is computed, not measured — always known
  view.natH = layout.h;
  if (view.fit) {
    if (!view.userZoomed) view.scale = view.fit;
    applyScale(wrap, zoomBox, canvas);
  }
  updateZoomLabel();

  if (fitRO) fitRO.disconnect();
  fitRO = new ResizeObserver(() => {
    const cw = wrap.clientWidth;
    const ch = wrap.clientHeight;
    if (cw <= 0 || ch <= 0) return; // panel hidden — wait for the tab to open
    view.fit = Math.min(cw / view.natW, ch / view.natH, 1);
    if (!view.userZoomed || view.scale < view.fit) view.scale = Math.max(view.fit, view.userZoomed ? view.scale : 0);
    if (!view.userZoomed) view.scale = view.fit;
    applyScale(wrap, zoomBox, canvas);
    updateZoomLabel();
  });
  fitRO.observe(wrap);

  const setScale = (next, cx, cy) => {
    if (!view.fit) return;
    const scale = Math.min(MAX_SCALE, Math.max(view.fit, next));
    if (scale === view.scale) return;
    const rect = wrap.getBoundingClientRect();
    const px = (cx - rect.left + wrap.scrollLeft) / view.scale;
    const py = (cy - rect.top + wrap.scrollTop) / view.scale;
    view.scale = scale;
    view.userZoomed = true;
    applyScale(wrap, zoomBox, canvas);
    wrap.scrollLeft = px * scale - (cx - rect.left);
    wrap.scrollTop = py * scale - (cy - rect.top);
    updateZoomLabel();
  };

  const zoomAtCenter = (factor) => {
    const rect = wrap.getBoundingClientRect();
    setScale(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
  };

  document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25));
  document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25));
  document.getElementById('zoom-reset').addEventListener('click', () => {
    if (!view.fit) return;
    view.scale = view.fit;
    view.userZoomed = false;
    applyScale(wrap, zoomBox, canvas);
    updateZoomLabel();
  });

  wrap.addEventListener('wheel', (event) => {
    event.preventDefault();
    setScale(view.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY);
  }, { passive: false });

  // drag to pan (1 pointer) + pinch to zoom (2 pointers)
  const pointers = new Map();
  let dragging = false;
  let dragMoved = false;
  let start = null;
  let pinch = null;

  wrap.addEventListener('pointerdown', (event) => {
    pointers.set(event.pointerId, event);
    // IMPORTANT: do NOT setPointerCapture here — capturing retargets the
    // eventual click to the wrap, which kills match-node clicks (modal and
    // simulation). Capture only once a real drag/pinch begins.
    if (pointers.size === 1) {
      dragging = true;
      dragMoved = false;
      start = { x: event.clientX, y: event.clientY, left: wrap.scrollLeft, top: wrap.scrollTop };
    } else if (pointers.size === 2) {
      dragging = false;
      for (const p of pointers.values()) {
        try { wrap.setPointerCapture(p.pointerId); } catch { /* pointer already gone */ }
      }
      const [a, b] = [...pointers.values()];
      pinch = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: view.scale };
    }
  });

  wrap.addEventListener('pointermove', (event) => {
    if (!pointers.has(event.pointerId)) return;
    pointers.set(event.pointerId, event);
    if (pointers.size === 2 && pinch) {
      const [a, b] = [...pointers.values()];
      const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
      setScale(pinch.scale * (dist / pinch.dist), (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
    } else if (dragging) {
      const dx = event.clientX - start.x;
      const dy = event.clientY - start.y;
      if (!dragMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
        dragMoved = true;
        try { wrap.setPointerCapture(event.pointerId); } catch { /* pointer already gone */ }
        wrap.classList.add('dragging');
      }
      if (dragMoved) {
        wrap.scrollLeft = start.left - dx;
        wrap.scrollTop = start.top - dy;
      }
    }
  });

  const endPointer = (event) => {
    pointers.delete(event.pointerId);
    if (pointers.size < 2) pinch = null;
    if (pointers.size === 0) {
      dragging = false;
      wrap.classList.remove('dragging');
    }
  };
  wrap.addEventListener('pointerup', endPointer);
  wrap.addEventListener('pointercancel', endPointer);

  // a real drag must not fire the match-node click underneath
  wrap.addEventListener('click', (event) => {
    if (dragMoved) {
      event.stopPropagation();
      event.preventDefault();
      dragMoved = false;
    }
  }, true);

  // hover/focus path highlight — any ref-carrying element (card or token)
  canvas.addEventListener('mouseover', (event) => {
    const node = event.target.closest('[data-ref]');
    if (node) showPath(node.dataset.ref);
  });
  canvas.addEventListener('mouseout', () => clearPath());
  canvas.addEventListener('focusin', (event) => {
    const node = event.target.closest('[data-ref]');
    if (node) showPath(node.dataset.ref);
  });
  canvas.addEventListener('focusout', () => clearPath());
}

function applyScale(wrap, zoomBox, canvas) {
  zoomBox.style.width = `${view.natW * view.scale}px`;
  zoomBox.style.height = `${view.natH * view.scale}px`;
  canvas.style.transform = `scale(${view.scale})`;
}

// 100% = the whole-chart fit, not natural card size
function updateZoomLabel() {
  const label = document.getElementById('zoom-reset');
  if (label) label.textContent = `${Math.round((view.scale / (view.fit || 1)) * 100)}%`;
}

// ----------------------------------------------------- path highlight

function refOf(roundIndex, nodeIndex) {
  const id = ROUND_ORDER[roundIndex];
  return id === 'FINAL' ? 'FINAL' : `${id}-${nodeIndex + 1}`;
}

// full path = the hovered node, every feeder below it, and its winner's
// route up to the final
function pathRefs(ref) {
  const refs = new Set([ref]);
  if (ref === 'THIRD-PLACE') {
    refs.add('SF-1');
    refs.add('SF-2');
    return refs;
  }
  const [roundId, num] = ref === 'FINAL' ? ['FINAL', '1'] : ref.split('-');
  const r = ROUND_ORDER.indexOf(roundId);
  if (r < 0) return refs;
  let i = Number(num) - 1;

  for (let rr = r, ii = i; rr < ROUND_ORDER.length - 1; rr += 1) {
    ii = Math.floor(ii / 2);
    refs.add(refOf(rr + 1, ii));
  }
  const stack = [[r, i]];
  while (stack.length) {
    const [cr, ci] = stack.pop();
    if (cr === 0) continue;
    for (const child of [ci * 2, ci * 2 + 1]) {
      refs.add(refOf(cr - 1, child));
      stack.push([cr - 1, child]);
    }
  }
  return refs;
}

function showPath(ref) {
  const canvas = document.getElementById('bracket-canvas');
  clearPath();
  canvas.classList.add('has-path');
  const refs = pathRefs(ref);
  for (const r of refs) {
    // cards (wallchart), tokens/center/third group (radial) — anything owning the ref
    for (const el of canvas.querySelectorAll(`[data-ref="${r}"]`)) el.classList.add('path-on');
  }
  // a connector lights up when both of its endpoints are on the path
  // (the champion stem carries FINAL→FINAL, so it follows the final)
  for (const path of canvas.querySelectorAll('.bk-links path')) {
    if (refs.has(path.dataset.from) && refs.has(path.dataset.to)) path.classList.add('path-on');
  }
}

function clearPath() {
  const canvas = document.getElementById('bracket-canvas');
  canvas.classList.remove('has-path');
  for (const el of canvas.querySelectorAll('.path-on')) el.classList.remove('path-on');
}

function teamRowHTML(display, node, side) {
  const { result } = node;
  let score = '';
  if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) {
    const goals = side === 'home' ? result.homeScore : result.awayScore;
    const pens = result.penalties
      ? `<small>(${side === 'home' ? result.penalties.home : result.penalties.away})</small>`
      : '';
    score = `<span class="bt-score">${goals}${pens}</span>`;
  } else if (node.simulated && node.simScore) {
    score = `<span class="bt-score sim">${node.simScore[side]}</span>`;
  }
  const isWinner = node.winner !== null && node[side].teamId === node.winner;
  const flag = display.team
    ? `<img class="flag" src="${flagSrc(display.team)}" alt="" width="20" height="14" loading="lazy">`
    : '<span class="bt-flag-ph" aria-hidden="true"></span>';
  return `
    <div class="bracket-team ${isWinner ? 'winner' : ''} ${display.team ? '' : 'tbd'}">
      ${flag}
      <span class="bt-name">${display.label}</span>
      ${score}
    </div>`;
}
