/* global React, Widget, WeatherData,
   StationWidget, HourlyWidget, DailyWidget, ChartWidget, ModelsWidget,
   WindWidget, SunWidget, AirWidget, AlertsWidget, SensorsWidget, IndoorWidget,
   CompareWidget */
const { useState, useRef, useEffect, useLayoutEffect, useCallback } = React;

// Widget registry: id -> meta + renderer.
//   span    default width (of 12 columns)
//   minSpan narrowest width that still makes sense — the size chips and the
//           resize handle never go below it, and saved layouts are clamped
//   minH    default minimum height (px) for tiles whose content fills the
//           tile (charts, dials); others size to their content
window.WIDGET_CATALOG = {
  station: {
    id: 'station', title: 'Aktuálně', icon: 'thermometer-half', span: 12, minSpan: 3,
    desc: 'Teplota teď, pocitová teplota, rozdíl proti včerejšku a rozsah za 24 h.'
  },
  tempChart: {
    id: 'tempChart', title: 'Teplota ±24 h', icon: 'thermometer-sun', span: 8, minSpan: 3, minH: 240,
    desc: 'Spojený graf — minulých 24 h ze stanice, dalších 24 h z předpovědi. Volitelně srážky a ikony počasí.'
  },
  hourly: {
    id: 'hourly', title: 'Hodinová předpověď', icon: 'clock-history', span: 12, minSpan: 3,
    desc: 'Vodorovný proužek hodinových dat, 12 / 24 / 48 / 72 h.'
  },
  chart: {
    id: 'chart', title: 'Graf předpovědi', icon: 'graph-up-arrow', span: 12, minSpan: 3, minH: 380,
    desc: 'Teplota, srážky, vítr, vlhkost a originální PNG ikony.'
  },
  daily: {
    id: 'daily', title: 'Denní přehled', icon: 'calendar3', span: 8, minSpan: 3,
    desc: 'Dny dopředu s rozsahem min/max teploty a srážkami.'
  },
  models: {
    id: 'models', title: 'Modely', icon: 'cpu', span: 4, minSpan: 3,
    desc: 'Žebříček podle odchylky od stanice. AUTO režim vybírá nejpřesnější.'
  },
  compare: {
    id: 'compare', title: 'Soulad s předpovědí', icon: 'clipboard-check', span: 4, minSpan: 3,
    desc: 'Co stanice teď měří a co předpověď na tuto hodinu čekala — u každé veličiny, zda je v souladu, nebo o kolik se liší.'
  },
  wind: {
    id: 'wind', title: 'Vítr', icon: 'wind', span: 3, minSpan: 3, minH: 240,
    desc: 'Rychlost, nárazy, směr a předpověď na tuto hodinu.'
  },
  sun: {
    id: 'sun', title: 'Slunce a Měsíc', icon: 'sun', span: 3, minSpan: 3,
    desc: 'Východ, západ, délka dne, fáze Měsíce.'
  },
  sensors: {
    id: 'sensors', title: 'Meteostanice 24 h', icon: 'broadcast-pin', span: 12, minSpan: 3,
    desc: 'Živé venkovní senzory: teplota, vlhkost, tlak, vítr, srážky, UV, sluneční záření.'
  },
  webcam: {
    id: 'webcam', title: 'Webkamera', icon: 'camera-video', span: 6, minSpan: 3,
    desc: 'Aktuální snímek z webkamery připojené ke stanici. Klikem otevřete v plné velikosti.'
  },
  indoor: {
    id: 'indoor', title: 'Vnitřní čidla', icon: 'house-door', span: 6, minSpan: 3,
    desc: 'Vnitřní teploty, vlhkost, CO₂, prach, půdní vlhkost.'
  },
  windRose: {
    id: 'windRose', title: 'Větrná růžice 24 h', icon: 'bullseye', span: 3, minSpan: 3, minH: 260,
    desc: 'Odkud a jak silně foukalo za posledních 24 h — 16 směrů, třídy podle Beauforta, šipka = teď.'
  },
  barometer: {
    id: 'barometer', title: 'Barometr', icon: 'speedometer2', span: 3, minSpan: 3, minH: 260,
    desc: 'Ciferník jako na stěně: tlak teď, vlečná ručička = před 3 h, tendence a změna za 24 h.'
  },
  radar: {
    id: 'radar', title: 'Radar – Satelit – Výstrahy', icon: 'radar', span: 6, minSpan: 3, minH: 220,
    desc: 'Radar, satelit, blesky a výstrahy za poslední 1–3 h jako animace. Klikem zvětšíte.'
  },
  extremes: {
    id: 'extremes', title: 'Extrémy 24 h', icon: 'trophy', span: 12, minSpan: 3,
    desc: 'Nejtepleji, nejchladněji, nejsilnější náraz, nejvíc slunce, srážky a tlak — s časem.'
  },
};

