// App — root orchestrator.
// Handles auth, loads brands from the API, manages central state.

// Initialise Supabase client once at module load
window.supabaseClient = supabase.createClient(
  window.IPSEM_CONFIG.supabaseUrl,
  window.IPSEM_CONFIG.supabaseAnonKey,
);

// ─── Loading spinner ────────────────────────────────────────────────────────
function LoadingScreen() {
  return (
    <div style={{
      minHeight: '100vh',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      background: 'var(--bg)',
      color: 'var(--ink-3)',
      fontSize: 13,
      flexDirection: 'column',
      gap: 14,
    }}>
      <div style={{
        width: 34, height: 34,
        background: 'var(--ink)', color: 'var(--bg)',
        borderRadius: 8,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'var(--f-display)', fontSize: 22, paddingTop: 2,
      }}>i</div>
      <span>Loading…</span>
    </div>
  );
}

// Turn a raw error into a clear, user-facing message (surfaces the server's
// detail, and gives friendly wording for the common quota / timeout cases).
function friendlyError(err, fallback) {
  const m = (err && err.message) ? String(err.message) : '';
  if (/^too many /i.test(m)) return m;  // backend rate-limit message is already user-facing
  if (/^already tracked as/i.test(m)) return m;  // duplicate-brand message is already user-facing
  if (/rate.?limit|quota|exhausted|overload|429/i.test(m)) return 'Service quota reached — wait a few minutes and try again.';
  if (/timed out|timeout/i.test(m)) return 'Request timed out — the server may be waking from idle. Try again in a moment.';
  if (/invalid api key|not valid/i.test(m)) return 'API key rejected — check the backend keys in Render.';
  // A missing column means a migration has not been run yet. Say which one
  // instead of surfacing the driver's raw "could not find column" text.
  const col = m.match(/'?(reviewed_at|reviewed_by|confirmed_disciplines|brand_id)'?/);
  if (col && /column|schema|could not find/i.test(m)) {
    const file = (col[1] === 'reviewed_at' || col[1] === 'reviewed_by') ? 'MIGRATION_review_state.sql'
      : col[1] === 'confirmed_disciplines' ? 'MIGRATION_brand_review_jul2026.sql'
      : 'MIGRATION_sponsorship_deals_link.sql';
    return `The database is missing the "${col[1]}" column — run ${file} in Supabase (SQL Editor → New query → Run), then reload.`;
  }
  return m ? `${fallback}: ${m}` : fallback;
}

// The columns /api/brands leaves out (see DETAIL_ONLY_COLUMNS in routers/brands.py).
// Listed here too so a hydrated row's detail is what wins when it is merged over
// a list row — the list row is fresher for everything else.
const DETAIL_FIELDS = [
  'markets', 'exposure', 'marketFocus', 'growthIntel', 'citations',
  'spending', 'sponsorshipHistory', 'currentSponsorships',
];
function pickDetail(row) {
  const out = {};
  DETAIL_FIELDS.forEach((f) => { if (row && row[f] !== undefined) out[f] = row[f]; });
  return out;
}

