/* Ported from apps/web/src/pages/UserHome.tsx
 *
 * Substitutions (CONVENTIONS rule 8 / substitution table):
 * - ComposeDrawer / useComposeCampaign / useComposeReentry are NOT ported.
 *   Every compose call site keeps the app's FLAG-OFF behavior (the wiring
 *   that shipped before chore/drop-sequence-composer-flag): the bar's
 *   Outreach CTA opens audience review (`openAudience`), ?compose=1 lands on
 *   audience review, and the AudienceDrawer's Outreach CTA closes the overlay
 *   and shows the "coming next"-style toast the host owned in that era.
 * - GET_HOME_COUNTS (useQuery) → T.Data.useHomeCounts (same envelope incl.
 *   refetch/startPolling/stopPolling; the mock's polling is inert).
 * - apolloClient.query warm-ups in onImportReady → the tabs' mock readers
 *   (T.queryHomePeople / T.Data.raw.filterAccounts). The mock has no cache,
 *   so the warm-up is inert beyond preserving the hand-off's ordering.
 * - useContextValidation / usePermissions (auth) are skipped: the fixture
 *   tenant+user always satisfy hasRequiredContext, and the fixture user is
 *   an admin, so `has('users:read')` is true.
 * - `import '../features/home/home-tables.css'` → loaded globally by
 *   index.html.
 */