// Default layout = the first built-in preset ("Přehled", dash-presets.jsx
// loads before this file); the literal fallback only guards a missing file.
const DEFAULT_PRESET = (window.WIDGET_PRESETS_BUILTIN && window.WIDGET_PRESETS_BUILTIN[0]) || null;
const DEFAULT_ORDER = DEFAULT_PRESET ? DEFAULT_PRESET.layout.order.slice()
  : ['station','tempChart','sensors','hourly','chart','daily','models','compare','wind','sun','webcam','indoor'];
const DEFAULT_HIDDEN = DEFAULT_PRESET ? DEFAULT_PRESET.layout.hidden.slice() : [];
// the default preset is a full snapshot: its heights and widget settings too;
// lokalita bez stanice má vlastní výchozí (WIDGET_LAYOUT_BEZ_STANICE v dash-presets.jsx)
const defaultLayout = (bez) => (bez && window.WIDGET_LAYOUT_BEZ_STANICE)
  ? mergeCatalog(JSON.parse(JSON.stringify(window.WIDGET_LAYOUT_BEZ_STANICE)))
  : mergeCatalog({
  order: DEFAULT_ORDER.slice(), hidden: DEFAULT_HIDDEN.slice(), spans: { ...DEFAULT_SPANS },
  heights: { ...((DEFAULT_PRESET && DEFAULT_PRESET.layout.heights) || {}) },
  rows: { ...((DEFAULT_PRESET && DEFAULT_PRESET.layout.rows) || {}) },
  widgetConfig: JSON.parse(JSON.stringify((DEFAULT_PRESET && DEFAULT_PRESET.layout.widgetConfig) || {})),
});
const DEFAULT_SPANS = DEFAULT_PRESET ? { ...DEFAULT_PRESET.layout.spans }
  : { station:4, tempChart:8, sensors:12, hourly:12, chart:12, daily:8, models:4, compare:4, wind:4, sun:3, webcam:4, indoor:6 };
// Widths offered by the size chips (¼ ⅓ ½ ⅔ ¾ full). The resize handle can
// stop on any column between the widget's minSpan and 12.
const SPAN_STEPS = [3, 4, 6, 8, 9, 12];
window.SPAN_STEPS = SPAN_STEPS;
const spanOf = (id, spans) => {
  const meta = window.WIDGET_CATALOG[id] || {};
  const v = (spans && spans[id]) || meta.span || 12;
  return Math.max(meta.minSpan || 3, Math.min(12, v));
};
window.__dashSpanOf = spanOf;

// Layout schema v4 (unchanged): { order, hidden, spans, heights, widgetConfig }.
// `heights` is a per-widget MINIMUM height in px — a row is as tall as its
// tallest tile and every tile in the row stretches to it, so nothing is ever
// clipped and rows end evenly.
const LAYOUT_KEY = 'pm_dash_layout_v4';
const LAYOUT_KEY_BEZ = 'pm_dash_layout_bez_v1';  // lokalita jen s předpovědí (pm_bez_dat)
// Forward-compat: any catalog widget missing from a saved/preset `order`
// is appended to `order` AND `hidden`, so the visible layout stays as the
// user left it and the new widget is reachable from the "Add widget" catalog.
function mergeCatalog(layout) {
  const known = new Set(layout.order);
  const missing = Object.keys(window.WIDGET_CATALOG || {}).filter(id => !known.has(id));
  if (!missing.length) return layout;
  return {
    ...layout,
    order: [...layout.order, ...missing],
    hidden: [...new Set([...layout.hidden, ...missing])],
  };
}
function loadLayout(bez) {
  try {
    const raw = localStorage.getItem(bez ? LAYOUT_KEY_BEZ : LAYOUT_KEY);
    if (raw) {
      const parsed = JSON.parse(raw);
      return mergeCatalog({
        order: parsed.order || DEFAULT_ORDER.slice(),
        hidden: parsed.hidden || [],
        spans: { ...DEFAULT_SPANS, ...(parsed.spans || {}) },
        heights: parsed.heights || {},
        rows: parsed.rows || {},
        widgetConfig: parsed.widgetConfig || {},
      });
    }
  } catch {}
  return defaultLayout(bez);
}
function saveLayout(l, bez) {
  try { localStorage.setItem(bez ? LAYOUT_KEY_BEZ : LAYOUT_KEY, JSON.stringify(l)); } catch {}
}
// Put a new order of the VISIBLE widgets back into the full order (hidden
// ids keep their slots).
function mergeOrder(full, vis) {
  const set = new Set(vis);
  let k = 0;
  return full.map(id => (set.has(id) ? vis[k++] : id));
}
const reduceMotion = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Breakpoints (same as the CSS): desktop honours every width, tablets bump
// narrow tiles up a step and collapse wide ones to a full row, phones are one
// column.
const bpNow = () => (window.matchMedia('(min-width: 1100px)').matches ? 'desk'
  : window.matchMedia('(min-width: 720px)').matches ? 'tab' : 'phone');