// ─── App ────────────────────────────────────────────────────────────────────
function App() {
  // undefined = checking session, null = not logged in, object = session
  const [session,   setSession]   = React.useState(undefined);
  const [brands,    setBrands]    = React.useState([]);
  // Where each brand stands with its rightsholders. Held app-wide because it
  // decides the pipeline stage, the status chip on every row, the queue and the
  // Dashboard chase panel — not just the Draft Review warning.
  const [approvals, setApprovals] = React.useState({});
  const [dataReady, setDataReady] = React.useState(false);
  const [route,     setRoute]     = React.useState(() => safeRoute(parseHash()));
  const [selectedBrandId, setSelectedBrandId] = React.useState(null);
  const [toast, setToast] = React.useState(null);
  const [navOpen, setNavOpen] = React.useState(false);  // mobile sidebar drawer
  const [recovery, setRecovery] = React.useState(false);  // password-reset flow
  const [scanRunning, setScanRunning] = React.useState(false);  // global scan indicator
  const [schedule, setSchedule] = React.useState(null);         // auto-scan schedule (for the topbar pill)
  const lastScanRef = React.useRef({ id: null, status: null });
  const [unreadCount, setUnreadCount] = React.useState(0);   // fed by the notification bell
  const [theme, setTheme] = React.useState(() => localStorage.getItem('ipsem-theme') || 'light');
  React.useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('ipsem-theme', theme);
  }, [theme]);
  const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));

  // ─── Stale build ─────────────────────────────────────────────────────────
  // A tab left open across a deploy keeps running the old code. After a spell of
  // inactivity this compares the served build against the one that loaded and
  // reloads only if they differ. If there is unsaved typing it asks instead of
  // discarding it — see useFreshBuild in ui.jsx.
  const offerReload = React.useCallback(() => {
    setToast({
      text: 'A newer version of the app is available. Finish your note, then reload.',
      action: { label: 'Reload now', onClick: () => window.location.reload() },
    });
  }, []);
  useFreshBuild(offerReload);

  // ─── Auth ────────────────────────────────────────────────────────────────
  React.useEffect(() => {
    // Check existing session
    window.supabaseClient.auth.getSession().then(({ data }) => {
      setSession(data.session ?? null);
    });

    // Listen for auth changes (login / logout / password recovery)
    const { data: { subscription } } = window.supabaseClient.auth.onAuthStateChange((event, s) => {
      if (event === 'PASSWORD_RECOVERY') setRecovery(true);
      setSession(s ?? null);
      if (!s) { setBrands([]); setDataReady(false); }
    });
    return () => subscription.unsubscribe();
  }, []);

  // ─── Load brands + properties when session becomes available ──────────────
  React.useEffect(() => {
    if (session) {
      loadBrands();
      loadProperties();
      loadApprovals();
      window.IPSEM_API.getSchedule().then(setSchedule).catch(() => {});
    }
  }, [session]);

  // Best-effort: before MIGRATION_property_approvals.sql is run there is nothing
  // recorded, and the app must not stall on that.
  //
  // Pushed into ui.jsx as well as into state: pbStage() and the status chips
  // read it from there rather than taking it as a prop, so the copy in state is
  // only what makes React re-render when it changes.
  const loadApprovals = React.useCallback(() => {
    window.IPSEM_API.getApprovals().then((map) => {
      setApprovalIndex(map);
      setApprovals(map);
    }).catch((e) => {
      console.warn('Could not load rightsholder approvals:', e);
    });
  }, []);

  // ─── Background-research polling ──────────────────────────────────────────
  // While any brand is mid-research, poll the API every 10s to catch background
  // completions (research runs server-side). Toasts when a brand finishes, and
  // warns once if polling itself keeps failing so stale data is never invisible.
  // Uses refs so the interval doesn't churn on every brands/session change.
  const brandsRef  = React.useRef(brands);
  const sessionRef = React.useRef(session);
  brandsRef.current  = brands;
  sessionRef.current = session;
  // One counter per poller. Sharing a single counter meant the scan poll, which
  // succeeds every 7s, reset it constantly — so the research poll could fail
  // indefinitely and the "live updates are failing" warning never appeared.
  const pollFailsRef = React.useRef(0);      // research poll
  const scanPollFailsRef = React.useRef(0);  // scan-status poll

  // ─── Pending local writes ─────────────────────────────────────────────────
  // Every mutation is optimistic. Server data arriving between the click and
  // the server acknowledging it used to overwrite the decision with the row's
  // old value — that is the "rubber banding" where brands just rejected or sent
  // to research reappeared in To review. So each optimistic patch is held here
  // and re-applied on top of any server data, until a fetch that STARTED after
  // the write was acknowledged comes back (that response must already carry it).
  const pendingRef = React.useRef(new Map());   // id → {patch, inflight, settledAt}
  const deletedRef = React.useRef(new Map());   // id → settledAt (0 = still in flight)
  const createdRef = React.useRef(new Map());   // id → {row, settledAt}
  // ─── Profile detail, fetched per brand ───────────────────────────────────
  // The brands list no longer carries the research profile — markets, exposure,
  // citations, growth intel and the rest are only ever read for the one brand a
  // user has open, and shipping them for all 641 made every page load a 5.6MB
  // request the 512MB backend had to parse and re-encode. It was being OOM-
  // killed mid-session. Detail is pulled once per brand, here, and laid back
  // over the row so a later list refresh cannot strip it out again.
  const detailRef = React.useRef(new Map());   // id → full row

  const notePendingWrite = (id, patch) => {
    const e = pendingRef.current.get(id) || { patch: {}, inflight: 0, settledAt: 0, since: 0 };
    e.patch = { ...e.patch, ...patch };
    if (e.inflight === 0) e.since = Date.now();
    e.inflight += 1;
    e.settledAt = 0;
    pendingRef.current.set(id, e);
  };
  const settlePendingWrite = (id, ok) => {
    const e = pendingRef.current.get(id);
    if (!e) return;
    e.inflight = Math.max(0, e.inflight - 1);
    if (e.inflight > 0) return;
    // A failed save has nothing to protect — drop it and let the server win.
    if (ok) e.settledAt = Date.now();
    else pendingRef.current.delete(id);
  };
  // Writes still waiting on the server. A request that hangs is ignored after
  // 20s so a stalled save can never switch live updates off for good — the
  // overlay above still protects its patch either way.
  const writesInFlight = () => {
    const now = Date.now();
    let n = 0;
    pendingRef.current.forEach(e => { if (e.inflight > 0 && now - e.since < 20000) n += 1; });
    return n;
  };

  // Overlay local edits the server has not confirmed back to us yet, and forget
  // the ones this response proves it already knows about.
  const applyPending = (rows, fetchStartedAt) => {
    pendingRef.current.forEach((e, id) => {
      if (e.inflight === 0 && e.settledAt && e.settledAt < fetchStartedAt) pendingRef.current.delete(id);
    });
    deletedRef.current.forEach((settledAt, id) => {
      if (settledAt && settledAt < fetchStartedAt) deletedRef.current.delete(id);
    });
    createdRef.current.forEach((e, id) => {
      if (e.settledAt && e.settledAt < fetchStartedAt) createdRef.current.delete(id);
    });
    const seen = new Set();
    const out = [];
    rows.forEach((r) => {
      if (deletedRef.current.has(r.id)) return;   // deleted here, server not caught up
      seen.add(r.id);
      // Detail first, then the row (fresher), then anything still unsaved.
      const detail = detailRef.current.get(r.id);
      const merged = detail ? { ...detail, ...r } : r;
      const e = pendingRef.current.get(r.id);
      out.push(e ? { ...merged, ...e.patch } : merged);
    });
    // A brand added here that this response predates would otherwise vanish.
    createdRef.current.forEach((e, id) => { if (!seen.has(id)) out.unshift(e.row); });
    return out;
  };

  // A single server row, with anything still unacknowledged laid back over it.
  // Contact discovery and draft generation return the whole brand, so without
  // this a slow save made a moment earlier (an assignee, a status) would be
  // wiped by the row that came back.
  const withPending = (row) => {
    if (row && row.id) rememberDetail(row);
    const e = row && pendingRef.current.get(row.id);
    return e ? { ...row, ...e.patch } : row;
  };

  // Any full row the server hands back (research finished, contacts found, a
  // draft written) also carries the profile — keep it, so opening that brand
  // does not need another request.
  const rememberDetail = (row) => {
    if (row && row.id && row.markets !== undefined) detailRef.current.set(row.id, row);
  };

  // A saved edit has to land in the detail cache too. The cache supplies the
  // columns the list request omits (markets, exposure, market_focus, spending,
  // growth_intel, citations…), and the research panel edits four of them. With
  // the cache left holding the pre-edit row, the next list refresh merged the
  // OLD values back over the saved ones — the edit visibly reverted and stayed
  // reverted, because hydrateBrand skips a brand it has already cached.
  const rememberDetailPatch = (id, patch) => {
    const cached = detailRef.current.get(id);
    if (!cached) return;
    const relevant = {};
    DETAIL_FIELDS.forEach((f) => { if (patch[f] !== undefined) relevant[f] = patch[f]; });
    if (Object.keys(relevant).length) detailRef.current.set(id, { ...cached, ...relevant });
  };

  React.useEffect(() => {
    const interval = setInterval(async () => {
      if (!sessionRef.current) return;
      const researching = (brandsRef.current || []).filter(b => b.researchStatus === 'researching');
      if (!researching.length) return;
      // Never poll across an unacknowledged write — that is the race that put
      // rejected brands back on the list.
      if (writesInFlight()) return;
      const startedAt = Date.now();
      try {
        // Two columns for every brand, not the whole book. The full row is
        // fetched only for the brands that actually left 'researching', so a
        // long research queue no longer re-downloads (and re-renders) hundreds
        // of profiles every 10 seconds.
        const statuses = await window.IPSEM_API.getBrandStatuses();
        pollFailsRef.current = 0;
        const byId = new Map((statuses || []).map(s => [s.id, s.researchStatus]));
        const finished = researching.filter(b => byId.has(b.id) && byId.get(b.id) !== 'researching');
        if (!finished.length) return;
        const rows = (await Promise.all(
          finished.slice(0, 12).map(b => window.IPSEM_API.getBrand(b.id).catch(() => null))
        )).filter(Boolean);
        if (!rows.length) return;
        rows.forEach(rememberDetail);   // these are full rows — keep the profile
        const fresh = new Map(rows.map(r => [r.id, r]));
        setBrands(cur => applyPending(cur.map(b => fresh.get(b.id) || b), startedAt));
        const done = rows.filter(b => b.researchStatus === 'complete');
        const flagged = rows.filter(b => b.researchStatus === 'needs-review');
        if (done.length) setToast(`${done.map(b => b.brand).join(', ')} → research complete`);
        else if (flagged.length) setToast(`${flagged.map(b => b.brand).join(', ')} → research needs your review`);
      } catch (err) {
        console.warn('Background poll failed:', err);
        pollFailsRef.current += 1;
        if (pollFailsRef.current === 3) {
          setToast('Live updates are failing — data may be stale. Check your connection.');
        }
      }
    }, 10000);
    return () => clearInterval(interval);
  }, []);

  const loadBrands = async () => {
    const startedAt = Date.now();
    try {
      const data = await window.IPSEM_API.getBrands();
      setBrands(applyPending(data, startedAt));
      setDataReady(true);
    } catch (err) {
      console.error('Failed to load brands:', err);
      setToast('Could not load data — check backend is running');
      setDataReady(true); // still show app so user can see error
    }
  };

  // Pull the full profile for one brand (the columns the list omits) and keep it,
  // so opening the same brand again costs nothing and a list refresh cannot
  // blank the panel. Best-effort: the profile simply stays empty on failure.
  const hydrateBrand = React.useCallback(async (id, { force = false } = {}) => {
    if (!id) return;
    if (!force && detailRef.current.has(id)) return;
    try {
      const row = await window.IPSEM_API.getBrand(id);
      if (!row || !row.id) return;
      detailRef.current.set(id, row);
      // The pending patch is applied LAST. Without it, an edit made while this
      // request was in flight was overwritten by the server's pre-edit values —
      // this was the one setBrands call that went through neither applyPending
      // nor withPending.
      const pending = pendingRef.current.get(id);
      setBrands(cur => cur.map(b => b.id === id
        ? { ...row, ...b, ...pickDetail(row), ...(pending ? pending.patch : null) }
        : b));
    } catch (err) {
      console.warn('Could not load the full profile for', id, err);
    }
  }, []);

  // Re-read one brand's profile from the server, discarding what is cached.
  // Needed after a job writes detail-only columns behind the app's back (the
  // footprint refresh), which no list response can carry.
  const refreshBrandDetail = React.useCallback((id) => {
    detailRef.current.delete(id);
    return hydrateBrand(id, { force: true });
  }, [hydrateBrand]);

  // Re-sync ONE brand from the server. Used when a save fails: reloading the
  // whole book would also throw away every other decision made in the meantime.
  const resyncBrand = async (id) => {
    try {
      const row = await window.IPSEM_API.getBrand(id);
      if (!row || !row.id) return;
      rememberDetail(row);
      const fresh = withPending(row);   // keep edits that are still in flight
      setBrands(cur => (cur.some(b => b.id === id)
        ? cur.map(b => b.id === id ? fresh : b)
        : [fresh, ...cur]));   // e.g. a delete that failed — put it back
    } catch (err) {
      console.warn('Could not re-sync brand', id, err);
    }
  };

  // Properties live in the DB. Load them into the shared window.IPSEM_DATA.PROPERTIES
  // array (in place, so existing references stay valid) and bump a version to
  // re-render. propVersion lets the Properties screen refresh after edits.
  const [propVersion, setPropVersion] = React.useState(0);
  const loadProperties = async () => {
    try {
      const props = await window.IPSEM_API.getProperties();
      if (Array.isArray(props)) {
        const arr = window.IPSEM_DATA.PROPERTIES;
        arr.length = 0;
        props.forEach((p) => arr.push(p));
        setPropVersion((v) => v + 1);
      }
    } catch (err) {
      console.error('Failed to load properties:', err);
    }
  };

  // ─── Draining the job queue ───────────────────────────────────────────────
  // Scans and research are queued, not run inline, so something has to work
  // through that queue. A scheduler does on a fixed cycle, but waiting for the
  // next cycle is what would make Re-run scan feel like it had not worked. So
  // while someone is watching, their browser drains it too, and the work moves
  // at the pace it did when it ran inside the server process.
  //
  // Each tick runs about one job and returns how much is left. The ref stops
  // two overlapping: a research job takes 30-60s, far longer than the 7s poll
  // that triggers it.
  const tickingRef = React.useRef(false);
  const queueHintRef = React.useRef(false);

  const driveQueue = React.useCallback(async () => {
    if (tickingRef.current || !sessionRef.current) return;
    tickingRef.current = true;
    try {
      const res = await window.IPSEM_API.tickJobs(20);
      // Keep going while there is work; otherwise stop asking, so an idle tab
      // is not calling this every 7 seconds forever.
      queueHintRef.current = !!(res && (res.remaining > 0 || res.drained > 0));
    } catch (err) {
      console.warn('Job tick failed:', err);
      queueHintRef.current = false;
    } finally {
      tickingRef.current = false;
    }
  }, []);

  // ─── Scan-status polling ──────────────────────────────────────────────────
  // Detect scans anywhere in the app — manual OR the daily background/cron scan.
  // Shows a global banner while one runs and auto-reloads brands the moment it
  // finishes, so new brands appear without a manual refresh.
  React.useEffect(() => {
    const poll = async () => {
      if (!sessionRef.current) return;

      // Keep the queue moving while there is a reason to think work is waiting:
      // something just queued, a brand mid-research, or a scan in flight. All
      // three are known without an extra request — the first from the API layer,
      // the others from state this poll already tracks.
      const justQueued = window.IPSEM_API.takeQueueHint();
      const researching = (brandsRef.current || []).some(b => b.researchStatus === 'researching');
      if (justQueued || queueHintRef.current || researching || lastScanRef.current.status === 'running') {
        driveQueue();
      }

      try {
        const runs = await window.IPSEM_API.getScanRuns();
        scanPollFailsRef.current = 0;
        const last = Array.isArray(runs) ? runs[0] : null;
        if (!last) return;
        setScanRunning(last.status === 'running');

        const prev = lastScanRef.current;
        const finished = last.status === 'complete' || last.status === 'error';
        // Announce a finished run we have not announced yet. Keyed on the run
        // id, not on having watched it turn from running to complete: a scan
        // that fails fast can start and finish inside one seven-second poll,
        // and requiring the running state to have been seen meant it was never
        // announced at all — the click looked like it had done nothing.
        // prev.id guards the first poll after a reload, which would otherwise
        // re-announce whatever ran last.
        const justFinished = !!prev.id && finished
          && (prev.id !== last.id || prev.status === 'running');
        if (justFinished) {
          await loadBrands();
          if (last.status === 'error') {
            setToast(last.errorMessage
              ? `Scan failed — ${last.errorMessage}`
              : 'Scan failed — check the jobs panel');
          } else {
            const added = last.brandsClassified ?? 0;
            setToast(added > 0
              ? `Scan complete — ${added} new brand${added === 1 ? '' : 's'} added`
              : 'Scan complete — no new brands');
          }
        }
        lastScanRef.current = { id: last.id, status: last.status };
      } catch (err) {
        console.warn('Scan-status poll failed:', err);
        scanPollFailsRef.current += 1;
        if (scanPollFailsRef.current === 3) {
          setToast('Live updates are failing — data may be stale. Check your connection.');
        }
      }
    };
    poll();
    const interval = setInterval(poll, 7000);
    return () => clearInterval(interval);
  }, [driveQueue]);

  const handleLogout = async () => {
    await window.supabaseClient.auth.signOut();
  };

  // ─── Profile (name / title stored on the Supabase auth user) ──────────────
  const updateProfile = async (patch) => {
    const { data, error } = await window.supabaseClient.auth.updateUser({ data: patch });
    if (error) {
      setToast('Could not save profile — please try again');
      throw error;
    }
    setSession(cur => (cur ? { ...cur, user: data.user } : cur));
    setToast('Profile updated');
  };

  // ─── Hash routing ─────────────────────────────────────────────────────────
  React.useEffect(() => {
    const onHash = () => {
      setRoute(safeRoute(parseHash()));
    };
    window.addEventListener('hashchange', onHash);
    return () => window.removeEventListener('hashchange', onHash);
  }, []);

  const navigate = React.useCallback((target, brandId) => {
    // A route this build does not have — a bookmark of a retired screen, a typo
    // in a link — lands on the Dashboard rather than rendering nothing at all.
    target = safeRoute(target);
    if (brandId) setSelectedBrandId(brandId);
    setRoute(target);
    setNavOpen(false);  // close mobile drawer on navigation
    window.location.hash = `#/${target}${brandId ? '/' + brandId : ''}`;
  }, []);

  React.useEffect(() => {
    const { brandId } = parseHashFull();
    if (brandId && brandId !== selectedBrandId) setSelectedBrandId(brandId);
  }, [route]);

  // Opening a brand loads its profile. Every screen that shows one reads the
  // selected brand from this state, so one effect covers Research, Match,
  // Contacts and Draft.
  React.useEffect(() => { hydrateBrand(selectedBrandId); }, [selectedBrandId, hydrateBrand]);

  // ─── Brand mutations ──────────────────────────────────────────────────────
  // All mutations are optimistic (UI updates instantly). If the API call fails,
  // the user is told AND that brand is re-synced from the server, so the screen
  // never silently drifts from what is actually saved. Only that brand: a
  // failed save used to reload the whole book, which threw away every other
  // decision made in the same minute — one 429 or one backend restart put a
  // whole review session back on the To review tab.
  const mutationFailed = (err, what, id) => {
    console.error(`${what} failed:`, err);
    setToast(friendlyError(err, what));
    if (id) resyncBrand(id); else loadBrands();
  };

  // A save that fails because the backend is restarting (Render does that after
  // an out-of-memory kill) is not a rejected decision — it is a decision that
  // has not landed yet. Retry the transient cases before telling the user
  // anything, so a blip during a review pass doesn't quietly lose their work.
  const TRANSIENT = (err) => {
    const s = err && err.status;
    return !s || s >= 500 || s === 429 || s === 408;
  };
  const saveBrandPatch = async (id, patch) => {
    const delays = [1500, 5000, 12000];
    for (let attempt = 0; ; attempt += 1) {
      try {
        return await window.IPSEM_API.updateBrand(id, patch);
      } catch (err) {
        if (attempt >= delays.length || !TRANSIENT(err)) throw err;
        if (attempt === 0) setToast('Saving… the server is slow to respond, retrying');
        await new Promise(r => setTimeout(r, delays[attempt]));
      }
    }
  };

  // Notes are the one field two people edit at the same time (the weekly
  // meeting), and they live in a single JSON column — so writing the whole
  // array back overwrote whatever a colleague had added since this tab loaded.
  // The array coming out of the notes panel is diffed against what we hold and
  // sent as one-note operations the server merges. Every call site keeps
  // passing {notes: [...]}, so nothing else had to change.
  const noteKey = (n) => `${(n && n.createdAt) || ''}|${(n && n.author) || ''}`;
  const saveNotes = async (id, nextNotes) => {
    const before = ((brandsRef.current.find(b => b.id === id) || {}).notes) || [];
    const beforeBy = new Map(before.map(n => [noteKey(n), n]));
    const afterBy  = new Map((nextNotes || []).map(n => [noteKey(n), n]));
    const ops = [];
    (nextNotes || []).forEach((n) => {
      const prev = beforeBy.get(noteKey(n));
      if (!prev) ops.push(['add', n]);
      else if (JSON.stringify(prev) !== JSON.stringify(n)) ops.push(['update', n]);
    });
    before.forEach((n) => { if (!afterBy.has(noteKey(n))) ops.push(['remove', n]); });
    for (const [op, note] of ops) await window.IPSEM_API.editNote(id, op, note);
  };

  // Contacts have the same shape of problem as notes: one JSON column, edited
  // from a page two people can be on at once. The Contacts screen edits by
  // position (change this row, delete this row, add one at the top), so the
  // diff below reads positionally when the length is unchanged — that is an
  // edit — and by identity otherwise, which is an add or a delete.
  const contactKey = (c) => (c && c.cid)
    ? `cid:${c.cid}`
    : `who:${((c && c.name) || '').trim().toLowerCase()}|${((c && c.title) || '').trim().toLowerCase()}|${((c && c.email) || '').trim().toLowerCase()}`;
  const newCid = () => `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
  // Returns the contacts to store locally (ids stamped on anything new or just
  // edited) and the operations to send. The stamped array has to go into local
  // state too, or the next edit would key off a contact the server has since
  // given an id and match nothing.
  const planContactOps = (before, nextContacts) => {
    const next = (nextContacts || []).map((c) => ({ ...c }));
    const ops = [];
    if (before.length === next.length) {
      next.forEach((c, i) => {
        const prev = before[i];
        if (JSON.stringify(prev) === JSON.stringify(c)) return;
        c.cid = c.cid || (prev && prev.cid) || newCid();
        ops.push(['update', c, prev]);
      });
    } else {
      const beforeKeys = new Set(before.map(contactKey));
      const nextKeys = new Set(next.map(contactKey));
      next.forEach((c) => {
        if (beforeKeys.has(contactKey(c))) return;
        c.cid = c.cid || newCid();
        ops.push(['add', c, null]);
      });
      before.forEach((c) => { if (!nextKeys.has(contactKey(c))) ops.push(['remove', c, c]); });
    }
    return { contacts: next, ops };
  };
  const runContactOps = async (id, ops) => {
    for (const [op, contact, key] of ops) await window.IPSEM_API.editContact(id, op, contact, key);
  };

  const updateBrand = (id, patchIn) => {
    let patch = patchIn;
    let contactOps = null;
    if (patch.contacts) {
      // Read the previous contacts before the optimistic update below.
      const before = ((brandsRef.current.find(b => b.id === id) || {}).contacts) || [];
      const planned = planContactOps(before, patch.contacts);
      patch = { ...patch, contacts: planned.contacts };
      contactOps = planned.ops;
    }
    setBrands(cur => cur.map(b => b.id === id ? { ...b, ...patch } : b));
    notePendingWrite(id, patch);
    if (patch.notes || contactOps) {
      const { notes, contacts, ...rest } = patch;
      const work = (notes ? saveNotes(id, notes) : Promise.resolve())
        .then(() => (contactOps ? runContactOps(id, contactOps) : null))
        .then(() => (Object.keys(rest).length ? saveBrandPatch(id, rest) : null));
      return work.then(
        () => { settlePendingWrite(id, true); rememberDetailPatch(id, patch); },
        (err) => { settlePendingWrite(id, false); mutationFailed(err, 'Save failed', id); },
      );
    }
    return saveBrandPatch(id, patch).then(
      () => { settlePendingWrite(id, true); rememberDetailPatch(id, patch); },
      (err) => {
        settlePendingWrite(id, false);
        // Swallow after re-sync — callers treat updateBrand as fire-and-forget.
        mutationFailed(err, 'Save failed', id);
      },
    );
  };

  const updateDraft = (id, draftPatch) => {
    const target = brands.find(b => b.id === id);
    if (!target) return;
    const newDraft = { ...target.draft, ...draftPatch };
    setBrands(cur => cur.map(b => b.id === id ? { ...b, draft: { ...b.draft, ...draftPatch } } : b));
    notePendingWrite(id, { draft: newDraft });
    saveBrandPatch(id, { draft: newDraft }).then(
      () => { settlePendingWrite(id, true); },
      (err) => { settlePendingWrite(id, false); mutationFailed(err, 'Draft save failed', id); },
    );
  };

  // selection: array of property ids (or the 'generic' / 'none' sentinels).
  // An EMPTY array is valid — that is how an approved property is removed.
  // disciplines: {propertyId: ['Dressage', …]} — narrows a property without
  // splitting it into separate portfolio entries.
  const confirmMatch = async (id, selection, disciplines) => {
    const isRemoval = (selection || []).length === 0;
    const patch = {
      confirmedProperties: selection || [],
      confirmedDisciplines: disciplines || {},
      ...(selection.includes('none') ? { researchStatus: 'rejected' } : {}),
    };
    setBrands(cur => cur.map(b => b.id === id ? { ...b, ...patch } : b));
    notePendingWrite(id, patch);

    if (isRemoval) {
      setToast('Approved property removed — this brand is back awaiting a decision');
    } else if (selection.includes('none')) {
      setToast('Marked as no suitable property — flow stopped');
    } else if (selection.includes('generic')) {
      setToast('Generic outreach confirmed → finding contacts…');
    } else {
      setToast(`Confirmed ${selection.length} propert${selection.length === 1 ? 'y' : 'ies'} → finding contacts…`);
    }

    try {
      await saveBrandPatch(id, patch);
      settlePendingWrite(id, true);
    } catch (err) {
      // confirmed_disciplines needs MIGRATION_brand_review_jul2026.sql. Retry
      // without it so the property match itself still saves.
      if (/confirmed_disciplines/i.test(String(err && err.message))) {
        console.warn('confirmed_disciplines column missing — run MIGRATION_brand_review_jul2026.sql');
        const { confirmedDisciplines, ...rest } = patch;
        try {
          await saveBrandPatch(id, rest);
          settlePendingWrite(id, true);
        } catch (err2) {
          settlePendingWrite(id, false);
          mutationFailed(err2, 'Could not save the property match', id);
          return;
        }
      } else {
        settlePendingWrite(id, false);
        mutationFailed(err, 'Could not save the property match', id);
        return;
      }
    }

    // Auto-chain: confirming a property is the trigger for the rest of the
    // cycle. Find contacts, then write the draft off the properties just
    // chosen, so the loop is pick → save → back to the next brand rather than
    // pick → save → remember to go and generate a draft.
    //
    // Sequential, not parallel: the draft is addressed to the contacts, so it
    // must run after discovery. Both are best-effort — a failure toasts and
    // leaves the confirmed match saved.
    if (!isRemoval && !selection.includes('none')) {
      const b = brands.find((x) => x.id === id);
      (async () => {
        if (!b || !(b.contacts || []).length) {
          try { await discoverContacts(id); } catch (e) { /* toasted upstream */ }
        }
        // Never silently overwrite a draft that already exists — it may carry
        // manual edits. Regenerating stays an explicit action on the Draft page.
        const current = brandsRef.current.find((x) => x.id === id);
        if (current && !current.draft) {
          try { await generateDraft(id); } catch (e) { /* toasted upstream */ }
        }
      })();
    }
  };

  // Bring a rejected / KIV brand back into the active flow.
  // A brand that already has a profile goes straight back to 'complete' (ready
  // for Match). One with no profile is put back into research AND the research
  // job is actually started — previously it was only flagged 'researching' and
  // sat there until the next server restart picked it up.
  const moveToResearch = (id) => {
    const target = brands.find(b => b.id === id);
    const wasStopped = !!target && (target.researchStatus === 'rejected' || target.researchStatus === 'kiv');
    const hasProfile = !!(target && String(target.description || '').trim());
    const needsResearch = wasStopped && !hasProfile;

    setBrands(cur => cur.map(b => {
      if (b.id !== id) return b;
      const restoreTier = b.previousPartner ? 'tier-2' : 'tier-3';
      const patch = {
        status: 'relevant',
        priority: b.priority === 'no-action' ? restoreTier : b.priority === 'kiv' ? 'tier-2' : b.priority,
        researchStatus: wasStopped ? (hasProfile ? 'complete' : 'researching') : b.researchStatus,
        needsManualReview: false,
        userOverride: true,
        rejectReason: null,
        manualReviewNote: b.manualReviewNote ? `${b.manualReviewNote} · User override applied.` : null,
      };
      notePendingWrite(id, patch);
      saveBrandPatch(id, patch).then(
        () => { settlePendingWrite(id, true); },
        (err) => { settlePendingWrite(id, false); mutationFailed(err, 'Could not move brand to research', id); },
      );
      return { ...b, ...patch };
    }));

    if (needsResearch) {
      queueResearchStart(id).catch(err => {
        console.error('reactivate research failed:', err);
        setToast(friendlyError(err, 'Brand reactivated, but research could not start'));
      });
    }
  };

  // ─── Deep research (Batch 3) ──────────────────────────────────────────────
  // Research runs server-side (30-60s per brand, one at a time on the server's
  // research worker). The API returns immediately with {status:'started'}; the
  // background poll spots the brand leaving 'researching' and swaps in the
  // finished profile.
  //
  // Start requests go out one at a time as well. Working down the review list
  // and hitting Research on a dozen brands used to fire a dozen simultaneous
  // POSTs, which tripped the per-minute rate limit — and every rejected request
  // then re-synced the whole book and undid the decisions around it.
  const researchQueueRef = React.useRef(Promise.resolve());
  const queueResearchStart = (id) => {
    const run = researchQueueRef.current.then(() => window.IPSEM_API.researchBrand(id));
    // A beat between starts so sending a batch paces itself under the backend's
    // per-minute cap rather than firing forty requests in three seconds. The row
    // already reads "Researching…" from the optimistic update, so the wait is
    // invisible. Errors are handled by the caller; the chain never breaks.
    researchQueueRef.current = run.catch(() => {}).then(() => new Promise(r => setTimeout(r, 1100)));
    return run;
  };

  const researchBrand = async (id) => {
    const name = (brands.find(b => b.id === id) || {}).brand || 'brand';
    // Optimistic: mark as researching immediately so the UI shows progress.
    // Registered as a pending write so a poll landing mid-queue cannot flip the
    // row back to "not started" before the server has been told.
    setBrands(cur => cur.map(b => b.id === id ? { ...b, researchStatus: 'researching' } : b));
    notePendingWrite(id, { researchStatus: 'researching' });
    try {
      await queueResearchStart(id);
      settlePendingWrite(id, true);
      setToast(`Researching ${name}… it will update here automatically`);
    } catch (err) {
      console.error('researchBrand failed:', err);
      settlePendingWrite(id, false);
      setToast(friendlyError(err, 'Could not start research'));
      await resyncBrand(id);   // just this brand — never the whole book
    }
  };

  // ─── Contact discovery (Batch 5) ──────────────────────────────────────────
  const discoverContacts = async (id) => {
    try {
      const updated = await window.IPSEM_API.discoverContacts(id);
      if (updated && updated.id) {
        setBrands(cur => cur.map(b => b.id === id ? withPending(updated) : b));
        const n = (updated.contacts || []).length;
        setToast(n > 0
          ? `Found ${n} contact${n === 1 ? '' : 's'}`
          : 'No contacts found — try again later');
      } else {
        await loadBrands();
        setToast('No contacts found for this brand');
      }
    } catch (err) {
      console.error('discoverContacts failed:', err);
      setToast(friendlyError(err, 'Contact search failed'));
      throw err;
    }
  };

  // ─── Email draft generation (Batch 6) ─────────────────────────────────────
  const generateDraft = async (id) => {
    try {
      // Sign the draft as the logged-in user (name + title from their profile).
      const sender = {
        name:  session?.user?.user_metadata?.full_name || '',
        title: session?.user?.user_metadata?.title || '',
      };
      const updated = await window.IPSEM_API.generateDraft(id, sender.name ? sender : null);
      if (updated && updated.id) {
        setBrands(cur => cur.map(b => b.id === id ? withPending(updated) : b));
        setToast(updated.draft?.source === 'template'
          ? `Draft ready for ${updated.brand} — standard template (AI was unavailable; regenerate later for a bespoke draft)`
          : `Draft ready for ${updated.brand}`);
      } else {
        await loadBrands();
        setToast('Could not generate a draft (no confirmed property)');
      }
    } catch (err) {
      console.error('generateDraft failed:', err);
      setToast(friendlyError(err, 'Draft generation failed'));
      throw err;
    }
  };

  // Regenerate every existing draft in IPSEM's proven format. AI-written per
  // brand (with a per-brand template fallback if Gemini is unavailable).
  const rebuildAllDrafts = async () => {
    const sender = {
      name:  session?.user?.user_metadata?.full_name || '',
      title: session?.user?.user_metadata?.title || '',
    };
    setToast('Rebuilding all drafts — AI writes each one, this can take a few minutes…');
    try {
      const res = await window.IPSEM_API.rebuildDrafts(sender.name ? sender : null, true);
      await loadBrands();
      const skipped = Array.isArray(res?.skipped) ? res.skipped : [];
      let msg = `Rebuilt ${res?.rebuilt ?? 0} draft${(res?.rebuilt ?? 0) === 1 ? '' : 's'}`;
      if (skipped.length) {
        const names = skipped.slice(0, 3).map((s) => s.brand).join(', ');
        msg += ` — ${skipped.length} skipped (${names}${skipped.length > 3 ? '…' : ''})`;
      }
      setToast(msg);
    } catch (err) {
      console.error('rebuildAllDrafts failed:', err);
      setToast(friendlyError(err, 'Rebuild failed'));
    }
  };

  const deleteBrand = (id) => {
    const target = brands.find(b => b.id === id);
    setBrands(cur => cur.filter(b => b.id !== id));
    // Hold the deletion so a server response fetched before it lands cannot put
    // the row back on the list.
    deletedRef.current.set(id, 0);
    if (selectedBrandId === id) setSelectedBrandId(null);
    setToast(target ? `${target.brand} deleted` : 'Brand deleted');
    window.IPSEM_API.deleteBrand(id).then(
      () => { deletedRef.current.set(id, Date.now()); },
      (err) => {
        deletedRef.current.delete(id);
        console.error('deleteBrand failed:', err);
        setToast(friendlyError(err, 'Delete failed'));
        resyncBrand(id);
      },
    );
  };

  const addBrand = async (newBrand) => {
    setBrands(cur => [{ ...newBrand }, ...cur]);
    createdRef.current.set(newBrand.id, { row: { ...newBrand }, settledAt: 0 });
    setSelectedBrandId(newBrand.id);
    setToast(newBrand.researchStatus === 'researching'
      ? `Researching ${newBrand.brand}…`
      : `${newBrand.brand} added`);
    try {
      await window.IPSEM_API.createBrand(newBrand);
      createdRef.current.set(newBrand.id, { row: { ...newBrand }, settledAt: Date.now() });
    } catch (err) {
      console.error('addBrand failed:', err);
      // The row went in optimistically. A rejected save — most often the backend
      // spotting a near-identical name already in the book — has to take it back
      // out, or the UI shows a brand that was never stored.
      createdRef.current.delete(newBrand.id);
      setBrands(cur => cur.filter(b => b.id !== newBrand.id));
      setSelectedBrandId(cur => (cur === newBrand.id ? null : cur));
      setToast(friendlyError(err, 'Error saving brand'));
    }
  };

  // Add a brand from a news-article URL: the backend reads the page, extracts the
  // brand and starts real research. Throws on failure so the drawer can show why.
  const addBrandFromUrl = async (url) => {
    const created = await window.IPSEM_API.researchFromUrl(url);
    if (!created || !created.id) throw new Error('Could not research that URL.');
    setBrands(cur => [created, ...cur.filter(b => b.id !== created.id)]);
    createdRef.current.set(created.id, { row: created, settledAt: Date.now() });
    setSelectedBrandId(created.id);
    setToast(`Researching ${created.brand}… it will update here automatically`);
    navigate('research', created.id);
    return created;
  };

  const onOpenBrand = (id) => {
    setSelectedBrandId(id);
    navigate('research', id);
  };

  // ─── Sidebar counts ───────────────────────────────────────────────────────
  // Every badge counts exactly what its page's "To review" tab shows, so the
  // sidebar can never disagree with the list you land on. Previously News
  // Intake badged BOTH review gates (67, when its own list held 0) and Brand
  // Research counted mid-research brands (0) — which made the sidebar fall back
  // to rendering the step number "02", indistinguishable from a count.
  const counts = {
    intake:   brands.filter(needsRelevanceCall).length || null,
    research: brands.filter(needsPropertyDecision).length || null,
    match:    brands.filter(needsPropertyDecision).length || null,
    contacts: brands.filter(needsContactWork).length || null,
    draft:    brands.filter(b => needsDraft(b) || draftReadyNotSent(b)).length || null,
    outreach: brands.filter(b => (b.confirmedProperties || []).length > 0 && !(b.confirmedProperties || []).includes('none') && (!b.outreachStatus || b.outreachStatus === 'not_contacted')).length || null,
    // Held/closed = out of the active flow (KIV, rejected, DNC, closed) — shown
    // in the sidebar so a big held book is never invisible.
    // pbIsHeldOrClosed, not a copy of it — the copy omitted researchStatus
    // 'rejected'/'kiv' and a 'none' property, so the sidebar's held count came
    // out lower than the Dashboard funnel's "Held / closed" row for the same
    // brands, and the funnel is the one that sums to the total.
    held: brands.filter(pbIsHeldOrClosed).length,
    // Workspace badges: unread @mentions, and open next-actions across all notes.
    notifications: unreadCount || null,
    // Badge counts OPEN ACTIONS only. Every uncleared comment would read in the
    // hundreds and stop meaning anything.
    notes: brands.reduce((n, b) =>
      n + (Array.isArray(b.notes) ? b.notes : []).filter(x => x && x.isAction && !noteIsCleared(x)).length, 0) || null,
    total: brands.length,
  };

  const selectedBrand = selectedBrandId ? brands.find(b => b.id === selectedBrandId) : null;

  const currentUser = {
    name:     session?.user?.user_metadata?.full_name || '',
    title:    session?.user?.user_metadata?.title || '',
    email:    session?.user?.email || '',
    timezone: session?.user?.user_metadata?.timezone || '',
  };

  // Push the reader's zone into the shared time helpers so every timestamp in
  // the app renders in it. Empty = follow the browser. Set synchronously rather
  // than in an effect: the helpers are read during the children's first render,
  // and a module-level assignment would not re-render them afterwards.
  setViewerTimeZone(currentUser.timezone);

  // ─── Render guards ────────────────────────────────────────────────────────
  if (recovery) return <ResetPasswordScreen onDone={() => { setRecovery(false); setToast('Password updated'); }} />;
  if (session === undefined) return <LoadingScreen />;
  if (session === null) return <AuthScreen onLogin={setSession} />;
  if (!dataReady) return <LoadingScreen />;
  if (brands.length === 0) return (
    <SeedScreen
      onSeeded={loadBrands}
      onLogout={handleLogout}
    />
  );

  // ─── Main app ─────────────────────────────────────────────────────────────
  return (
    <div className={`app ${navOpen ? 'nav-open' : ''}`}>
      <div className="scrim" onClick={() => setNavOpen(false)} />
      <Sidebar
        active={route}
        onNavigate={k => navigate(k)}
        counts={counts}
        onLogout={handleLogout}
        user={currentUser}
        onUpdateProfile={updateProfile}
        theme={theme}
        onToggleTheme={toggleTheme}
      />
      <main className="main">
        <Topbar
          route={route}
          brand={selectedBrand}
          onNavigate={navigate}
          brands={brands}
          onMenu={() => setNavOpen(true)}
          scanRunning={scanRunning}
          schedule={schedule}
          currentUser={currentUser}
          onUpdateBrand={updateBrand}
          onMoveToResearch={moveToResearch}
          onToast={setToast}
          onUnreadChange={setUnreadCount}
          onScanStart={() => setScanRunning(true)}
        />

        {scanRunning && <ScanBanner />}

        {route === 'dashboard' && (
          <Dashboard brands={brands} onNavigate={navigate} onOpenBrand={onOpenBrand} onUpdateBrand={updateBrand} onToast={setToast} currentUser={currentUser} approvals={approvals} onReloadProperties={loadProperties} />
        )}
        {route === 'intake' && (
          <NewsIntake
            brands={brands}
            onNavigate={navigate}
            onOpenBrand={onOpenBrand}
            onUpdateBrand={updateBrand}
            onAddBrand={addBrand}
            onResearchUrl={addBrandFromUrl}
            onMoveToResearch={moveToResearch}
            onToast={setToast}
            onReload={loadBrands}
            onDeleteBrand={deleteBrand}
            onResearchBrand={researchBrand}
            onScanStart={() => setScanRunning(true)}
            currentUser={currentUser}
          />
        )}
        {route === 'research' && (
          <BrandResearch
            brands={brands}
            selectedBrandId={selectedBrandId}
            onSelectBrand={setSelectedBrandId}
            onNavigate={navigate}
            onUpdateBrand={updateBrand}
            onMoveToResearch={moveToResearch}
            onToast={setToast}
            onResearchBrand={researchBrand}
            onRefreshBrandDetail={refreshBrandDetail}
            currentUser={currentUser}
          />
        )}
        {route === 'match' && (
          <PropertyMatch
            brands={brands}
            selectedBrandId={selectedBrandId}
            onSelectBrand={setSelectedBrandId}
            onConfirmMatch={confirmMatch}
            onNavigate={navigate}
            onUpdateBrand={updateBrand}
            onMoveToResearch={moveToResearch}
            onToast={setToast}
            currentUser={currentUser}
            onReloadProperties={loadProperties}
          />
        )}
        {route === 'contacts' && (
          <Contacts
            brands={brands}
            selectedBrandId={selectedBrandId}
            onSelectBrand={setSelectedBrandId}
            onNavigate={navigate}
            onToast={setToast}
            onDiscoverContacts={discoverContacts}
            onUpdateBrand={updateBrand}
            currentUser={currentUser}
            onMoveToResearch={moveToResearch}
          />
        )}
        {route === 'draft' && (
          <DraftReview
            brands={brands}
            selectedBrandId={selectedBrandId}
            onSelectBrand={setSelectedBrandId}
            onUpdateDraft={updateDraft}
            onNavigate={navigate}
            onToast={setToast}
            onGenerateDraft={generateDraft}
            onUpdateBrand={updateBrand}
            onRebuildAll={rebuildAllDrafts}
            currentUser={currentUser}
            onMoveToResearch={moveToResearch}
            approvals={approvals}
          />
        )}
        {route === 'properties' && (
          <PropertiesLibrary onToast={setToast} propVersion={propVersion} onReloadProperties={loadProperties} brands={brands} onNavigate={navigate} onApprovalsChanged={loadApprovals} />
        )}
        {route === 'deals' && (
          <SponsorshipDataPage onToast={setToast} />
        )}
        {route === 'notifications' && (
          <NotificationsPage currentUser={currentUser} onNavigate={navigate} onToast={setToast} />
        )}
        {route === 'notes' && (
          <NotesPage
            brands={brands}
            currentUser={currentUser}
            onUpdateBrand={updateBrand}
            onNavigate={navigate}
            onToast={setToast}
          />
        )}
        {route === 'howto' && (
          <HowTo onNavigate={navigate} />
        )}
      </main>

      <Toast msg={toast} onClear={() => setToast(null)} />
    </div>
  );
}

// ─── Global scan banner ─────────────────────────────────────────────────────
// Shown on every page while a scan runs (manual or the daily background scan).
function ScanBanner() {
  return (
    <div className="row gap-2" style={{
      marginBottom: 14, padding: '12px 16px',
      background: 'var(--accent-soft-bg)', border: '1px solid var(--accent)',
      borderRadius: 10, alignItems: 'center', flexWrap: 'wrap',
    }}>
      <window.I.refresh size={14} className="spin" stroke="var(--accent)" />
      <span className="text-sm" style={{ fontWeight: 600 }}>Scan in progress…</span>
      <span className="text-xs text-3">Finding new brands from the latest news — they'll appear automatically when it's done.</span>
    </div>
  );
}

// ─── Global brand search ────────────────────────────────────────────────────
function GlobalSearch({ brands, onNavigate }) {
  const [q, setQ] = React.useState('');
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const results = React.useMemo(() => {
    const s = q.trim().toLowerCase();
    if (!s) return [];
    return (brands || [])
      .filter((b) => b.brand.toLowerCase().includes(s) || (b.category || '').toLowerCase().includes(s))
      .slice(0, 8);
  }, [q, brands]);
  const pick = (b) => { setQ(''); setOpen(false); onNavigate('research', b.id); };
  return (
    <div ref={ref} style={{ position: 'relative', width: 230, maxWidth: '38vw' }}>
      <div className="field" style={{ height: 34 }}>
        <window.I.search size={14} stroke="var(--ink-3)" />
        <input
          placeholder="Search brands…"
          value={q}
          onChange={(e) => { setQ(e.target.value); setOpen(true); }}
          onFocus={() => setOpen(true)}
        />
      </div>
      {open && results.length ? (
        <div style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, left: 0, zIndex: 40, background: 'var(--card)', border: '1px solid var(--hairline-strong)', borderRadius: 8, boxShadow: 'var(--shadow-lg)', maxHeight: 340, overflowY: 'auto', padding: 6 }}>
          {results.map((b) => (
            <div key={b.id} onClick={() => pick(b)} className="row gap-2" style={{ padding: '7px 8px', borderRadius: 6, cursor: 'pointer', alignItems: 'center' }}>
              <BrandLogo brand={b} size={22} />
              <div className="col" style={{ gap: 0, minWidth: 0 }}>
                <span className="text-sm" style={{ fontWeight: 500 }}>{b.brand}</span>
                <span className="text-xs text-3 truncate">{b.category}</span>
              </div>
              <span className="right"><PriorityPill priority={b.priority} /></span>
            </div>
          ))}
        </div>
      ) : null}
    </div>
  );
}

// ─── Topbar ───────────────────────────────────────────────────────────────
function Topbar({ route, brand, onNavigate, brands, onMenu, scanRunning, schedule, currentUser, onUpdateBrand, onMoveToResearch, onToast, onUnreadChange, onScanStart }) {
  const FLOW = ['intake', 'research', 'match', 'contacts', 'draft'];
  const flowLabel = { intake: 'Intake', research: 'Research', match: 'Match', contacts: 'Contacts', draft: 'Draft' };
  const idx = FLOW.indexOf(route);

  const MenuBtn = () => (
    <button className="menu-btn" onClick={onMenu} aria-label="Open navigation">
      <window.I.menu size={20} />
    </button>
  );

  if (route === 'dashboard' || route === 'properties' || route === 'deals' || idx === -1) {
    return (
      <div className="topbar">
        <div className="row gap-2"><MenuBtn /><span className="text-3 text-sm">{breadcrumb(route)}</span></div>
        <div className="row gap-2" style={{ alignItems: 'center' }}>
          <GlobalSearch brands={brands} onNavigate={onNavigate} />
          <NotificationBell currentUser={currentUser} onNavigate={onNavigate} onUnreadChange={onUnreadChange} />
          {/* One button that lands on the next brand actually waiting on a person. */}
          <span className="hide-mobile"><QueueButton brands={brands} onNavigate={onNavigate} /></span>
          {/* Scanning is manual, so the control belongs on every page — not only
              News Intake, where it was easy to forget. */}
          <ScanButton onToast={onToast} onScanStart={onScanStart} scanRunning={scanRunning} className="btn" />
        </div>
      </div>
    );
  }

  return (
    <div className="topbar" style={{ height: 60 }}>
      <div className="row gap-3" style={{ flex: 1, overflowX: 'auto' }}>
        <MenuBtn />
        <div className="row gap-3 hide-mobile" style={{ overflowX: 'auto' }}>
        {FLOW.map((step, i) => {
          const active = step === route;
          const past = i < idx;
          return (
            <React.Fragment key={step}>
              <button
                className="row gap-2"
                onClick={() => onNavigate(step, brand?.id)}
                style={{
                  background: 'transparent', border: 0,
                  color: active ? 'var(--ink)' : past ? 'var(--ink-2)' : 'var(--ink-3)',
                  fontWeight: active ? 600 : 500,
                  fontSize: 13.5, padding: 0, cursor: 'pointer',
                }}
              >
                <span className="mono" style={{
                  width: 22, height: 22, borderRadius: 999,
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  background: active ? 'var(--accent)' : past ? 'var(--ink)' : 'var(--muted-soft)',
                  color: active || past ? 'white' : 'var(--ink-3)',
                  fontSize: 10.5,
                }}>
                  {i + 1}
                </span>
                {flowLabel[step]}
              </button>
              {i < FLOW.length - 1 && <window.I.chevronRight size={12} stroke="var(--ink-4)" />}
            </React.Fragment>
          );
        })}
        </div>
      </div>
      <div className="row gap-3" style={{ alignItems: 'center', flexShrink: 0 }}>
        {brand && route !== 'intake' && (
          <div className="row gap-2 hide-mobile">
            <span className="text-xs text-3">Focused on</span>
            <span className="text-sm" style={{ fontWeight: 600 }}>{brand.brand}</span>
            <PriorityPill priority={brand.priority} />
          </div>
        )}
        {/* Reject / KIV is reachable from every stage of the flow, not just Intake. */}
        {brand && route !== 'intake' && onUpdateBrand ? (
          <BrandStopControl
            brand={brand}
            onUpdateBrand={onUpdateBrand}
            onMoveToResearch={onMoveToResearch}
            onToast={onToast}
            variant="compact"
          />
        ) : null}
        <NotificationBell currentUser={currentUser} onNavigate={onNavigate} onUnreadChange={onUnreadChange} />
        <span className="hide-mobile"><QueueButton brands={brands} onNavigate={onNavigate} className="btn btn-primary btn-sm" /></span>
        <ScanButton onToast={onToast} onScanStart={onScanStart} scanRunning={scanRunning} iconOnly />
        <GlobalSearch brands={brands} onNavigate={onNavigate} />
      </div>
    </div>
  );
}

function breadcrumb(route) {
  return {
    dashboard: 'Dashboard', properties: 'Properties library',
    deals: 'Sponsorship data', howto: 'How to use',
    notifications: 'Notifications', notes: 'Notes & actions',
  }[route] || route;
}

// Every screen this build renders. A hash naming anything else — a bookmark of
// the retired Pipeline board, a hand-typed typo — used to set a route nothing
// matched, which rendered an empty page under a live topbar. It now falls back
// to the Dashboard.
const ROUTES = new Set([
  'dashboard', 'intake', 'research', 'match', 'contacts', 'draft',
  'properties', 'deals', 'notifications', 'notes', 'howto',
]);

function safeRoute(route) {
  return ROUTES.has(route) ? route : 'dashboard';
}

function parseHash() {
  const h = window.location.hash || '';
  const m = h.match(/^#\/([a-z]+)(?:\/.+)?$/);
  return m ? m[1] : null;
}
function parseHashFull() {
  const h = window.location.hash || '';
  const m = h.match(/^#\/([a-z]+)(?:\/(.+))?$/);
  return { route: m ? m[1] : null, brandId: m ? m[2] : null };
}

// ─── Mount ────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
