// ===========================================================
// BallotSection — the weekly Deep Dive ballot.
//
// ONE COMPONENT, THREE PLACES. /vote (canonical, what the email links to), embedded on
// /deep-dives, and a signpost at the end of a dive essay. Rendering the same component
// everywhere means the counts, the countdown and "your pick" cannot drift between surfaces —
// the same reason ballot_slate is stored rather than recomputed per caller.
//
// WHY /vote IS THE CANONICAL HOME AND NOT /deep-dives. The ballot lived at the bottom of
// /deep-dives from June and collected 5 nominations in 8 weeks. affiliate_clicks has never
// recorded a single interaction from a deep-dives surface — engagement comes from collection
// (24), the album-page strip (15), list (10) and home (5). So the archive page is not where
// people are. It gets the embed because a dive reader is the right audience; the email gets
// the traffic.
//
// THE MAGIC LINK. A vote arrives as ?r&n&e&t and is POSTed from here on mount, never by the
// GET that loaded the page. 72% of the record-shop drip's clicks fired inside 60 seconds of
// delivery — Outlook and Mimecast fetching every URL — so a GET that wrote would let scanners
// pick the winner. They do not run JS. The query is then stripped with replaceState so a
// refresh cannot re-submit.
// ===========================================================
const { useState: useBalState, useEffect: useBalEffect, useRef: useBalRef } = React;

const BAL_API = "/api/deep-dives?action=ballot-state";
const BAL_VOTE = "/api/deep-dives?action=ballot-vote";

// The same localStorage key Nominations.jsx has always used, so a device that voted under the
// old ballot keeps its identity rather than becoming a second person.
function balDeviceKey() {
  try {
    let k = localStorage.getItem("bbr:voter");
    if (!k) {
      k = "d:" + (crypto.randomUUID ? crypto.randomUUID() : Date.now() + "-" + Math.random().toString(36).slice(2));
      localStorage.setItem("bbr:voter", k);
    }
    return k.replace(/^d:/, "");   // the API prefixes and sanitises it server-side
  } catch (e) { return null; }
}

function balCountdown(ms) {
  if (ms <= 0) return "closed";
  const m = Math.floor(ms / 60000), d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60);
  if (d >= 1) return d + "d " + h + "h";
  if (h >= 1) return h + "h " + (m % 60) + "m";
  return m + "m";
}

function balDate(iso) {
  if (!iso) return "";
  const d = new Date(iso + (String(iso).length === 10 ? "T00:00:00Z" : ""));
  return isNaN(d) ? "" : d.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" });
}