const TABLET_SPAN = { 12: 12, 11: 12, 10: 12, 9: 12, 8: 8, 7: 8, 6: 6, 5: 6, 4: 4, 3: 6, 2: 4 };
// Placement — "bento". Tiles go in reading order on a 12-column grid, each
// `w` columns wide and `h` rows tall (1–4; rows size to their content).
// Sparse auto-placement like CSS grid: a tall tile is flowed around — the
// tiles after it fill the columns beside it, row by row — so small widgets
// stack next to a big one. Then holes are closed: a tile with free cells to
// its right grows into them, a tile with free cells right below it (inside the
// used grid) grows down. Deterministic — content never moves tiles. Tablets
// remap narrow widths, phones get one column and one row per tile.
function placeTiles(ids, wOf, hOf, bp) {
  const N = 12;
  const eff = (v) => (bp === 'desk' ? v : bp === 'tab' ? (TABLET_SPAN[v] || 12) : 12);
  const occ = [];
  const row = (r) => { while (occ.length <= r) occ.push(new Array(N).fill(null)); return occ[r]; };
  const free = (r, c, w, h) => {
    for (let y = r; y < r + h; y++) { const R = row(y); for (let x = c; x < c + w; x++) if (R[x]) return false; }
    return true;
  };
  const pos = {};
  let cr = 0, cc = 0;
  ids.forEach(id => {
    const w = Math.min(N, eff(wOf(id)));
    const h = bp === 'phone' ? 1 : Math.max(1, Math.min(4, hOf(id) || 1));
    let r = cr, c = cc;
    for (;;) {
      if (c + w <= N && free(r, c, w, h)) break;
      c++;
      if (c + w > N) { r++; c = 0; }
    }
    for (let y = r; y < r + h; y++) { const R = row(y); for (let x = c; x < c + w; x++) R[x] = id; }
    pos[id] = { r, c, w, h };
    cr = r; cc = c + w;
  });
  const used = occ.length;
  ids.forEach(id => {                                   // grow right into holes
    const p = pos[id];
    for (;;) {
      if (p.c + p.w >= N) break;
      let ok = true;
      for (let y = p.r; y < p.r + p.h; y++) if (occ[y][p.c + p.w]) { ok = false; break; }
      if (!ok) break;
      for (let y = p.r; y < p.r + p.h; y++) occ[y][p.c + p.w] = id;
      p.w++;
    }
  });
  ids.forEach(id => {                                   // grow down into holes
    const p = pos[id];
    for (;;) {
      if (p.r + p.h >= used) break;
      const R = occ[p.r + p.h];
      let ok = true;
      for (let x = p.c; x < p.c + p.w; x++) if (R[x]) { ok = false; break; }
      if (!ok) break;
      for (let x = p.c; x < p.c + p.w; x++) R[x] = id;
      p.h++;
    }
  });
  return pos;
}
const rowsOf = (id, rows) => Math.max(1, Math.min(4, (rows && rows[id]) || 1));

const JEN_SE_STANICI = new Set(['compare', 'sensors', 'webcam', 'indoor', 'windRose', 'barometer', 'extremes']);