(() => {
  const T = window.T;
  const { useEffect, useMemo, useRef, useState } = React;
  const { useNavigate, useParams, useSearchParams } = T.Router;
  const {
    MobileSidebarTrigger,
    PageContainer,
    useImportJobRunning,
    WORKFLOW_JOB_STARTED_EVENT,
    WORKFLOW_JOB_COMPLETED_EVENT,
  } = T.UI;
  const { Loader2 } = T.Icons;
  const {
    AccountDrawer,
    AccountsTab,
    AudienceBar,
    AudienceDrawer,
    DrawerShell,
    HomeTabs,
    ListFilterChip,
    PeopleTab,
    PersonDrawer,
    SignalDrawer,
    SignalsTab,
    clearHomeTableSelection,
    homeAccountsVars,
    homeAnalytics,
    homePeopleVars,
    listScope,
    membersToAudience,
    queryHomePeople,
    resolvedPeopleScope,
    scopeToAccountIds,
    scopeToPeopleOr,
    toast,
    useAudience,
    useHomeDeepLinkConsumer,
    useListResolutions,
  } = T;
  // useLists (features/home/lists/useLists.ts) is exported on T.Data.
  const useLists = T.useLists || T.Data.useLists;

  // GET_HOME_COUNTS → T.Data.useHomeCounts (badges query; same variables).

  /** Quiet window a table's row count must hold before `tab_viewed` reports it. */
  const TAB_VIEW_SETTLE_MS = 600;

  // Drawer navigation state - a small back-stack so signal ↔ person ↔ account
  // cross-nav returns to where it came from.
  // Detail = { type: 'signal', event, from? } | { type: 'person', person, from? }
  //        | { type: 'account', account, from? }

  function HomeInner({ scopeUserId, canFilterByUser }) {
    const [searchParams, setSearchParams] = useSearchParams();
    const navigate = useNavigate();
    // Each table is its own route (/user/home/signals|accounts|people), so every
    // view has a fixed URL and the sidebar links to it statically. An unknown
    // segment falls back to Signals rather than 404ing.
    const { view } = useParams();
    const tab = view === 'accounts' || view === 'people' ? view : 'signals';
    const setTab = (t) => {
      void navigate(`/user/home/${t}`, { replace: true });
    };
    const [detail, setDetail] = useState(null);
    // Open time of the current drawer, for the close event's dwell. A ref, not
    // state: it must not re-render anything, and it is written in the same tick
    // the drawer opens so a fast open→close still reports a real duration.
    const drawerOpenedAt = useRef(0);
    /** Drawer dismissed by the render-time tab reset, awaiting its close event. */
    const pendingTabClose = useRef(null);
    // True while the detail drawer plays its exit animation - un-shifts the
    // audience bar in sync with the close instead of after it.
    const [detailClosing, setDetailClosing] = useState(false);
    // The audience/message review flow overlays the card (mutually exclusive
    // with the row detail drawers). 'compose' never occurs in the port — the
    // ComposeDrawer is not ported (see header comment).
    const [overlay, setOverlay] = useState(null);
    // useComposeCampaign / useComposeReentry (the cohort's campaign row,
    // TRA-1311 / TRA-1437) are NOT ported — no campaign is ever minted here.

    const [signalCount, setSignalCount] = useState(undefined);
    const [accountCount, setAccountCount] = useState(undefined);
    const [peopleCount, setPeopleCount] = useState(undefined);
    const [bulkSelections, setBulkSelections] = useState({});
    const [selectionResetVersion, setSelectionResetVersion] = useState(0);
    const setBulkSelection = (table, selection) => {
      setBulkSelections((current) => {
        if (selection) return { ...current, [table]: selection };
        if (!current[table]) return current;
        const next = { ...current };
        delete next[table];
        return next;
      });
    };

    const fromLabel = (from) =>
      !from
        ? undefined
        : from.type === 'signal'
          ? 'Signal'
          : from.type === 'account'
            ? from.account.name
            : from.person.fullName;

    // The app's flag-off Outreach path (and today's back-from-composer path):
    // open the audience review overlay. Carries `audienceOpened`, and dismisses
    // any open row drawer first — `detail` and `overlay` are independent shells
    // with no mutual exclusion, and the audience bar stays clickable (shifted)
    // while a drawer is open — so without this the overlay opens ON TOP of a
    // live detail drawer, and `closeDetail` never emits its dwell/`drawer_closed`
    // event.
    const openAudience = () => {
      homeAnalytics.audienceOpened({
        audience_people: pb.res.counts.people,
        audience_accounts: pb.res.counts.accounts,
      });
      closeDetail();
      setDetailClosing(false);
      setOverlay('audience');
    };

    // ?compose=1 (TRA-1359): another page (Find) handed its audience off to the
    // composer. Strip the param and open the overlay once, on arrival. With the
    // ComposeDrawer not ported this takes the app's flag-off branch: audience
    // review instead of the compose stage.
    const composeParam = searchParams.get('compose');
    useEffect(() => {
      if (composeParam !== '1') return;
      setSearchParams(
        (prev) => {
          const next = new URLSearchParams(prev);
          next.delete('compose');
          return next;
        },
        { replace: true }
      );
      openAudience();
      // run once per arrival; handlers are stable per render
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [composeParam]);
    // Cross-nav helpers: opening a drawer pushes the current one as `from`.
    // Each open resets detailClosing: re-opening during a close animation must
    // not leave the flag stuck true (the audience bar would stay unshifted and
    // run under the drawer).
    //
    // Drawer analytics live here rather than inside each drawer: this is where
    // the back-stack is, so `from` and `depth` (a row open vs a signal → person →
    // account chain) are only knowable at this level. `depth` is what tells us
    // whether people actually explore across entities or bounce straight out.
    //
    // Emitted from the handler against the CURRENT `detail`, never from inside
    // the `setDetail` updater: React can run an updater more than once per commit
    // (StrictMode, or a re-render before the commit lands), which would duplicate
    // every open. These are click handlers, so `detail` is the stack the user
    // sees at the moment they click.
    //
    // `source` overrides the inferred one for opens that did NOT come from a
    // click on this page — today that is only the deep link, which arrives with
    // no drawer underneath it and would otherwise report itself as a row click.
    const noteDrawerOpen = (entity, entityId, source) => {
      let depth = 1;
      for (let node = detail; node; node = node.from ?? null) depth += 1;
      homeAnalytics.drawerOpened({
        entity,
        entity_id: entityId,
        from: source ?? detail?.type ?? 'row',
        depth,
      });
      drawerOpenedAt.current = Date.now();
    };
    const openSignal = (event, source) => {
      noteDrawerOpen('signal', event.id, source);
      setOverlay(null);
      setDetailClosing(false);
      setDetail((prev) => ({ type: 'signal', event, from: prev ?? undefined }));
    };
    const openPerson = (person, source) => {
      noteDrawerOpen('person', person.id, source);
      setOverlay(null);
      setDetailClosing(false);
      setDetail((prev) => ({ type: 'person', person, from: prev ?? undefined }));
    };
    const openAccount = (account, source) => {
      noteDrawerOpen('account', account.id, source);
      setOverlay(null);
      setDetailClosing(false);
      setDetail((prev) => ({ type: 'account', account, from: prev ?? undefined }));
    };

    // ?signal= / ?account= / ?person= (deep link from the daily report email,
    // Slack, HubSpot). The record is fetched by the hook; this consumes it once
    // it lands and strips the param. See use-home-deep-link (home-shared.jsx)
    // for why these are params rather than path segments, and why the URL is
    // not kept in sync afterwards.
    //
    // No flag gate here, unlike ?compose=1: reaching this component at all means
    // the redesign flag already resolved true (RedesignRoute), so there is no
    // closed-default to race.
    useHomeDeepLinkConsumer((target) => {
      if (target.type === 'signal') openSignal(target.event, 'url');
      else if (target.type === 'account') openAccount(target.account, 'url');
      else openPerson(target.person, 'url');
    });
    const goBack = () => {
      if (detail?.from) {
        // Leaving THIS drawer, so it gets its own close before the stack pops —
        // otherwise a signal → account chain produced two opens and one close,
        // and the account's dwell silently included the signal excursion.
        emitDrawerClosed(detail);
        homeAnalytics.drawerBack({ from: detail.type, to: detail.from.type });
        // Restart the clock for the drawer we are returning to, so its dwell is
        // the time actually spent looking at it after the trip.
        drawerOpenedAt.current = Date.now();
      }
      setDetail((prev) => prev?.from ?? null);
    };
    /** Emit `drawer_closed` for `d` and disarm the clock. Idempotent via
     *  `drawerOpenedAt`: one dismissal can reach here twice (the inner drawer's X
     *  AND the shell's post-animation `onClose`), and `detail` is still the
     *  pre-close value in that render, so a `detail`-only guard would double-emit. */
    const emitDrawerClosed = (d) => {
      if (!drawerOpenedAt.current) return;
      homeAnalytics.drawerClosed({
        entity: d.type,
        entity_id:
          d.type === 'signal'
            ? d.event.id
            : d.type === 'person'
              ? d.person.id
              : d.account.id,
        dwell_ms: Date.now() - drawerOpenedAt.current,
      });
      drawerOpenedAt.current = 0;
    };
    /** The one close path, so dwell is reported however the drawer was dismissed:
     *  X, backdrop, Escape, opening the audience overlay, or a tab switch. */
    const closeDetail = () => {
      if (detail) emitDrawerClosed(detail);
      setDetail(null);
    };

    // Switching source tabs closes any open drawer (mock: detail resets on tab
    // change; the audience/message overlay goes with it too). Render-time reset
    // keyed on the URL-derived tab so sidebar deep-links reset drawers the same
    // as in-page tab clicks.
    const [prevTab, setPrevTab] = useState(tab);
    if (prevTab !== tab) {
      setPrevTab(tab);
      // This reset runs DURING render, where an analytics call would be an unsafe
      // side effect (React may throw the render away, and StrictMode runs it
      // twice). So the drawer being dismissed is parked on a ref and the effect
      // below emits it after commit — without this, switching tabs with a drawer
      // open produced an open with no close, and `closeDetail`'s own docstring
      // claimed otherwise.
      if (detail) pendingTabClose.current = detail;
      setDetail(null);
      setDetailClosing(false);
      setOverlay(null);
    }
    useEffect(() => {
      const dismissed = pendingTabClose.current;
      if (!dismissed) return;
      pendingTabClose.current = null;
      emitDrawerClosed(dismissed);
    });

    // Focus filter: which list scopes the three tables. Held in the audience
    // context (above the router) so it follows the user around the app instead of
    // being dropped by any link that forgets to carry it — see AudienceApi.
    const pb = useAudience();
    const { lists, loading: listsLoading, refetch: refetchLists } = useLists();
    const focusedListId = pb.focusedListId;
    const activeList = focusedListId
      ? (lists.find((l) => l.id === focusedListId) ?? null)
      : null;
    // Focusing a list SCOPES the tables and nothing else: it does not tick its
    // members into the bar. A focus is what you are looking at, a selection is
    // what you picked, and TRA-1359 keeps them as two separate verbs. `loadList`
    // enforces that — it only acts for a focus set with `seedBar` (the import
    // hand-off), and re-running it is free, so members landing in chunks still
    // catch up.
    const prevListRef = useRef(focusedListId);
    useEffect(() => {
      const prev = prevListRef.current;
      prevListRef.current = focusedListId;
      if (activeList) {
        pb.loadList(activeList.id, membersToAudience(activeList.members));
        return;
      }
      // Focus was just cleared (had a list, now none): if the bar is the clean,
      // linked copy of that list, put it away too. Only a seeded focus can have
      // put it there (an import), and dropping the focus ends that hand-off, so
      // the "Saved" bar + checked rows should not outlive it. An EDITED bar is
      // kept — we never discard unsaved work.
      if (prev && !focusedListId && pb.linkedListId === prev && !pb.isDirty) {
        setSelectionResetVersion((version) => version + 1);
        // Passive: nobody asked to empty the bar, the focus just went away.
        pb.clear('unfocus');
      }
    }, [activeList, focusedListId, pb]);
    // Drop a focus whose list no longer exists — e.g. deleted from the Lists page,
    // or an import that created nothing and removed its own list. Without this the
    // tables would scope to a phantom and silently show the whole tenant.
    //
    // Unlike the old URL-based focus this can't race a freshly-set value: the
    // focus and the lists cache are both client state now, and a hand-off only
    // sets the focus AFTER refetching lists.
    useEffect(() => {
      if (!focusedListId || listsLoading) return;
      if (!lists.some((l) => l.id === focusedListId)) pb.setFocusedList(null);
    }, [focusedListId, listsLoading, lists, pb]);
    const scope = useMemo(
      () => (activeList ? listScope(activeList.members) : null),
      [activeList]
    );
    const activeListHasDynamicAccounts =
      activeList?.members.some(
        (member) => member.kind === 'account' && !member.excluded
      ) ?? false;
    const activeListResolutionInput = useMemo(
      () => (activeList ? [activeList] : []),
      [activeList]
    );
    const { byList: activeListResolutions, resolving: activeListResolving } =
      useListResolutions(activeListResolutionInput, activeListHasDynamicAccounts);
    // Accounts/signals tabs: account members ∪ pinned people's accounts. A list
    // that IS focused but has no account members yet (a freshly-created import
    // list mid-fill, or an empty list) scopes to an impossible id → ZERO rows,
    // never the whole tenant (mirrors peopleScopeOr; TRA-1359 instant-focus).
    const scopeAccountIds = useMemo(() => {
      if (!scope) return null;
      if (scope.accountIds.length) return scope.accountIds;
      return ['00000000-0000-0000-0000-000000000000'];
    }, [scope]);
    // People tab: explicit-person lists scope directly. Dynamic account members
    // scope to the SAME resolved Best/Top-N recipients shown by the list, never
    // every known coworker at those accounts.
    const peopleScopeOr = useMemo(() => {
      if (!scope) return null;
      if (activeList && activeListHasDynamicAccounts) {
        const resolved = activeListResolutions.get(activeList.id);
        if (activeListResolving || !resolved) return resolvedPeopleScope([]);
        return resolvedPeopleScope(
          resolved.recipients.map((recipient) => recipient.personId)
        );
      }
      return scopeToPeopleOr(scope);
    }, [
      activeList,
      activeListHasDynamicAccounts,
      activeListResolving,
      activeListResolutions,
      scope,
    ]);
    // Focusing narrows the tables and leaves the selection untouched, so there is
    // nothing to warn the user about and nothing to offer to replace. The
    // session-only bulk scopes still reset: they describe "everything matching
    // THIS view", which a new scope invalidates.
    const setActiveList = (id) => {
      homeAnalytics.listFocused({ list_id: id, table: tab });
      setBulkSelections({});
      pb.setFocusedList(id);
    };

    // One `tab_viewed` per table the user actually lands on, keyed on the tab
    // rather than the click: a sidebar deep-link, the ?from=onboarding arrival and
    // the post-import hand-off all switch tables without touching HomeTabs, and
    // all of them are views. It waits for the count so the event carries the size
    // of what was shown — a table viewed with 0 rows is a different fact from one
    // viewed with 400 — and the ref keeps it to one event per (table, focus).
    //
    // The count SETTLES rather than arriving: a tab reports 0 on its first render
    // and again with the real number once its query lands. Emitting on the first
    // defined value shipped `row_count: 0` for a 33-row table, so the event waits
    // out a quiet window and re-arms the timer on every change. The dedup key is
    // (table, focused list), so re-entering a tab after switching lists is a new
    // view but a count settling in place is not.
    const lastTabViewed = useRef('');
    // Only the ACTIVE table's count is a dependency. Depending on all three meant
    // any table's count change re-armed the timer, so while the Signals tab polled
    // during onboarding backfill it could postpone — or with a fast navigation,
    // lose — the Accounts view event, even though `rowCount` never reads it.
    const activeRowCount =
      tab === 'signals' ? signalCount : tab === 'accounts' ? accountCount : peopleCount;
    useEffect(() => {
      if (activeRowCount === undefined) return;
      const key = `${tab}:${focusedListId ?? ''}`;
      if (lastTabViewed.current === key) return;
      const t = setTimeout(() => {
        lastTabViewed.current = key;
        homeAnalytics.tabViewed({
          table: tab,
          row_count: activeRowCount,
          list_focused: focusedListId != null,
        });
      }, TAB_VIEW_SETTLE_MS);
      return () => clearTimeout(t);
    }, [tab, activeRowCount, focusedListId]);

    // Reset ("start over") from the bar. When a list is focused, the bar IS that
    // list, so a bare pb.clear() is instantly re-seeded from the focus and the
    // glyph looks dead. Drop the focus first (so the re-seed effect can't refill
    // the bar), then clear. This intentionally wipes even an EDITED focused bar —
    // an explicit reset click means "start over", unlike a passive unfocus which
    // preserves unsaved work. clear() sets EMPTY_AUDIENCE (linkedListId null), so
    // once the focus is gone nothing re-seeds. (TRA-1359 list-as-bar.)
    const resetAudience = () => {
      setSelectionResetVersion((version) => version + 1);
      clearHomeTableSelection({
        focusedListId,
        clearFocusedList: () => pb.setFocusedList(null),
        clearBulkSelection: () => setBulkSelections({}),
        clearAudience: () => pb.clear('reset'),
      });
    };

    // Clearing from inside a table (the header checkbox, the scope row's "Clear")
    // drops the SELECTION only: unchecking must not throw the user out of the list
    // they are looking at. The 'unselect' reason also ends any seeded hand-off, so
    // an emptied bar cannot be refilled a render later.
    const clearTableSelection = () => {
      setSelectionResetVersion((version) => version + 1);
      clearHomeTableSelection({
        focusedListId,
        keepFocusedList: true,
        clearFocusedList: () => pb.setFocusedList(null),
        clearBulkSelection: () => setBulkSelections({}),
        clearAudience: () => pb.clear('unselect'),
      });
    };

    // Just-onboarded live mode (?from=onboarding), ported from the newsfeed:
    // the discovery burst is still streaming events / people / enrichment into
    // the tenant, so the counts and the visible tab poll every couple of
    // seconds and a banner says discovery is running. Settled when the data has
    // been quiet for a grace window AND the loaded signals are enriched (their
    // suggested contacts and why-it-matters backfill late), with a hard cap so
    // a stalled pipeline can't poll forever. Then ?from is stripped (replace,
    // no reload) so a later refresh doesn't re-enter live mode.
    const LIVE_QUIET_MS = 45000;
    const LIVE_HARD_CAP_MS = 6 * 60 * 1000;
    const LIVE_POLL_MS = 2500;
    const [live, setLive] = useState(
      () => searchParams.get('from') === 'onboarding'
    );
    // The import modal now blocks until every row is written, so there is no
    // "rows trickling into a half-empty table" window to paper over any more. What
    // IS still in flight after the hand-off is the worker's tail: contact photos,
    // account logos, enrichment, discovery. We poll for exactly as long as that
    // job runs — no 120s guess, no 1s list re-fetch loop.
    const [streamingJobId, setStreamingJobId] = useState(null);
    const importStreaming = useImportJobRunning(streamingJobId);

    /**
     * The import's rows are all in and its list is complete. Focus that list AND
     * seed the bar from it — the one place a focus also selects — so the user
     * lands looking at exactly what they imported, ready to act on it. They
     * curated this set outside the app, so nothing is being guessed for them.
     */
    const onImportReady = async (info, focusTab = 'people') => {
      setStreamingJobId(info.jobId || null);
      if (!info.listId) return;
      const listId = info.listId;
      // Resolve the list BEFORE focusing it, so `activeList` lands the same tick
      // (no all-people flash while the list is unknown) and the dangling-focus
      // sweep can't fire against a lists cache that hasn't caught up.
      const listsResult = await refetchLists();

      // Warm the destination tab's FIRST PAGE before switching views. In the
      // app this is an apolloClient.query network-only prefetch that seeds the
      // tab's cache-and-network read; the mock readers are synchronous and
      // cache-less, so this is inert beyond keeping the hand-off's ordering.
      // Best-effort: a miss just means today's behavior.
      const fresh = (listsResult?.data?.lists ?? []).find((l) => l.id === listId);
      if (fresh) {
        const scope = listScope(fresh.members ?? []);
        try {
          if (focusTab === 'people') {
            await queryHomePeople(
              homePeopleVars({
                scopeOr: scopeToPeopleOr(scope),
                userId: scopeUserId,
              })
            );
          } else {
            T.Data.raw.filterAccounts(
              homeAccountsVars({
                ids: scopeToAccountIds(scope),
                userId: scopeUserId,
                withUserId: canFilterByUser,
              })
            );
          }
        } catch {
          /* best-effort warm-up; the tab still fetches on its own */
        }
      }
      pb.setFocusedList(listId, { seedBar: true });
      void navigate(`/user/home/${focusTab}`, { replace: true });
    };
    // Signals tab reports how complete its loaded rows are (events with
    // matched-signal/why data + suggested faces) so settle can wait for the
    // backfill, not just for event counts to stop growing.
    const [liveStats, setLiveStats] = useState({
      events: 0,
      enriched: 0,
      faces: 0,
    });
    const [mountedAt] = useState(() => Date.now());
    const lastChangeRef = useRef(0);
    const lastSigRef = useRef('');

    // Badges come from the SAME server filters the tabs query with, so a
    // focused list can never show a count the rows disagree with.
    const {
      data: countsData,
      refetch: refetchCounts,
      startPolling: startCountsPolling,
      stopPolling: stopCountsPolling,
    } = T.Data.useHomeCounts({
      userId: scopeUserId,
      accountIds: scopeAccountIds ?? undefined,
      ids: scopeAccountIds ?? undefined,
      peopleScopeOr: peopleScopeOr ?? undefined,
    });
    // TRA-1359: accounts with enrichment/initial discovery still running keep
    // the whole page live (all three tabs poll) until the last one settles, not
    // just the onboarding burst. Derived straight from the counts query; polling
    // is toggled below since the flag feeds the SAME query's own poll cadence.
    // NB: this drives POLLING only — not the banner. A single manually-added
    // account also flips this true while its contacts enrich, but its row is
    // already on screen (only cells fill), so "new … appear here automatically"
    // would be misleading. The banner is gated on `live` (the onboarding burst)
    // alone, where genuinely-new rows do stream in.
    const discovering = (countsData?.discoveringAccountsCount ?? 0) > 0;
    useEffect(() => {
      if (live || discovering || importStreaming)
        startCountsPolling(LIVE_POLL_MS);
      else stopCountsPolling();
      return () => stopCountsPolling();
    }, [
      live,
      discovering,
      importStreaming,
      startCountsPolling,
      stopCountsPolling,
      LIVE_POLL_MS,
    ]);

    // Steady state is 0 and nothing polls; an add on THIS page
    // (beyond-your-accounts) fires the workflow-job event → re-check now.
    useEffect(() => {
      const onJobStarted = () => void refetchCounts();
      window.addEventListener(WORKFLOW_JOB_STARTED_EVENT, onJobStarted);
      return () =>
        window.removeEventListener(WORKFLOW_JOB_STARTED_EVENT, onJobStarted);
    }, [refetchCounts]);

    // The BACKGROUNDED path: the user dismissed the blocking modal with "continue
    // in background" (or the import was started elsewhere), so nobody has focused
    // its list yet. When it lands while Home is mounted, do the same hand-off the
    // modal would have done. On the normal path this is a no-op re-focus of the
    // list we already switched to. Focusing the list re-scopes the tab's query
    // (cache-and-network) so the new rows fetch; refetchLists makes the new list
    // resolvable in the chip + scope. We also switch to the matching tab so the
    // created entities are actually in view.
    useEffect(() => {
      const onJobCompleted = (e) => {
        const detail = e.detail;
        if (!detail) return;
        const isPeople = detail.jobKind === 'people-import';
        const isAccounts = detail.jobKind === 'account-import';
        if (!isPeople && !isAccounts) return;
        // Only a successful import has rows to fetch and a list to focus; a
        // failed/cancelled one stops here.
        if (detail.status !== 'completed') return;
        // Always refetch lists on an import completion: on the normal path it
        // makes the just-filled list resolvable before we focus it, and on the
        // ZERO-created path (the worker deleted its empty list, so importListId
        // is null here) it drops the dead list from the cache.
        void refetchLists();
        if (!detail.importListId) return;
        pb.setFocusedList(detail.importListId);
        void navigate(`/user/home/${isPeople ? 'people' : 'accounts'}`, {
          replace: true,
        });
      };
      window.addEventListener(WORKFLOW_JOB_COMPLETED_EVENT, onJobCompleted);
      return () =>
        window.removeEventListener(WORKFLOW_JOB_COMPLETED_EVENT, onJobCompleted);
    }, [refetchLists, navigate, pb]);

    useEffect(() => {
      if (!live) return;
      const sig = JSON.stringify([
        countsData?.eventsCount,
        countsData?.accountsCount,
        countsData?.peopleCount,
        liveStats,
      ]);
      if (sig !== lastSigRef.current) {
        lastSigRef.current = sig;
        lastChangeRef.current = Date.now();
      }
      const t = setInterval(() => {
        const now = Date.now();
        const quiet = now - lastChangeRef.current >= LIVE_QUIET_MS;
        const enriched =
          liveStats.events > 0 && liveStats.enriched >= liveStats.events;
        const settled =
          (quiet && enriched) || now - mountedAt >= LIVE_HARD_CAP_MS;
        if (!settled) return;
        setLive(false);
        setSearchParams(
          (prev) => {
            const next = new URLSearchParams(prev);
            next.delete('from');
            return next;
          },
          { replace: true }
        );
      }, 2000);
      return () => clearInterval(t);
    }, [
      live,
      countsData,
      liveStats,
      mountedAt,
      setSearchParams,
      LIVE_QUIET_MS,
      LIVE_HARD_CAP_MS,
    ]);

    const counts = useMemo(
      () => ({
        signals: countsData?.eventsCount ?? signalCount,
        accounts: countsData?.accountsCount ?? accountCount,
        people: countsData?.peopleCount ?? peopleCount,
      }),
      [countsData, signalCount, accountCount, peopleCount]
    );

    const openEventId = detail?.type === 'signal' ? detail.event.id : null;
    const openAccountId = detail?.type === 'account' ? detail.account.id : null;
    const openPersonId = detail?.type === 'person' ? detail.person.id : null;

    return (
      <PageContainer width="page" className="flex h-full min-h-0 flex-col">
        <div className="mb-4 flex items-baseline justify-between gap-3">
          <div className="htbl-head-row">
            {/* Mobile nav opener. This page hand-rolls its title row instead of
                using the shared PageHeader, so it must render the burger itself
                — the shell only provides the opener via context. `-my-1
                self-center` matches PageHeader: it pulls the 38px control's
                margin box back onto the title's line so both centers coincide
                in this baseline-aligned row. Not `float-left` (the pattern on
                EventDetail/AccountDetail): this page is a full-height flex
                column, which gets pushed below a float instead of beside it. */}
            <MobileSidebarTrigger className="-my-1 self-center" />
            <h1 className="htbl-h1">Home</h1>
            <span className="htbl-sub">
              {activeList ? (
                <React.Fragment>
                  Showing <b className="text-text-primary">{activeList.name}</b>{' '}
                  across signals, accounts, and people.
                </React.Fragment>
              ) : (
                <React.Fragment>
                  Pick from signals, accounts, or people.
                  {/* Second sentence only once there is room for it. Below this
                      it wrapped onto a line of its own, spending 24px on copy the
                      tabs directly beneath already convey.

                      `max-sm` (640px) rather than a bound fitted to the measured
                      string width. Body text is the SYSTEM stack (tokens.css), so
                      the sentence is a different width on every OS: a bound of
                      544 was measured on macOS/SF Pro and still wrapped in CI's
                      Linux container, where the same string is wider. There is no
                      single correct pixel here, so the bound takes the standard
                      breakpoint plus enough headroom to survive the widest system
                      font. Erring early is nearly free — this is supplementary
                      copy — while erring late is the two-line wrap being fixed.

                      Note it is NOT the 480px gutter tier and must not be tidied
                      into matching it: by 524px the gutter is already back to
                      16px and the sentence still does not fit. */}
                  <span className="max-sm:hidden">
                    {' '}
                    Your list builds as you go.
                  </span>
                </React.Fragment>
              )}
            </span>
          </div>
        </div>

        <HomeTabs value={tab} onChange={setTab} counts={counts} />

        <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden htbl-card">
          {live && (
            <div
              className="htbl-live-banner"
              data-testid="home-onboarding-discovering"
            >
              <Loader2 className="size-3.5 animate-spin" />
              Discovery is still running. New signals, accounts, and contacts
              appear here automatically.
            </div>
          )}
          {tab === 'signals' && (
            <SignalsTab
              key="signals"
              scopeUserId={scopeUserId}
              scopeAccountIds={scopeAccountIds}
              onOpenSignal={openSignal}
              openEventId={openEventId}
              onCountChange={setSignalCount}
              pollMs={live || discovering || importStreaming ? LIVE_POLL_MS : 0}
              onLiveStats={setLiveStats}
              bulkSelection={bulkSelections.signals ?? null}
              onBulkSelectionChange={(selection) =>
                setBulkSelection('signals', selection)
              }
              onClearSelection={clearTableSelection}
              selectionResetVersion={selectionResetVersion}
              toolbarExtra={
                <ListFilterChip value={focusedListId} onChange={setActiveList} />
              }
            />
          )}
          {tab === 'accounts' && (
            <AccountsTab
              key="accounts"
              scopeUserId={scopeUserId}
              canFilterByUser={canFilterByUser}
              scopeAccountIds={scopeAccountIds}
              onOpenAccount={openAccount}
              openAccountId={openAccountId}
              onCountChange={setAccountCount}
              pollMs={live || discovering || importStreaming ? LIVE_POLL_MS : 0}
              onImportReady={(info) => onImportReady(info, 'accounts')}
              bulkSelection={bulkSelections.accounts ?? null}
              onBulkSelectionChange={(selection) =>
                setBulkSelection('accounts', selection)
              }
              onClearSelection={clearTableSelection}
              selectionResetVersion={selectionResetVersion}
              toolbarExtra={
                <ListFilterChip value={focusedListId} onChange={setActiveList} />
              }
            />
          )}
          {tab === 'people' && (
            <PeopleTab
              key="people"
              scopeUserId={scopeUserId}
              scopeOr={peopleScopeOr}
              focusedListName={activeList?.name ?? null}
              onImportReady={(info) => onImportReady(info)}
              pollMs={live || discovering || importStreaming ? LIVE_POLL_MS : 0}
              onOpenPerson={(row) =>
                openPerson({
                  id: row.id,
                  fullName: row.fullName,
                  title: row.title,
                  profileImageUrl: row.profileImageUrl,
                })
              }
              openPersonId={openPersonId}
              onCountChange={setPeopleCount}
              bulkSelection={bulkSelections.people ?? null}
              onBulkSelectionChange={(selection) =>
                setBulkSelection('people', selection)
              }
              onClearSelection={clearTableSelection}
              selectionResetVersion={selectionResetVersion}
              toolbarExtra={
                <ListFilterChip value={focusedListId} onChange={setActiveList} />
              }
            />
          )}

          {/* One persistent shell for the whole detail session: cross-nav
              (signal ↔ person ↔ account) swaps the BODY, so the scrim never
              blinks and the panel animates only on real open/close. */}
          {detail && (
            <DrawerShell
              key="detail"
              // Prototype divergence: the row drawers meet the right edge of
              // the SCREEN rather than the table card's. Both Home shells take
              // it, so the page never mixes the two behaviors.
              screen
              label={
                detail.type === 'signal'
                  ? 'Signal'
                  : detail.type === 'person'
                    ? detail.person.fullName
                    : detail.account.name
              }
              onCloseStart={() => setDetailClosing(true)}
              onClose={() => {
                closeDetail();
                setDetailClosing(false);
              }}
            >
              {detail.type === 'signal' && (
                <SignalDrawer
                  event={detail.event}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenPerson={(person) => openPerson(person)}
                  onOpenAccount={(acct) =>
                    openAccount({
                      id: acct.id,
                      name: acct.name,
                      url: acct.url ?? null,
                      logoUrl: acct.logoUrl ?? null,
                      oneLiner: null,
                      employeeCount: null,
                      annualRevenueFrom: null,
                      annualRevenueTo: null,
                      ownership: null,
                      hqLocation: null,
                      timingScore: null,
                      createdAt: '',
                    })
                  }
                />
              )}
              {detail.type === 'person' && (
                <PersonDrawer
                  person={detail.person}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenSignal={openSignal}
                  onOpenAccount={(acct) =>
                    openAccount({
                      id: acct.id,
                      name: acct.name,
                      url: acct.url ?? null,
                      logoUrl: acct.logoUrl ?? null,
                      oneLiner: null,
                      employeeCount: null,
                      annualRevenueFrom: null,
                      annualRevenueTo: null,
                      ownership: null,
                      hqLocation: null,
                      timingScore: null,
                      createdAt: '',
                    })
                  }
                />
              )}
              {detail.type === 'account' && (
                <AccountDrawer
                  account={detail.account}
                  onClose={closeDetail}
                  onBack={detail.from ? goBack : undefined}
                  backLabel={fromLabel(detail.from)}
                  onOpenSignal={openSignal}
                  onOpenPerson={(person) => openPerson(person)}
                />
              )}
            </DrawerShell>
          )}

          {/* The audience ↔ message back-stack shares one shell too. The
              'compose' stage is not ported, so the shell only ever hosts the
              audience review; label/wide keep the app's expressions. */}
          {overlay && (
            <DrawerShell
              key="overlay"
              screen
              label={overlay === 'audience' ? 'List' : 'Compose'}
              wide={overlay === 'compose'}
              onClose={() => setOverlay(null)}
            >
              {overlay === 'audience' && (
                <AudienceDrawer
                  onClose={() => setOverlay(null)}
                  // ComposeDrawer is not ported: the Outreach CTA keeps the
                  // app's flag-off behavior (close the overlay), with the
                  // composer hand-off itself replaced by the "coming next"
                  // toast the flag-off era used.
                  onStart={() => {
                    setOverlay(null);
                    toast('The sequence composer is not part of this prototype.');
                  }}
                  // The drawer's Clear all is the bar's reset glyph in another
                  // place: a bare pb.clear() with a list focused is instantly
                  // re-seeded by the focus effect, so it must drop the focus
                  // first. Passing this also keeps Home's clear-and-close overlay
                  // behavior — without it the drawer would offer the Lists
                  // editor's Reset instead, which on Home would revert the bar to
                  // a focused list's members rather than emptying it (TRA-1443).
                  onClearAll={resetAudience}
                />
              )}
            </DrawerShell>
          )}

          {/* Bar stays visible with the detail drawers (shifted left); the
              audience/message overlay hides it. */}
          {!overlay && (
            <AudienceBar
              /* In the app Outreach goes straight to the composer; with the
                 composer not ported this keeps the flag-off path — the audience
                 review overlay — so the audience stays editable. */
              onReview={openAudience}
              onReset={resetAudience}
              shifted={!!detail && !detailClosing}
            />
          )}
        </div>
      </PageContainer>
    );
  }

  function UserHome() {
    T.useRegisterPageContext({ route: '/user/home', entity: null });
    // useContextValidation / usePermissions are auth surfaces (CONVENTIONS
    // rule 8): the fixture tenant+user always satisfy hasRequiredContext, so
    // the Loading screen and ContextRequiredWarning branches are unreachable,
    // and the fixture user is an admin → `has('users:read')` is true.
    const canFilterByUser = true;

    // Non-privileged users are scoped to their own accounts/events (mirrors the
    // newsfeed); admins/managers see the whole tenant. The AudienceProvider lives
    // at the route shell (MainLayout) so a loaded list survives cross-page nav.
    const scopeUserId = undefined;

    return (
      <HomeInner scopeUserId={scopeUserId} canFilterByUser={canFilterByUser} />
    );
  }

  Object.assign(window.T, { UserHome });
})();