function BallotSection({ variant = "page", user, onRequireAuth, magic }) {
  const [state, setState] = useBalState(null);      // null = loading
  const [busy, setBusy] = useBalState(null);        // nomination id mid-flight
  const [msg, setMsg] = useBalState("");
  const [now, setNow] = useBalState(Date.now());
  const magicDone = useBalRef(false);

  // Recomputed from the clock each tick so a throttled or backgrounded tab self-corrects.
  // Decoration only: every date below is also stated in words.
  useBalEffect(() => {
    const t = setInterval(() => setNow(Date.now()), 30000);
    return () => clearInterval(t);
  }, []);

  async function load() {
    const d = balDeviceKey();
    const qs = [];
    if (d) qs.push("d=" + encodeURIComponent(d));
    if (magic && magic.e && magic.t) qs.push("e=" + encodeURIComponent(magic.e) + "&t=" + encodeURIComponent(magic.t));
    try {
      const r = await fetch(BAL_API + (qs.length ? "&" + qs.join("&") : ""));
      setState(r.ok ? await r.json() : { round: null });
    } catch (e) { setState({ round: null }); }
  }
  useBalEffect(() => { load(); }, []);

  async function accessToken() {
    try {
      const { data } = await window.BBR_supabase.auth.getSession();
      return (data && data.session && data.session.access_token) || null;
    } catch (e) { return null; }
  }

  async function cast(nominationId, viaMagic) {
    setBusy(nominationId); setMsg("");
    try {
      const payload = { nominationId };
      if (viaMagic && magic) { payload.email = atob(magic.e.replace(/-/g, "+").replace(/_/g, "/")); payload.token = magic.t; }
      else {
        const tok = await accessToken();
        if (tok) payload.accessToken = tok; else payload.deviceKey = balDeviceKey();
      }
      const r = await fetch(BAL_VOTE, {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setMsg(j.error || "Couldn’t record that vote."); }
      else { setMsg(j.moved ? "Vote moved." : "Vote counted."); }
      await load();
    } catch (e) {
      setMsg("Couldn’t record that vote.");
    } finally { setBusy(null); }
  }

  // A vote arriving from the email: POST once, then strip the query so a refresh or a back
  // button cannot re-submit it.
  useBalEffect(() => {
    if (!magic || !magic.n || magicDone.current) return;
    magicDone.current = true;
    cast(magic.n, true).then(() => {
      try { window.history.replaceState({}, "", "/vote"); } catch (e) {}
    });
  }, [magic && magic.n]);

  if (!state) return null;
  const round = state.round;

  if (!round) {
    return (
      <section className={"bal bal--" + variant} id="vote">
        <div className="bal-inner">
          <div className="bal-kicker">The ballot</div>
          <h2 className="bal-h">Voting opens Wednesday.</h2>
          <p className="bal-lede">
            Every Wednesday a shortlist of records goes up and the one with the most votes gets
            written up as a full Deep Dive. Come back then, or read this week’s while you wait.
          </p>
        </div>
      </section>
    );
  }

  const left = new Date(round.closesAt).getTime() - now;
  const open = round.isOpen && left > 0;
  const total = state.slate.reduce((t, s) => t + (s.votes || 0), 0);
  // A slate of finished essays asks a different question from a slate of suggestions, and the
  // prize differs too, so the copy has to follow rather than average the two.
  const anyWritten = state.slate.some((s) => s.written);

  return (
    <section className={"bal bal--" + variant} id="vote" aria-labelledby="bal-h">
      <div className="bal-inner">
        <div className="bal-head">
          <div className="bal-kicker">
            {open ? <><span className="bal-pulse" aria-hidden="true" /> {anyWritten ? "Voting open · sets the running order" : "Voting open"}</> : "Voting closed"}
          </div>
          <h2 className="bal-h" id="bal-h">You pick next week’s Deep Dive.</h2>
          <p className="bal-lede">
            {open ? (anyWritten ? <>
              These are written and waiting. Your vote decides which one runs next Wednesday —
              the essay, the pressing guide, the history, already finished.
              {" "}Closes in <strong>{balCountdown(left)}</strong>. No account needed.
            </> : <>
              Whichever record leads when voting closes gets the full treatment — the essay, the
              pressing guide, the history — published <strong>{balDate(round.publishesOn)}</strong>.
              {" "}Closes in <strong>{balCountdown(left)}</strong>. No account needed.
            </>) : <>
              This round has closed. The winner is written up for {balDate(round.publishesOn)}.
            </>}
          </p>
          {msg ? <div className="bal-msg">{msg}</div> : null}
        </div>

        <ol className="bal-list">
          {state.slate.map((s) => {
            const mine = state.myVote === s.id;
            const pct = total > 0 ? Math.round((s.votes / total) * 100) : 0;
            return (
              <li className={"bal-row" + (mine ? " is-mine" : "") + (s.isWinner ? " is-winner" : "")} key={s.id}>
                <div className="bal-cover">
                  {s.cover
                    /* No loading="lazy". These are 64px thumbnails on a page whose entire
                       purpose is this list, so deferring them buys nothing — and it measurably
                       did not work: verified in-viewport at y=136 with the correct src and
                       complete:false after three seconds, while the same URL via new Image()
                       loaded instantly at 1200px. Whatever the cause, a cover that never
                       requests is worse than one requested a moment early. */
                    ? <img src={s.cover} alt="" width="64" height="64" decoding="async"
                           /* Cover Art Archive returns 500 at random — three passes over the
                              same eleven URLs failed on different albums each time — so a
                              broken image falls back to the blank treatment rather than
                              showing a broken-image icon. */
                           onError={(e) => { e.target.style.display = "none"; }} />
                    : null}
                </div>
                <div className="bal-meta">
                  <div className="bal-title">{s.title}</div>
                  <div className="bal-artist">
                    {s.artist}{s.year ? " · " + s.year : ""}
                    {s.written
                      ? <span className="bal-tag bal-tag--ready">Written · ready to publish</span>
                      : s.editorial ? <span className="bal-tag">The Lead-In’s shortlist</span> : null}
                  </div>
                  {s.pitch ? <p className="bal-pitch">{s.pitch}</p> : null}
                  <div className="bal-bar"><i style={{ width: Math.max(pct, s.votes ? 4 : 0) + "%" }} /></div>
                </div>
                <div className="bal-act">
                  <div className="bal-count">{s.votes}<span>{s.votes === 1 ? " vote" : " votes"}</span></div>
                  {open ? (
                    <button className={"bal-btn" + (mine ? " bal-btn--mine" : "")}
                            disabled={busy === s.id}
                            onClick={() => cast(s.id)}>
                      {busy === s.id ? "…" : mine ? "Your pick ✓" : "Vote"}
                    </button>
                  ) : s.isWinner ? <span className="bal-won">Winner</span> : null}
                </div>
              </li>
            );
          })}
        </ol>

        <div className="bal-foot">
          {total === 0
            ? "No votes yet. One vote each, and you can move it until Wednesday."
            : total + (total === 1 ? " vote cast" : " votes cast") + " so far. One vote each — move yours any time before it closes."}
          {" "}
          {user
            ? null
            : <button className="bal-link" onClick={() => onRequireAuth && onRequireAuth("nominate")}>
                Suggest an album we’ve missed
              </button>}
        </div>
      </div>
    </section>
  );
}

// Read the magic-link params off the URL. Exported so /vote and the embeds agree on the shape.
function bbrBallotMagic() {
  try {
    const p = new URLSearchParams(location.search);
    const n = p.get("n"), e = p.get("e"), t = p.get("t");
    return n && e && t ? { n, e, t, r: p.get("r") } : null;
  } catch (err) { return null; }
}

Object.assign(window, { BallotSection, bbrBallotMagic });