window.Dashboard = function Dashboard({
  hourly, daily, dailyMore, nowWx, station, stationStatus, coords, scores, currentModel, autoModel, autoPickId,
  onPickModel, onToggleAuto, hourlyRange, setHourlyRange, chartCfg, setChartCfg,
  showStation, windFlow, windFlowSpeed, chartFillArea, chartMinMax, hideTitles,
  editing, onAddOpen
}) {
  // Lokalita jen s předpovědí (stanice neposílá data, pm_bez_dat): vlastní rozložení
  // (pm_dash_layout_bez_v1) a dlaždice, které stojí na měření stanice, nejsou vidět.
  const bezStanice = stationStatus !== 'ok' && !!window.cteBezDat?.(window.StationData?.siteId);
  const bezRef = useRef(bezStanice);
  const [layout, setLayoutRaw] = useState(() => loadLayout(bezStanice));
  const layoutRef = useRef(layout);
  const editingRef = useRef(editing);
  editingRef.current = editing;
  const gridRef = useRef(null);
  const [bp, setBp] = useState(bpNow);
  useEffect(() => {
    const mqs = ['(min-width: 1100px)', '(min-width: 720px)'].map(q => window.matchMedia(q));
    const on = () => { captureRects(); setBp(bpNow()); };
    mqs.forEach(m => m.addEventListener('change', on));
    return () => mqs.forEach(m => m.removeEventListener('change', on));
  }, []);

  // Při první návštěvě se „bez stanice“ pozná až po načtení dat stanice → přepnout rozložení.
  useEffect(() => {
    if (bezRef.current === bezStanice) return;
    bezRef.current = bezStanice;
    const N = loadLayout(bezStanice);
    layoutRef.current = N;
    setLayoutRaw(N);
  }, [bezStanice]);

  useEffect(() => {
    saveLayout(layout, bezRef.current);
    // PresetBar (app.jsx) highlights the matching preset.
    document.dispatchEvent(new CustomEvent('dash-layout'));
    emitHistory();
  }, [layout]);

  // ── Smooth layout changes (FLIP) ───────────────────────────────────
  // Every change of order / width / visibility first records where each
  // tile is, then — after React has laid out the new state — slides tiles
  // from their old spot to the new one instead of jumping there.
  const flipFrom = useRef(null);
  const captureRects = () => {
    const grid = gridRef.current;
    if (!grid) return;
    const m = new Map();
    grid.querySelectorAll(':scope > .widget').forEach(el => m.set(el.dataset.widgetId, el.getBoundingClientRect()));
    flipFrom.current = m;
  };
  useLayoutEffect(() => {
    const before = flipFrom.current;
    flipFrom.current = null;
    const grid = gridRef.current;
    if (!before || !grid || reduceMotion()) return;
    grid.querySelectorAll(':scope > .widget').forEach(el => {
      const b = before.get(el.dataset.widgetId);
      if (!b) return;
      // a slide still running would skew the new end position — `b` already
      // holds where the tile is drawn right now, so it continues from there
      el.getAnimations().forEach(an => { if (an.id === 'flip') an.cancel(); });
      const a = el.getBoundingClientRect();
      const dx = b.left - a.left, dy = b.top - a.top;
      if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
      el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'translate(0, 0)' }],
                 { duration: 220, easing: 'cubic-bezier(.2,.75,.25,1)', id: 'flip' });
    });
  });

  // ── Every layout change goes through commit() ──────────────────────
  // It computes the new layout right away (resize handles call it many times
  // between two renders), records tile positions for the slide, and in edit
  // mode keeps an undo history. A gesture (one resize drag: many width / row
  // steps) is one undo step — beginGesture() … endGesture().
  const hist = useRef({ past: [], future: [], key: null, gesture: null, start: null });
  const emitHistory = () => {
    const h = hist.current;
    const state = { undo: h.past.length, redo: h.future.length,
                    dirty: !!h.start && layoutRef.current !== h.start };
    window.__dash_history.state = state;
    document.dispatchEvent(new CustomEvent('dash-history', { detail: state }));
  };
  const commit = useCallback((fn, opts = {}) => {
    const L = layoutRef.current;
    const N = fn(L);
    if (!N || N === L) return;
    if (opts.flip !== false) captureRects();
    if (editingRef.current) {
      const h = hist.current;
      const key = h.gesture || opts.key || null;
      if (!(key && h.key === key)) {
        h.past.push(L);
        if (h.past.length > 80) h.past.shift();
      }
      h.key = key;
      h.future = [];
    }
    layoutRef.current = N;
    setLayoutRaw(N);
  }, []);
  const setLayout = commit;
  const beginGesture = useCallback((name) => { hist.current.gesture = `${name}:${Date.now()}`; }, []);
  const endGesture = useCallback(() => { hist.current.gesture = null; hist.current.key = null; }, []);
  const travel = (from, to) => {
    const h = hist.current;
    if (!h[from].length) return;
    captureRects();
    h[to].push(layoutRef.current);
    const L = h[from].pop();
    h.key = null;
    layoutRef.current = L;
    setLayoutRaw(L);
  };
  const undo = () => travel('past', 'future');
  const redo = () => travel('future', 'past');
  // Zrušit: back to the layout from before edit mode was switched on.
  const revert = () => { const s = hist.current.start; if (s) commit(() => s); };
  window.__dash_history = { undo, redo, revert, state: window.__dash_history?.state || { undo: 0, redo: 0, dirty: false } };
  // A fresh history for every editing session.
  useEffect(() => {
    hist.current = { past: [], future: [], key: null, gesture: null, start: editing ? layoutRef.current : null };
    emitHistory();
  }, [editing]);
  // Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y while editing.
  useEffect(() => {
    if (!editing) return undefined;
    const onKey = (e) => {
      if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
      if (e.target.closest?.('input, textarea, select, [contenteditable="true"]')) return;
      const k = e.key.toLowerCase();
      if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
      else if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [editing]);

  const visible = layout.order.filter(id => !layout.hidden.includes(id) && window.WIDGET_CATALOG[id]
    && !(bezStanice && JEN_SE_STANICI.has(id)));

  // ── Reorder by dragging (Pointer Events — mouse, pen and touch) ────
  // The tile follows the pointer as a floating copy (big tiles shrink, so
  // the spot they go to stays visible), its slot in the grid stays as a
  // placeholder and moves live to where the tile would land, the other tiles
  // slide aside. Mouse can grab a tile anywhere; touch only by its edit bar
  // so the page still scrolls under a finger. Where the tile lands is read
  // from the tiles' layout boxes (offsetLeft/Top — not the sliding, animated
  // boxes), again when the pointer is released, so even a quick flick lands
  // where it was dropped. Empty space below the tiles = the end; near the top
  // or bottom of the window the page scrolls by itself.
  const [drag, setDrag] = useState(null);          // { id, order } while dragging
  const dragRef = useRef(null);
  const visibleRef = useRef(visible);
  visibleRef.current = visible;

  // New order of the visible tiles for the pointer at client (x, y), or null
  // (over its own placeholder / outside the grid → keep).
  const dropOrder = (d, x, y) => {
    const grid = gridRef.current;
    if (!grid) return null;
    const g = grid.getBoundingClientRect();
    const px = x - g.left, py = y - g.top;
    if (px < -40 || px > g.width + 40 || py < -60) return null;
    const tiles = [...grid.querySelectorAll(':scope > .widget')].map(el => ({
      id: el.dataset.widgetId, x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight,
    }));
    if (!tiles.length) return null;
    const rest = d.order.filter(id => id !== d.id);
    let hit = tiles.find(t => px >= t.x && px < t.x + t.w && py >= t.y && py < t.y + t.h);
    let after;
    if (!hit) {
      // In a gap or beside the tiles: the nearest one decides. Below them all = the end.
      if (py > Math.max(...tiles.map(t => t.y + t.h))) return [...rest, d.id];
      const dist = (t) => Math.hypot(Math.max(t.x - px, 0, px - t.x - t.w), Math.max(t.y - py, 0, py - t.y - t.h));
      hit = tiles.reduce((a, t) => (dist(t) < dist(a) ? t : a));
    }
    if (hit.id === d.id) return null;
    // A tile spanning (almost) the whole row: top half = before, bottom half =
    // after; otherwise its left / right half.
    if (hit.w > g.width * 0.7) after = py > hit.y + hit.h / 2;
    else after = px > hit.x + hit.w / 2;
    const ti = rest.indexOf(hit.id);
    if (ti < 0) return null;
    rest.splice(after ? ti + 1 : ti, 0, d.id);
    return rest;
  };
  const applyDrop = (d, x, y, force) => {
    const next = dropOrder(d, x, y);
    if (!next || next.join() === d.order.join()) return;
    // Hysteresis: right after a change the tiles are still sliding — wait
    // for a real pointer move before changing again (no flip-flopping).
    const now = performance.now();
    if (!force && now - d.lastSwap < 90 && Math.hypot(x - d.swapX, y - d.swapY) < 14) return;
    d.order = next;
    d.lastSwap = now; d.swapX = x; d.swapY = y;
    captureRects();
    setDrag({ id: d.id, order: next });
  };

  const endDrag = useCallback((ok) => {
    const d = dragRef.current;
    dragRef.current = null;
    window.removeEventListener('pointermove', d?.onMove);
    window.removeEventListener('pointerup', d?.onUp);
    window.removeEventListener('pointercancel', d?.onCancel);
    window.removeEventListener('keydown', d?.onKey);
    cancelAnimationFrame(d?.raf || 0);
    document.body.classList.remove('dash-dragging');
    if (!d || !d.started) return;
    if (ok) applyDrop(d, d.x, d.y, true);
    const ghost = d.ghost;
    const order = d.order;
    // Let the floating copy land in its slot (after React placed it), then drop it.
    requestAnimationFrame(() => {
      const slot = gridRef.current?.querySelector(`:scope > .widget[data-widget-id="${d.id}"]`);
      if (!ghost) return;
      if (slot && !reduceMotion()) {
        const r = slot.getBoundingClientRect();
        const s = r.width / d.w;
        const anim = ghost.animate([{ transform: ghost.style.transform, opacity: .94 },
                                    { transform: `translate(${r.left}px, ${r.top}px) scale(${s})`, opacity: .6 }],
                                   { duration: 170, easing: 'ease-out', fill: 'forwards' });
        anim.onfinish = () => ghost.remove();
      } else ghost.remove();
    });
    if (ok) commit(L => {
      const vis = L.order.filter(x => !L.hidden.includes(x) && window.WIDGET_CATALOG[x]);
      if (vis.join() === order.join()) return L;
      return { ...L, order: mergeOrder(L.order, order) };
    }, { flip: false });
    else captureRects();
    setDrag(null);
  }, []);

  const startDrag = useCallback((id) => (e) => {
    if (!editing || dragRef.current) return;
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    const tile = e.currentTarget.closest('.widget');
    if (!tile) return;
    const r = tile.getBoundingClientRect();
    // the floating copy of a big tile is scaled down to about 560 × 320 px
    // (never below 40 %, so its bar stays readable)
    const s = Math.max(0.4, Math.min(1, 560 / r.width, 320 / r.height));
    const d = {
      id, tile, started: false, ghost: null, lastSwap: 0, swapX: 0, swapY: 0, raf: 0, s,
      startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY,
      offX: (e.clientX - r.left) * s, offY: (e.clientY - r.top) * s, w: r.width, h: r.height,
      order: visibleRef.current.slice(),
    };
    const placeGhost = () => {
      d.ghost.style.transform = `translate(${d.x - d.offX}px, ${d.y - d.offY}px) scale(${d.s})`;
    };
    // Near the top / bottom edge the page scrolls by itself — faster the
    // closer the pointer is — and the drop target follows.
    const EDGE = 90;
    const tick = () => {
      d.raf = 0;
      if (dragRef.current !== d) return;
      const bottomEdge = window.innerHeight - EDGE;
      const v = d.y < EDGE ? -Math.ceil((EDGE - d.y) / 5) : d.y > bottomEdge ? Math.ceil((d.y - bottomEdge) / 5) : 0;
      if (!v) return;
      const before = window.scrollY;
      window.scrollBy(0, v);
      if (window.scrollY !== before) applyDrop(d, d.x, d.y);
      d.raf = requestAnimationFrame(tick);
    };
    d.onMove = (ev) => {
      d.x = ev.clientX; d.y = ev.clientY;
      if (!d.started) {
        if (Math.hypot(ev.clientX - d.startX, ev.clientY - d.startY) < 6) return;
        d.started = true;
        const g = d.tile.cloneNode(true);
        g.removeAttribute('data-widget-id');
        g.classList.add('widget-ghost');
        g.style.width = d.w + 'px';
        g.style.height = d.h + 'px';
        document.body.appendChild(g);
        d.ghost = g;
        document.body.classList.add('dash-dragging');
        setDrag({ id: d.id, order: d.order });
      }
      ev.preventDefault();
      placeGhost();
      if (!d.raf) d.raf = requestAnimationFrame(tick);
      applyDrop(d, ev.clientX, ev.clientY);
    };
    d.onUp = (ev) => { d.x = ev.clientX; d.y = ev.clientY; endDrag(true); };
    d.onCancel = () => endDrag(false);
    d.onKey = (ev) => { if (ev.key === 'Escape') endDrag(false); };
    dragRef.current = d;
    window.addEventListener('pointermove', d.onMove, { passive: false });
    window.addEventListener('pointerup', d.onUp);
    window.addEventListener('pointercancel', d.onCancel);
    window.addEventListener('keydown', d.onKey);
  }, [editing, endDrag]);
  useEffect(() => { if (!editing && dragRef.current) endDrag(false); }, [editing, endDrag]);
  useEffect(() => () => { if (dragRef.current) endDrag(false); }, [endDrag]);

  // Move one step earlier / later in reading order (gear popover, keyboard).
  const move = (id, dir) => setLayout(L => {
    const vis = L.order.filter(x => !L.hidden.includes(x) && window.WIDGET_CATALOG[x]);
    const i = vis.indexOf(id), j = i + dir;
    if (i < 0 || j < 0 || j >= vis.length) return L;
    [vis[i], vis[j]] = [vis[j], vis[i]];
    return { ...L, order: mergeOrder(L.order, vis) };
  });
  // Hide with an undo toast instead of a confirm dialog.
  const hide = (id) => {
    const meta = window.WIDGET_CATALOG[id];
    const name = meta?.title || id;
    setLayout(L => ({ ...L, hidden: [...new Set([...L.hidden, id])] }));
    window.showToast?.(`Skryto: ${name}`, {
      icon: 'eye-slash',
      action: { label: 'Vrátit', onClick: () => setLayout(L => ({ ...L, hidden: L.hidden.filter(x => x !== id) })) },
    });
  };
  // Shown again (catalog, command palette): it goes to the END of the
  // dashboard — the page scrolls there and the tile is highlighted for a
  // moment, so it's clear where it is and it can be dragged from there.
  const [fresh, setFresh] = useState(null);
  const show = (id) => {
    setLayout(L => {
      if (!L.hidden.includes(id)) return L;
      return { ...L, order: [...L.order.filter(x => x !== id), id], hidden: L.hidden.filter(x => x !== id) };
    });
    setFresh(id);
  };
  useEffect(() => {
    if (!fresh) return undefined;
    const el = gridRef.current?.querySelector(`:scope > .widget[data-widget-id="${fresh}"]`);
    el?.scrollIntoView({ behavior: reduceMotion() ? 'auto' : 'smooth', block: 'center' });
    const t = setTimeout(() => setFresh(null), 1800);
    return () => clearTimeout(t);
  }, [fresh]);
  // Width (desktop). Narrowing by the right edge first just sets the width:
  // if the next tiles fit into the freed columns they move up into the row
  // (four ¼ tiles end up side by side). Only when the tile would be stretched
  // back over an empty gap does the width work like a divider: what the tile
  // gives up goes to its neighbour, what it takes comes from its neighbours
  // (down to their minSpan), so narrowing always works. The LEFT edge is the
  // same divider seen from the other tile: it only trades columns with the
  // tiles on the left. Everything starts from the width you SEE (placeTiles),
  // not the stored one.
  const minOf = (x) => (window.WIDGET_CATALOG[x] || {}).minSpan || 3;
  const setSpan = (id, v, side = 'right') => setLayout(L => {
    const want = Math.max(minOf(id), Math.min(12, v));
    if (bpNow() !== 'desk') {
      if (spanOf(id, L.spans) === want) return L;
      return { ...L, spans: { ...L.spans, [id]: want } };
    }
    const vis = L.order.filter(x => !L.hidden.includes(x) && window.WIDGET_CATALOG[x]);
    const place = (sp) => placeTiles(vis, (x) => spanOf(x, sp), (x) => rowsOf(x, L.rows), 'desk');
    const pos = place(L.spans);
    const p = pos[id];
    const cur = p.w;
    if (cur === want) return L;
    if (want < cur && side === 'right') {
      const plain = { ...L.spans, [id]: want };
      if (place(plain)[id].w === want) return { ...L, spans: plain };
    }
    // neighbours share the divider: tiles touching this one on the right
    // (or, for the last tile, on the left) in any of its rows — beside a tall
    // tile that can be several stacked tiles, they all move together
    const over = (a, b) => a.r < b.r + b.h && b.r < a.r + a.h;
    const right = vis.filter(x => x !== id && pos[x].c === p.c + p.w && over(pos[x], p));
    const left = vis.filter(x => x !== id && pos[x].c + pos[x].w === p.c && over(pos[x], p));
    const groups = side === 'left' ? [left] : [right, left];
    const spans = { ...L.spans };
    vis.forEach(x => { if (over(pos[x], p)) spans[x] = pos[x].w; });   // store what is shown
    spans[id] = want;
    if (want < cur) {
      const g = groups.find(x => x.length);
      if (!g) return L;
      g.forEach(nb => { spans[nb] = Math.min(12, spans[nb] + (cur - want)); });
    } else {
      // widen only as far as the neighbours can give; if they can't give
      // anything at all, the tile moves to a row of its own (right edge only)
      let need = want - cur, got = 0;
      for (const group of groups) {
        if (!need || !group.length) continue;
        const take = Math.min(need, Math.max(0, Math.min(...group.map(nb => spans[nb] - minOf(nb)))));
        if (take > 0) { group.forEach(nb => { spans[nb] -= take; }); need -= take; got += take; }
      }
      if (!got && side === 'left') return L;
      spans[id] = got > 0 ? cur + got : want;
    }
    return { ...L, spans };
  });
  // Rows a tile spans (1–4): the small tiles after it flow into the rows beside it.
  const setRows = (id, h) => setLayout(L => {
    const v = Math.max(1, Math.min(4, h));
    if (rowsOf(id, L.rows) === v) return L;
    return { ...L, rows: { ...(L.rows || {}), [id]: v } };
  });
  // Does spanning n rows change anything? Only when a tile after it then
  // starts in the last of those rows, beside it (desktop grid) — otherwise the
  // extra rows stay empty and the tile looks exactly the same.
  const rowsStack = (id, n) => {
    if (n <= 1) return true;
    const L = layoutRef.current;
    const vis = L.order.filter(x => !L.hidden.includes(x) && window.WIDGET_CATALOG[x]
      && !(bezStanice && JEN_SE_STANICI.has(x)));
    const P = placeTiles(vis, (x) => spanOf(x, L.spans), (x) => (x === id ? n : rowsOf(x, L.rows)), 'desk');
    const p = P[id];
    return !!p && vis.some(x => x !== id && P[x].r === p.r + n - 1);
  };
  const setHeight = (id, h) => setLayout(L => {
    const cur = (L.heights || {})[id] ?? null;
    if (cur === (h ?? null)) return L;
    return {
      ...L,
      heights: h == null ? Object.fromEntries(Object.entries(L.heights || {}).filter(([k]) => k !== id))
                         : { ...(L.heights || {}), [id]: h },
    };
  });
  // Per-widget configuration (e.g. which sensor keys to hide in SensorsWidget).
  const setWidgetConfig = (id, patch) => setLayout(L => ({
    ...L,
    widgetConfig: { ...L.widgetConfig, [id]: { ...(L.widgetConfig?.[id] || {}), ...patch } },
  }));

  window.__dash_show = show;
  window.__dash_hiddenCount = layout.hidden.length;
  window.__dash_reset = () => setLayout(() => defaultLayout(bezRef.current));
  window.__dash_hiddenIds = layout.hidden;
  window.__dash_hidden = layout.hidden;
  // Apply a layout snapshot atomically (preset switcher). Partial snapshots
  // keep current order/hidden/spans/widgetConfig; heights always come from
  // the snapshot (minimums tuned for another arrangement would look odd).
  window.__dash_setLayout = (snap) => setLayout(L => mergeCatalog({
    order: snap.order ? snap.order.slice() : L.order,
    hidden: snap.hidden ? snap.hidden.slice() : L.hidden,
    spans: snap.spans ? { ...DEFAULT_SPANS, ...snap.spans } : L.spans,
    heights: snap.heights ? { ...snap.heights } : {},
    rows: snap.rows ? { ...snap.rows } : {},
    widgetConfig: snap.widgetConfig || L.widgetConfig,
  }));
  // Read-only snapshot of the current layout (for "Save as preset").
  window.__dash_currentLayout = layout;

  const shown = drag ? drag.order : visible;
  const pos = placeTiles(shown, (id) => spanOf(id, layout.spans), (id) => rowsOf(id, layout.rows), bp);

  return (
    <div className={`dash-grid ${editing ? 'editing' : ''} ${hideTitles ? 'hide-titles' : ''} ${drag ? 'is-sorting' : ''}`} ref={gridRef}>
      {shown.map((id, idx) => {
        const meta = window.WIDGET_CATALOG[id];
        if (!meta) return null;
        const span = spanOf(id, layout.spans);
        const wConfig = layout.widgetConfig?.[id] || {};
        const toggleSensorKey = (key) => setWidgetConfig(id, {
          hiddenKeys: (wConfig.hiddenKeys || []).includes(key)
            ? (wConfig.hiddenKeys || []).filter(k => k !== key)
            : [...(wConfig.hiddenKeys || []), key]
        });
        return (
          <Widget key={id} id={id} title={meta.title} icon={meta.icon} span={span} place={pos[id]}
            minSpan={meta.minSpan || 3} defaultMinH={meta.minH} shownSpan={bp === 'desk' ? pos[id].w : span}
            rows={pos[id].h} onSetRows={(h) => setRows(id, h)}
            resizable={bp === 'desk'} sizeCtl={bp !== 'phone'}
            rowsStack={(n) => rowsStack(id, n)}
            onResizeStart={() => beginGesture(`resize:${id}`)} onResizeEnd={endGesture}
            editing={editing} placeholder={drag?.id === id} isNew={fresh === id}
            sub={renderSub(id, {currentModel, autoModel, autoPickId, station, hourlyRange, chartCfg})}
            onStartDrag={startDrag(id)}
            onHide={() => hide(id)}
            onMove={(d) => move(id, d)} canMoveBack={idx > 0} canMoveFwd={idx < shown.length - 1}
            onSetSpan={(v, side) => setSpan(id, v, side)}
            userHeight={layout.heights?.[id]}
            onSetHeight={(h) => setHeight(id, h)}
            widgetConfig={wConfig}
            onWidgetConfigChange={(patch) => setWidgetConfig(id, patch)}
            station={station}
            hourlyRange={hourlyRange}
            onHourlyRangeChange={setHourlyRange}
            chartCfg={chartCfg}
            onChartCfgChange={setChartCfg}>
            {id === 'station' && <StationWidget station={station} stationStatus={stationStatus} hourly={hourly} nowWx={nowWx} showStation={showStation} config={wConfig} />}
            {id === 'tempChart' && <window.TempChartWidget station={station} hourly={hourly} coords={coords} config={wConfig} />}
            {id === 'sensors' && <SensorsWidget station={station} editing={editing} hiddenKeys={wConfig.hiddenKeys||[]} onToggleKey={toggleSensorKey} fillArea={chartFillArea} showMinMax={chartMinMax} config={wConfig} />}
            {id === 'indoor'  && <IndoorWidget station={station} editing={editing} hiddenKeys={wConfig.hiddenKeys||[]} onToggleKey={toggleSensorKey} fillArea={chartFillArea} showMinMax={chartMinMax} config={wConfig} />}
            {id === 'hourly'  && <HourlyWidget hourly={hourly} range={hourlyRange} onRangeChange={setHourlyRange} config={wConfig} />}
            {id === 'chart'   && <ChartWidget hourly={hourly} config={chartCfg} onConfigChange={setChartCfg} widgetConfig={wConfig} />}
            {id === 'daily'   && <DailyWidget daily={daily} hourly={hourly} more={dailyMore} nowTemp={station?.latest?.TeplotaVnejsi} config={wConfig} />}
            {id === 'models'  && <ModelsWidget scores={scores} current={currentModel} autoModel={autoModel} autoPickId={autoPickId} onPick={onPickModel} onToggleAuto={onToggleAuto} config={wConfig} />}
            {id === 'compare' && <CompareWidget station={station} hourly={hourly} config={wConfig} />}
            {id === 'wind'    && <WindWidget hourly={hourly} stationLatest={station?.latest} station={station} flow={windFlow} flowSpeed={windFlowSpeed} config={wConfig} />}
            {id === 'sun'     && <SunWidget coords={coords} config={wConfig} />}
            {id === 'webcam'  && <WebcamWidget station={station} config={wConfig} />}
            {id === 'windRose'  && <window.WindRoseWidget station={station} config={wConfig} />}
            {id === 'barometer' && <window.BarometerWidget station={station} config={wConfig} />}
            {id === 'extremes'  && <window.ExtremesWidget station={station} config={wConfig} />}
            {id === 'radar'     && <window.RadarWidget config={wConfig} />}
          </Widget>
        );
      })}
    </div>
  );
};

function modelLabel(id) {
  return window.WeatherData?.MODEL_META?.[id]?.name || id;
}
function renderSub(id, ctx) {
  const ageMin = ctx.station?.timestamp ? Math.round((Date.now() - ctx.station.timestamp.getTime()) / 60000) : null;
  if (id === 'station' && ctx.station) return `stanice před ${ageMin} min`;
  if (id === 'sensors' && ageMin != null) return `před ${ageMin} min`;
  if (id === 'indoor' && ageMin != null) return `před ${ageMin} min`;
  if (id === 'hourly')  return `${ctx.hourlyRange} h · ${modelLabel(ctx.currentModel)}`;
  if (id === 'chart')   return `${ctx.chartCfg.hours} h · ${modelLabel(ctx.currentModel)}`;
  if (id === 'daily')   return `${modelLabel(ctx.currentModel)}`;
  if (id === 'models')  return ctx.autoModel ? 'AUTO' : 'ručně';
  if (id === 'compare') return modelLabel(ctx.currentModel);
  if ((id === 'windRose' || id === 'extremes') && ageMin != null) return '24 h';
  if (id === 'barometer' && ageMin != null) return 'relativní tlak';
  return null;
}
