// app.jsx — root composition: header, the board (Ungrouped inbox + grouping
// cards), drawer, tweaks. Owns the board, the global column order, and the
// cross-container row drag. The board model + LedgerTable / GroupCard live in
// groups.jsx; the per-ticker row lives in row.jsx.

function App({ onLogout }) {
  const [t, setTweak] = useTweaks(window.TWEAK_DEFAULTS);
  // The board + column order live server-side now (one JSON blob per user).
  // null = still loading from /board.
  const [board, setBoard] = React.useState(null);
  const [saveState, setSaveState] = React.useState("idle"); // idle|saving|saved|error
  const [migrateOffer, setMigrateOffer] = React.useState(null); // pending local import
  const [input, setInput] = React.useState("");
  const [error, setError] = React.useState("");
  const [openTicker, setOpenTicker] = React.useState(null);
  const [columnOrder, setColumnOrder] = React.useState(() => [...DEFAULT_COLUMN_ORDER]);
  const [dragId, setDragId] = React.useState(null);   // column-header drag
  const [dropId, setDropId] = React.useState(null);   // column-header drag
  // Cross-container row drag — see the pointer handlers + RowDragClone.
  const [rowDrag, setRowDrag] = React.useState(null);
  const rowDragRef = React.useRef(null);
  rowDragRef.current = rowDrag;
  // Card reorder — press-and-hold a card header, then drag it up/down.
  const [cardDrag, setCardDrag] = React.useState(null);
  const cardDragRef = React.useRef(null);
  cardDragRef.current = cardDrag;
  const inputRef = React.useRef(null);

  // ── Server-backed board ────────────────────────────────────────────────
  // Load once on mount. boardLoadedRef gates the persist effect so the very
  // first setBoard (from the server payload) doesn't immediately PUT it back.
  const boardLoadedRef = React.useRef(false);
  React.useEffect(() => {
    fetchBoard()
      .then((b) => {
        setBoard({ ungrouped: b.ungrouped || [], cards: b.cards || [] });
        if (Array.isArray(b.columnOrder) && b.columnOrder.length) {
          setColumnOrder(normalizeColumnOrder(b.columnOrder));
        }
        boardLoadedRef.current = true;
        // One-time offer to import a dashboard left in this browser's
        // localStorage — only when the server board is still the default seed.
        try {
          if (!localStorage.getItem("lsm.migrated.v1")) {
            const local = loadBoard();
            const serverIsDefault =
              (!b.cards || b.cards.length === 0) &&
              JSON.stringify(b.ungrouped || []) === JSON.stringify(DEFAULT_TICKERS);
            const localIsDefault =
              (!local.cards || local.cards.length === 0) &&
              JSON.stringify(local.ungrouped || []) === JSON.stringify(DEFAULT_TICKERS);
            const localHasContent =
              (local.ungrouped && local.ungrouped.length) ||
              (local.cards && local.cards.length);
            if (serverIsDefault && localHasContent && !localIsDefault) {
              setMigrateOffer(local);
            } else {
              localStorage.setItem("lsm.migrated.v1", "nothing-to-import");
            }
          }
        } catch (e) {}
      })
      .catch(() => {
        // /board 401 — the session lapsed. Bounce back to the login gate.
        if (onLogout) onLogout();
      });
  }, []);

  // Persist the board + column order, debounced — not on every keystroke. A
  // failed PUT isn't swallowed: it flips the footer to "Save failed" and keeps
  // retrying every 4s until it lands (or the next edit supersedes it).
  const saveTimer = React.useRef(null);
  React.useEffect(() => {
    if (!boardLoadedRef.current || board == null) return;
    const blob = {
      ungrouped: board.ungrouped, cards: board.cards, columnOrder: columnOrder,
    };
    if (saveTimer.current) clearTimeout(saveTimer.current);
    setSaveState("saving");
    const attempt = () => {
      saveBoardToServer(blob)
        .then(() => setSaveState("saved"))
        .catch(() => {
          setSaveState("error");
          saveTimer.current = setTimeout(attempt, 4000);
        });
    };
    saveTimer.current = setTimeout(attempt, 600);
    return () => saveTimer.current && clearTimeout(saveTimer.current);
  }, [board, columnOrder]);

  // Flush any pending save when the tab is hidden or the user navigates away —
  // keepalive lets the PUT outlive the unload.
  React.useEffect(() => {
    const flush = () => {
      if (!boardLoadedRef.current || board == null) return;
      if (saveTimer.current) clearTimeout(saveTimer.current);
      flushBoard({
        ungrouped: board.ungrouped, cards: board.cards, columnOrder: columnOrder,
      });
    };
    const onVis = () => { if (document.visibilityState === "hidden") flush(); };
    window.addEventListener("visibilitychange", onVis);
    window.addEventListener("beforeunload", flush);
    return () => {
      window.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("beforeunload", flush);
    };
  }, [board, columnOrder]);

  const logout = () => {
    flushBoard({
      ungrouped: board ? board.ungrouped : [],
      cards: board ? board.cards : [],
      columnOrder: columnOrder,
    });
    fetch("/auth/logout", { method: "POST", credentials: "same-origin" })
      .finally(() => onLogout && onLogout());
  };

  const acceptMigration = () => {
    setBoard({ ungrouped: migrateOffer.ungrouped || [], cards: migrateOffer.cards || [] });
    try { localStorage.setItem("lsm.migrated.v1", "imported"); } catch (e) {}
    setMigrateOffer(null);
  };
  const declineMigration = () => {
    try { localStorage.setItem("lsm.migrated.v1", "declined"); } catch (e) {}
    setMigrateOffer(null);
  };

  // Apply tweaks via documentElement data attributes so CSS controls everything
  React.useEffect(() => {
    document.documentElement.dataset.density = t.density;
    document.documentElement.dataset.theme = t.night ? "graphite" : "cream";
    document.documentElement.dataset.accent = t.accent;
    document.documentElement.dataset.pulses = t.pulses ? "on" : "off";
  }, [t.density, t.night, t.accent, t.pulses]);

  const addTicker = (raw) => {
    const ticker = raw.trim().toUpperCase();
    if (!ticker) return;
    if (!/^[A-Z0-9.\-]{1,12}$/.test(ticker)) {
      setError("Use letters, numbers, dot or dash (e.g. AAPL or SAP.DE)");
      return;
    }
    if (boardHasTicker(board, ticker)) {
      setError(ticker + " is already on the board");
      return;
    }
    setBoard((b) => boardAddTicker(b, ticker));
    setInput("");
    setError("");
  };

  const removeTicker = (ticker) => {
    setBoard((b) => boardRemoveTicker(b, ticker));
    if (openTicker === ticker) setOpenTicker(null);
  };

  // Drag a value-column header onto another to drop it into that slot. The
  // order is global — every table (inbox + cards) renders it.
  const moveColumn = (fromId, toId) => {
    setColumnOrder((order) => {
      if (fromId === toId) return order;
      const next = order.filter((id) => id !== fromId);
      const at = next.indexOf(toId);
      next.splice(at < 0 ? next.length : at, 0, fromId);
      return next;
    });
  };

  // ── Cross-container row drag ───────────────────────────────────────────
  // The move/up/cancel handlers are created once (stable identity — they read
  // live state through rowDragRef) and bound to window *synchronously* on
  // pointer-down, so a click faster than a paint can't slip past a not-yet-
  // bound pointerup listener.
  const rowDragHandlers = React.useRef(null);
  if (!rowDragHandlers.current) {
    const teardown = () => {
      window.removeEventListener("pointermove", h.move);
      window.removeEventListener("pointerup", h.up);
      window.removeEventListener("pointercancel", h.cancel);
    };
    const h = {
      move: (e) => {
        const d = rowDragRef.current;
        if (!d || d.settling) return;
        const dx = e.clientX - d.startX;
        const dy = e.clientY - d.startY;
        if (!d.active && Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
        // Hit-test every container's <tbody> for the drop target + index.
        const hit = hitTestContainers(e.clientX, e.clientY);
        setRowDrag({
          ...d,
          active: true,
          pointerX: e.clientX,
          pointerY: e.clientY,
          overContainer: hit ? hit.containerId : d.fromContainer,
          overIndex: hit ? hit.index : d.fromIndex,
        });
      },
      up: () => {
        const d = rowDragRef.current;
        if (!d || d.settling) return;
        teardown();
        if (!d.active) {
          // Never crossed the threshold — a plain click. Open the drawer.
          setRowDrag(null);
          setOpenTicker(d.ticker);
          return;
        }
        const commit = () => {
          setBoard((b) => boardMoveTicker(b, d.ticker, d.overContainer, d.overIndex));
          setRowDrag(null);
        };
        // Glide the clone onto the placeholder gap, then commit — so the row
        // lands exactly where the clone settled, with no jump.
        const ph = document.querySelector(".ld__row--placeholder");
        const target = ph ? ph.getBoundingClientRect() : null;
        if (target) {
          setRowDrag({ ...d, settling: true, settleX: target.left, settleY: target.top });
          window.setTimeout(commit, 170);
        } else {
          commit();
        }
      },
      cancel: () => { teardown(); setRowDrag(null); },
    };
    rowDragHandlers.current = h;
  }

  // Pointer-down on a row arms a *potential* drag — click (open the drawer) vs
  // drag (reorder / regroup) is decided by how far the pointer travels.
  const onRowPointerDown = (e, ticker, containerId) => {
    if (e.button != null && e.button !== 0) return;          // primary button only
    if (rowDragRef.current || cardDragRef.current) return;   // a drag is already live
    const list = containerTickers(board, containerId);
    const fromIndex = list.indexOf(ticker);
    if (fromIndex < 0) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const handlers = rowDragHandlers.current;
    window.addEventListener("pointermove", handlers.move);
    window.addEventListener("pointerup", handlers.up);
    window.addEventListener("pointercancel", handlers.cancel);
    setRowDrag({
      ticker,
      fromContainer: containerId,
      fromIndex,
      startX: e.clientX,
      startY: e.clientY,
      pointerX: e.clientX,
      pointerY: e.clientY,
      grabDX: e.clientX - rect.left,
      grabDY: e.clientY - rect.top,
      rowWidth: rect.width,
      rowHeight: rect.height,
      overContainer: containerId,
      overIndex: fromIndex,
      active: false,
      settling: false,
    });
  };

  // ── Card reorder (press-and-hold the header, then drag) ────────────────
  // Cards carry an editable title, so a movement threshold would clash with
  // selecting text in it — hence a hold delay: press the header, hold ~160ms
  // without moving and the card lifts; then drag it to reorder. The displaced
  // cards slide on a rail; the order commits on release.
  const cardDragHandlers = React.useRef(null);
  if (!cardDragHandlers.current) {
    const teardown = () => {
      window.removeEventListener("pointermove", h.move);
      window.removeEventListener("pointerup", h.up);
      window.removeEventListener("pointercancel", h.cancel);
    };
    const h = {
      move: (e) => {
        const d = cardDragRef.current;
        if (!d) return;
        if (!d.armed) {
          // Travelled before the hold fired — it's text selection, not a drag.
          if (Math.abs(e.clientY - d.startY) > 8 ||
              Math.abs(e.clientX - d.startX) > 8) {
            window.clearTimeout(d.armTimer);
            teardown();
            setCardDrag(null);
          }
          return;
        }
        if (!d.moved) {
          // First armed move — drop the title's focus / any text selection.
          if (document.activeElement && document.activeElement.blur) {
            document.activeElement.blur();
          }
          const sel = window.getSelection && window.getSelection();
          if (sel && sel.removeAllRanges) sel.removeAllRanges();
        }
        const offsetY = e.clientY - d.startY;
        const center =
          d.layout[d.fromIndex].top + d.layout[d.fromIndex].height / 2 + offsetY;
        let over = 0;
        for (let i = 0; i < d.layout.length; i++) {
          if (i === d.fromIndex) continue;
          const c = d.layout[i].top + d.layout[i].height / 2;
          if (c < center) over++;
        }
        setCardDrag({ ...d, moved: true, offsetY, overIndex: over });
      },
      up: () => {
        const d = cardDragRef.current;
        if (!d) return;
        window.clearTimeout(d.armTimer);
        teardown();
        if (d.armed && d.moved && d.overIndex !== d.fromIndex) {
          setBoard((b) => boardMoveCard(b, d.cardId, d.overIndex));
        }
        setCardDrag(null);
      },
      cancel: () => {
        const d = cardDragRef.current;
        if (d) window.clearTimeout(d.armTimer);
        teardown();
        setCardDrag(null);
      },
    };
    cardDragHandlers.current = h;
  }

  const onCardPointerDown = (e, cardId) => {
    if (e.button != null && e.button !== 0) return;
    if (cardDragRef.current || rowDragRef.current) return;
    const fromIndex = board.cards.findIndex((c) => c.id === cardId);
    if (fromIndex < 0) return;
    // Measure every card now — drag-reorder works off fixed boundaries; the
    // live shifting is just feedback.
    const layout = [];
    document.querySelectorAll(".group-card").forEach((el) => {
      const r = el.getBoundingClientRect();
      layout.push({ top: r.top, height: r.height });
    });
    if (!layout[fromIndex]) return;
    const gap = layout.length > 1
      ? Math.max(0, layout[1].top - (layout[0].top + layout[0].height))
      : 0;
    const handlers = cardDragHandlers.current;
    window.addEventListener("pointermove", handlers.move);
    window.addEventListener("pointerup", handlers.up);
    window.addEventListener("pointercancel", handlers.cancel);
    const armTimer = window.setTimeout(() => {
      const d = cardDragRef.current;
      if (!d) return;
      // Update the ref synchronously too, so a pointermove landing before the
      // re-render doesn't see a stale armed:false and abort the drag.
      cardDragRef.current = { ...d, armed: true };
      setCardDrag(cardDragRef.current);
    }, 160);
    setCardDrag({
      cardId,
      fromIndex,
      overIndex: fromIndex,
      startX: e.clientX,
      startY: e.clientY,
      offsetY: 0,
      layout,
      slot: layout[fromIndex].height + gap,
      armTimer,
      armed: false,
      moved: false,
    });
  };

  // Shared props for every LedgerTable (inbox + each card).
  const tableProps = {
    columnOrder,
    rowDrag,
    onRowPointerDown,
    onOpenTicker: setOpenTicker,
    dragId, dropId, setDragId, setDropId, moveColumn,
  };

  // Still waiting on GET /board — keep it quiet.
  if (board == null) {
    return (
      <div className="page">
        <div className="board__loading">loading your board…</div>
      </div>
    );
  }

  const totalTickers = boardAllTickers(board).length;
  const boardEmpty = board.ungrouped.length === 0 && board.cards.length === 0;

  return (
    <div className="page">
      <header className="hd">
        <div className="hd__top">
          <div className="hd__identity">
            <EfimeraMark night={t.night} />
            <div className="hd__identity-text">
              <h1 className="hd__title">
                <em>Every ticker,</em><br />
                one current price.
              </h1>
              <p className="hd__sub">
                A quiet watchlist for the active market, pre-market and
                post-market move&nbsp;— synchronised with each
                company&rsquo;s own exchange.
              </p>
            </div>
          </div>
          <div className="hd__toggles">
            <InlineToggle
              label="Pulses"
              on={t.pulses}
              onChange={(v) => setTweak("pulses", v)}
            />
            <InlineToggle
              label="Night mode"
              on={t.night}
              onChange={(v) => setTweak("night", v)}
            />
            <button type="button" className="hd__logout" onClick={logout}>
              Log out
            </button>
          </div>
        </div>

        <form
          className={"input" + (error ? " input--err" : "")}
          onSubmit={(e) => { e.preventDefault(); addTicker(input); }}>
          <span className="input__caret">›</span>
          <input
            ref={inputRef}
            className="input__field"
            placeholder="Enter ticker, e.g. AAPL"
            value={input}
            onChange={(e) => { setInput(e.target.value); setError(""); }}
            spellCheck="false"
            autoCapitalize="characters"
            autoCorrect="off"
          />
          <button type="submit" className="input__btn"
                  disabled={!input.trim()}>
            Add ↵
          </button>
        </form>
        {error && <div className="input__err">{error}</div>}

        <div className="hd__legend">
          <LegendSwatch kind="pre-market" label="Pre-market" />
          <LegendSwatch kind="market" label="Market open" />
          <LegendSwatch kind="post-market" label="Post-market" />
          <LegendSwatch kind="market-closed" label="Closed" />
          <div className="hd__legend-spacer" />
          <div className="hd__live">
            <span className="hd__live-dot" />
            <span>Live · tick every 2s</span>
          </div>
        </div>
      </header>

      {migrateOffer && (
        <div className="migrate">
          <span className="migrate__text">
            This browser has a saved dashboard from before you had an account.
            Import it into this account?
          </span>
          <span className="migrate__actions">
            <button className="migrate__btn migrate__btn--yes"
                    onClick={acceptMigration}>Import it</button>
            <button className="migrate__btn" onClick={declineMigration}>
              No thanks
            </button>
          </span>
        </div>
      )}

      <main className="board">
        {boardEmpty ? (
          <EmptyState onPick={(x) => addTicker(x)}
                      onFocus={() => inputRef.current?.focus()} />
        ) : (
          <>
            <section className="board__inbox">
              <div className="board__inbox-label">Ungrouped</div>
              <LedgerTable
                containerId="ungrouped"
                tickers={board.ungrouped}
                emptyHint="New tickers land here — drag them into a group below."
                {...tableProps}
              />
            </section>

            {board.cards.map((card, index) => (
              <GroupCard
                key={card.id}
                card={card}
                index={index}
                cardDrag={cardDrag}
                onCardPointerDown={onCardPointerDown}
                onRename={(id, title) => setBoard((b) => boardRenameCard(b, id, title))}
                onDelete={(id) => setBoard((b) => boardDeleteCard(b, id))}
                {...tableProps}
              />
            ))}

            <button className="board__add"
                    onClick={() => setBoard((b) => boardCreateCard(b))}>
              <span className="board__add-plus">+</span> New group
            </button>
          </>
        )}
      </main>

      <footer className="ft">
        <div>
          {totalTickers} {totalTickers === 1 ? "ticker" : "tickers"}
          {board.cards.length > 0 &&
            " · " + board.cards.length +
            (board.cards.length === 1 ? " group" : " groups")}
        </div>
        <div className="ft__rule" />
        <div className={"ft__save ft__save--" + saveState}>
          {saveState === "saving" && "Saving…"}
          {saveState === "saved" && "Saved"}
          {saveState === "error" && "Save failed — retrying…"}
          {saveState === "idle" && "Live data · Yahoo Finance & Nasdaq"}
        </div>
      </footer>

      {rowDrag && rowDrag.active && (
        <RowDragClone rowDrag={rowDrag} columnOrder={columnOrder} />
      )}

      <StockDrawer
        ticker={openTicker}
        onClose={() => setOpenTicker(null)}
        onRemove={() => removeTicker(openTicker)}
      />

      <TweaksPanel title="Tweaks">
        <TweakSection label="Display">
          <TweakRadio label="Density" value={t.density}
                      options={["airy", "regular", "tight"]}
                      onChange={(v) => setTweak("density", v)} />
        </TweakSection>
        <TweakSection label="Direction">
          <TweakRadio label="Move accents" value={t.accent}
                      options={[
                        { value: "muted", label: "Muted" },
                        { value: "vivid", label: "Vivid" },
                        { value: "mono", label: "Mono" },
                      ]}
                      onChange={(v) => setTweak("accent", v)} />
        </TweakSection>
        <TweakSection label="Demo">
          <TweakButton label="Reset to default tickers"
                       onClick={() => {
                         setBoard({ ungrouped: [...DEFAULT_TICKERS], cards: [] });
                         setOpenTicker(null);
                       }} />
          <TweakButton label="Reset column order" secondary
                       onClick={() => setColumnOrder([...DEFAULT_COLUMN_ORDER])} />
          <TweakButton label="Clear board" secondary
                       onClick={() => {
                         setBoard({ ungrouped: [], cards: [] });
                         setOpenTicker(null);
                       }} />
        </TweakSection>
      </TweaksPanel>
    </div>
  );
}

// ── Server board API ─────────────────────────────────────────────────────
// The board + column order are one JSON blob per user, stored server-side.
// Same-origin fetches carry the session cookie automatically.
function fetchBoard() {
  return fetch("/board", { credentials: "same-origin" }).then((r) => {
    if (!r.ok) throw new Error("board " + r.status);
    return r.json();
  });
}

function saveBoardToServer(blob) {
  return fetch("/board", {
    method: "PUT",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(blob),
  }).then((r) => {
    if (!r.ok) throw new Error("board save " + r.status);
    return r.json();
  });
}

// Best-effort flush on tab-hide / unload — `keepalive` lets the PUT outlive
// the page. (sendBeacon can't be used: it's POST-only, /board takes PUT.)
function flushBoard(blob) {
  try {
    fetch("/board", {
      method: "PUT",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(blob),
      keepalive: true,
    });
  } catch (e) {}
}

// EFIMERA wordmark — a dark logo for the cream theme, a light one for night.
// Rafa drops the two files into static/; until then (or if the fetch fails) it
// falls back to a clean serif "EFIMERA" so the header never shows a broken img.
function EfimeraMark({ night }) {
  const src = night ? "efimera-mark-light.png" : "efimera-mark-dark.png";
  const [failed, setFailed] = React.useState(false);
  React.useEffect(() => { setFailed(false); }, [src]);
  if (failed) {
    return <span className="hd__logo hd__logo--fallback">EFIMERA</span>;
  }
  return (
    <img className="hd__logo" src={src} alt="EFIMERA" draggable={false}
         onError={() => setFailed(true)} />
  );
}

function LegendSwatch({ kind, label }) {
  return (
    <span className={"legend legend--" + kind}>
      <span className="legend__dot" />
      <span>{label}</span>
    </span>
  );
}

function InlineToggle({ label, on, onChange }) {
  return (
    <button
      type="button"
      className={"itog" + (on ? " itog--on" : "")}
      role="switch"
      aria-checked={!!on}
      onClick={() => onChange(!on)}>
      <span className="itog__label">{label}</span>
      <span className="itog__track"><span className="itog__thumb" /></span>
      <span className="itog__state">{on ? "On" : "Off"}</span>
    </button>
  );
}

function EmptyState({ onPick, onFocus }) {
  return (
    <div className="empty">
      <div className="empty__rule" />
      <h2 className="empty__h">A blank watchlist.</h2>
      <p className="empty__p">
        Enter a ticker above to begin&nbsp;— or pick one to seed the board.
      </p>
      <div className="empty__chips">
        {["AAPL", "MSFT", "NVDA", "SAP.DE", "NOVO-B.CO"].map((tk) => (
          <button key={tk} className="empty__chip" onClick={() => onPick(tk)}>
            {tk}
          </button>
        ))}
      </div>
      <button className="empty__focus" onClick={onFocus}>
        Focus input
      </button>
    </div>
  );
}

// `App` is mounted by auth.jsx's <AuthGate>, which renders it only once the
// session is confirmed. The ReactDOM.createRoot(...).render() call lives there.
window.App = App;
window.EfimeraMark = EfimeraMark;
