/* Ported from apps/web/src/features/home/people/PeopleTab.tsx
 * (+ the tab-owned bits of people/people-query.ts: homePeopleVars and
 * HOME_PEOPLE_PAGE_SIZE, which UserHome's import hand-off prefetch shares). */
(() => {
  const T = window.T;
  const { useCallback, useEffect, useMemo, useRef, useState } = React;
  const {
    AccountMultiSelect,
    AddContactsDialog,
    Button,
    LogoAvatar,
    PersonAvatar,
    TableScrollRegion,
    getInitials,
    useAccountFilterOptions,
  } = T.UI;
  const { ChevronDown, Loader2, Plus } = T.Icons;

  // Apollo's NetworkStatus.setVariables. The mock envelope always reports
  // networkStatus 7 (ready), so `refetching` stays false — the mock resolves
  // synchronously and there is no in-flight refetch window to indicate.
  const NetworkStatus = { setVariables: 2 };

  /* ================= people-query.ts (tab-owned pieces) ================= */

  /** First-page size for the Home People tab. */
  const HOME_PEOPLE_PAGE_SIZE = 50;

  /**
   * Variables for the Home People tab's first page. Shared by the tab and by
   * the import hand-off's prefetch — they must match EXACTLY or the prefetch
   * writes a different cache entry than the tab reads (the flash).
   */
  function homePeopleVars(opts) {
    return {
      scopeOr: opts.scopeOr ?? undefined,
      userId: opts.userId,
      accountIds: opts.accountIds?.length ? opts.accountIds : undefined,
      search: opts.search || undefined,
      // Default: what happened most recently, which is the reason to open the
      // tab at all. It is deliberately NOT one of the sortable columns, so the
      // header row starts with no arrow until the user picks a column.
      sortBy: opts.sortBy ?? 'recentActivity',
      sortDir: opts.sortDir ?? 'desc',
      skip: 0,
      take: HOME_PEOPLE_PAGE_SIZE,
    };
  }

  /** apolloClient.query({ query: GET_HOME_PEOPLE, variables }) stand-in:
   *  a synchronous mock read wrapped in a promise so loadMore / bulk selection
   *  keep their async shape (fetchPolicy 'network-only' /
   *  HOME_TABLE_BULK_FETCH_POLICY are meaningless without a cache). */
  async function queryHomePeople(variables) {
    const rows = T.Data.raw.filterPeople(variables);
    const skip = variables.skip || 0;
    const take = variables.take != null ? variables.take : rows.length;
    return { data: { people: rows.slice(skip, skip + take) } };
  }

  /* ================= PeopleTab.tsx ================= */

  /** Stable identity so the `?? []` fallback doesn't invalidate downstream memos. */
  const EMPTY_BEYOND = [];

  /** Names read A→Z; "Last outreach" opens most-recent-first. */
  const PEOPLE_ASC_FIRST = ['fullName', 'title', 'account'];

  function PeopleTab({
    scopeUserId,
    scopeOr,
    focusedListName,
    onImportReady,
    onOpenPerson,
    openPersonId,
    onCountChange,
    pollMs = 0,
    toolbarExtra,
    bulkSelection,
    onBulkSelectionChange,
    onClearSelection,
    selectionResetVersion = 0,
  }) {
    const {
      countSelectedMatchingIds,
      fetchAllMatchingRows,
      hasAudienceSelection,
      homeTableBulkSelectionState,
      homeTableLoadedSelectionState,
      peopleToAudienceBatch,
      shouldShowHomeTableSelectionScope,
      HomeSelectCell,
      HomeTableResultCount,
      HomeTableSearch,
      HomeTableSelectAllCheckbox,
      HomeTableSelectionScopeRow,
      HomeTableFilteredSelectionNoticeRow,
      HomeTableSortTh,
      nextSortDir,
      ExternalGroupBand,
      HomeSearchSplitCount,
      PEOPLE_SEARCH_LIMIT,
      SearchInFindLink,
      useExternalBandOffset,
      useWorkingSetPin,
      cn,
      homeAnalytics,
      toast,
    } = T;
    const pb = T.useAudience();
    // Seeded from `?q=` so one table can hand a search to another.
    const [query, setQuery] = T.useSeededSearch();
    const debouncedQuery = T.useDebouncedValue(query, 250);
    const [companyFilter, setCompanyFilter] = useState([]);
    // Opens on recent activity, which is not one of the columns, so no header
    // reads as active until the user sorts by one.
    const [sortField, setSortField] = useState('recentActivity');
    const [sortDir, setSortDir] = useState('desc');

    const toggleSort = (field) => {
      const nextDir = nextSortDir({
        field,
        sortField,
        sortDir,
        ascFirst: PEOPLE_ASC_FIRST,
      });
      homeAnalytics.sortChanged({
        table: 'people',
        column: field,
        direction: nextDir,
      });
      setSortField(field);
      setSortDir(nextDir);
    };
    // TRA-1359: the redesign routes People to this Home tab (the legacy
    // /user/people page is off the redesign nav), so the bulk contact CSV import
    // entry point lives here. Web omits tenantId → imports into the caller's own
    // tenant. The modal now BLOCKS on its own progress step until every contact
    // exists, so this tab never has to render a half-filled import: by the time
    // `onImportReady` fires, the rows and the list are complete.
    const [importOpen, setImportOpen] = useState(false);
    // Open time of the contacts importer, so the ready hand-off can report how
    // long the user actually waited.
    const importOpenedAt = useRef(0);
    /** Job queued but rows not yet ready — a close in this window is an escape. */
    const importSubmittedRef = useRef(false);
    useEffect(() => {
      if (!importOpen) return;
      importOpenedAt.current = Date.now();
      homeAnalytics.importOpened({ kind: 'contacts' });
    }, [importOpen]);

    // Built by the SAME helper the import hand-off prefetches with, so the two
    // hit the same cache entry (a mismatch would reintroduce the stale-rows flash).
    const queryVars = useMemo(
      () =>
        homePeopleVars({
          scopeOr,
          userId: scopeUserId,
          accountIds: companyFilter,
          search: debouncedQuery,
          sortBy: sortField,
          sortDir,
        }),
      [scopeOr, scopeUserId, companyFilter, debouncedQuery, sortField, sortDir]
    );
    const baseVars = useMemo(() => {
      const { skip: _skip, take: _take, ...rest } = queryVars;
      return rest;
    }, [queryVars]);
    // App: useQuery(GET_HOME_PEOPLE, { fetchPolicy: 'cache-and-network',
    // pollInterval: pollMs }). Mock hooks recompute reactively on data
    // mutations, so the host's poll cadence is unnecessary here.
    const {
      data: dataRaw,
      previousData,
      loading,
      refetch,
      networkStatus,
    } = T.Data.useHomePeople(queryVars);

    // Soft "N of M" chip inside search — only when search/account filter narrows below scope baseline.
    const filtersActive = Boolean(
      debouncedQuery.trim() || companyFilter.length > 0
    );
    const peopleCountVars = useMemo(
      () => ({
        scopeOr: scopeOr ?? undefined,
        userId: scopeUserId,
        ...(companyFilter.length ? { accountIds: companyFilter } : {}),
        ...(debouncedQuery ? { search: debouncedQuery } : {}),
      }),
      [scopeOr, scopeUserId, companyFilter, debouncedQuery]
    );
    const peopleBaselineVars = useMemo(
      () => ({
        scopeOr: scopeOr ?? undefined,
        userId: scopeUserId,
      }),
      [scopeOr, scopeUserId]
    );
    const { data: peopleCountData, previousData: peopleCountPrev } =
      T.Data.useHomePeopleTotal(peopleCountVars);
    // App passes `skip: !filtersActive`; the mock hook is synchronous and
    // cheap, so it always runs and the derived reads below stay identical.
    const { data: peopleBaselineData, previousData: peopleBaselinePrev } =
      T.Data.useHomePeopleTotal(peopleBaselineVars);
    const peopleCountResolved = peopleCountData ?? peopleCountPrev;
    const peopleMatchCount = peopleCountResolved?.peopleCount ?? 0;
    const selectablePeopleMatchCount =
      peopleCountResolved?.selectablePeopleCount ?? 0;
    const resultBaseline = filtersActive
      ? (peopleBaselineData ?? peopleBaselinePrev)?.peopleCount
      : undefined;
    const resultCount =
      filtersActive && peopleCountResolved !== undefined
        ? peopleMatchCount
        : undefined;
    const showResultCount =
      resultCount !== undefined &&
      resultBaseline !== undefined &&
      resultBaseline > resultCount;

    // The import uploads avatars in a follow-up step, so the LAST photos land
    // right before the job completes — possibly after the final poll tick. Force
    // one refetch on completion so those trailing photos refresh in place instead
    // of sticking on initials until a manual reload.
    useEffect(() => {
      const onCompleted = (e) => {
        const detail = e.detail;
        if (detail?.jobKind === 'people-import') void refetch();
      };
      window.addEventListener(T.UI.WORKFLOW_JOB_COMPLETED_EVENT, onCompleted);
      return () =>
        window.removeEventListener(
          T.UI.WORKFLOW_JOB_COMPLETED_EVENT,
          onCompleted
        );
    }, [refetch]);
    // previousData keeps rows painted while a keystroke's refetch is in flight —
    // but ONLY within the same list scope. Reusing it across a scope change paints
    // the PREVIOUS list's contacts under the new list's filter, which is what made
    // an import hand-off flash the old table before swapping to the new one.
    const listScopeKey = useMemo(
      () => JSON.stringify(scopeOr ?? null),
      [scopeOr]
    );
    const lastPaintedScope = useRef(listScopeKey);
    if (dataRaw) lastPaintedScope.current = listScopeKey;
    const data =
      dataRaw ??
      (lastPaintedScope.current === listScopeKey ? previousData : undefined);
    const firstPage = useMemo(() => data?.people ?? [], [data]);

    // ── "Beyond your people" (TRA-1382): fuzzy CoreSignal name search for people
    // NOT yet in the tenant — the People-table twin of the accounts "Beyond your
    // accounts" fallback. Suppressed while scoped to a list. Adding one follows
    // the CSV-import path: create the account if needed (ensureContactAccount),
    // create the person linked to it (createPerson), then drop into the bar.
    const beyondActive = !scopeOr && debouncedQuery.trim().length >= 3;
    // App passes `skip: !beyondActive`; the mock hook always runs, so the
    // active gate is applied to the result below instead.
    const { data: beyondData, loading: beyondLoading } =
      T.Data.useSearchCoresignalPeople({
        query: debouncedQuery.trim(),
        limit: PEOPLE_SEARCH_LIMIT,
      });
    // Memoised: `?? []` minted a new array identity every render, so every memo
    // downstream of it (visibleBeyond) recomputed on every render.
    const beyond = useMemo(
      () =>
        (beyondActive
          ? beyondData?.searchCoresignalPeopleByName
          : undefined) ?? EMPTY_BEYOND,
      [beyondData, beyondActive]
    );
    // employeeIds whose add-click fired but whose create hasn't settled — the row
    // paints a spinner in the checkbox slot immediately so the click feels instant.
    const [pendingAdds, setPendingAdds] = useState(() => new Set());
    /** Same set, synchronously — see the guard in addBeyondPerson. */
    const inFlightAdds = useRef(new Set());
    // Rows created in this session, held locally until the server list catches up.
    //
    // Without this the created person only reaches the table via the pin query's
    // round-trip, so the row vanished from the directory group and reappeared
    // above a beat later — long enough to read as a glitch, and long enough for
    // the selection count to disagree with the checkboxes. Keyed by employeeId so
    // the directory row can be hidden in the same commit that adds the owned one.
    //
    // Each entry records the SEARCH SCOPE it was created under. A local row is a
    // bridge across one query's round-trip, and the "drop it once the server
    // returns the same id" check can only fire while that query is still the one
    // running — so under a different query the row was immortal: create someone
    // while searching "chukwu", search "okonkwo", and the chukwu contact still
    // sat at the top of the results. Scoping also bounds the list, which
    // otherwise grew for the lifetime of the tab.
    const [created, setCreated] = useState([]);
    const createdEmployeeIds = useMemo(
      () => new Set(created.map((c) => c.employeeId)),
      [created]
    );
    /** Directory rows still worth showing — one we just created is now owned, and
     *  showing it in both groups is exactly the confusion this work is fixing. */
    const visibleBeyond = useMemo(
      () => beyond.filter((c) => !createdEmployeeIds.has(c.employeeId)),
      [beyond, createdEmployeeIds]
    );

    const addBeyondPerson = useCallback(
      async (c) => {
        // Silent before: the spinner cleared and the checkbox reverted with no
        // explanation, which reads as a dead click.
        if (!c.accountName) {
          toast.error(
            `${c.name} has no company on record, so they can’t be added.`
          );
          return;
        }
        // Synchronous re-entrancy guard. `pendingAdds` is state, so two calls in
        // the same tick both read the pre-update value and both create — belt and
        // braces alongside the click fix, because the failure mode here is a
        // duplicate CONTACT in the customer's workspace, not a wasted render.
        if (inFlightAdds.current.has(c.employeeId)) return;
        inFlightAdds.current.add(c.employeeId);
        setPendingAdds((s) => new Set(s).add(c.employeeId));
        try {
          // App: useMutation(ENSURE_CONTACT_ACCOUNT) → ensureContactAccount.
          const accountId = await T.Data.ensureContactAccount(
            c.accountName,
            c.accountDomain
          );
          if (!accountId) {
            homeAnalytics.entityCreated({
              entity: 'person',
              via: 'people-beyond',
              ok: false,
            });
            toast.error(`Couldn’t add ${c.name}. Try again.`);
            return;
          }
          const handle =
            c.linkedinUrl.split('/in/')[1]?.replace(/\/+$/, '') || undefined;
          // App: useMutation(CREATE_PERSON) → createPerson.
          const person = await T.Data.createPerson({
            accountId,
            fullName: c.name,
            title: c.title ?? undefined,
            profileImageUrl: c.profilePictureUrl ?? undefined,
            linkedinUsername: handle,
          });
          // Creating a contact into the tenant is the paid half of "add"; adding
          // an existing one to the audience is not the same act, and only the
          // latter was recorded.
          homeAnalytics.entityCreated({
            entity: 'person',
            via: 'people-beyond',
            ok: !!person,
          });
          if (!person) {
            toast.error(`Couldn’t add ${c.name}. Try again.`);
            return;
          }
          // One commit: the directory row disappears and the owned row appears,
          // already checked (the checkbox reads `pb.hasPerson`, and the add below
          // makes that true). No refetch in between, so the selection count never
          // disagrees with what is on screen.
          const createdUnderScope = JSON.stringify(baseVars);
          setCreated((prev) => [
            {
              employeeId: c.employeeId,
              scopeKey: createdUnderScope,
              row: {
                id: person.id,
                fullName: person.fullName,
                title: person.title ?? null,
                profileImageUrl: person.profileImageUrl ?? null,
                accountId,
                email: null,
                deliverableEmail: null,
                deliverablePhone: null,
                enrichmentState: null,
                lastContactedAt: null,
                contactedByMe: null,
                createdAt: new Date().toISOString(),
                account: person.account
                  ? {
                      id: accountId,
                      name: person.account.name,
                      url: null,
                      logoUrl: person.account.logoUrl ?? null,
                    }
                  : null,
              },
            },
            // Kept across scopes on purpose. The RENDER filter is scoped (see
            // rawPeople), but `createdEmployeeIds` is not: the CoreSignal search
            // has no idea what this tenant owns, so someone created earlier can
            // come back as a directory hit under a later query, and suppressing
            // that is the whole point of this work. The list is bounded by how
            // many contacts one session creates.
            ...prev,
          ]);
          pb.addPerson(
            accountId,
            person.id,
            'people-beyond',
            undefined,
            person.account
              ? {
                  name: person.account.name,
                  url: null,
                  logoUrl: person.account.logoUrl ?? null,
                }
              : undefined,
            {
              fullName: person.fullName,
              title: person.title,
              profileImageUrl: person.profileImageUrl,
            }
          );
          // Background reconcile only — the row is already on screen, so this
          // just lets the server copy take over when it lands.
          void refetch();
        } catch {
          // Parity with AccountsTab: without this the create funnel showed only
          // successes on the people side, so ok:false rates weren't comparable.
          homeAnalytics.entityCreated({
            entity: 'person',
            via: 'people-beyond',
            ok: false,
          });
          toast.error(`Couldn’t add ${c.name}. Try again.`);
        } finally {
          inFlightAdds.current.delete(c.employeeId);
          setPendingAdds((s) => {
            const n = new Set(s);
            n.delete(c.employeeId);
            return n;
          });
        }
      },
      [baseVars, pb, refetch]
    );

    // Appended pages live outside the reactive first page; reset on scope change.
    const [extra, setExtra] = useState([]);
    const [hasMore, setHasMore] = useState(true);
    const [loadingMore, setLoadingMore] = useState(false);
    const [bulkSelectionBusy, setBulkSelectionBusy] = useState(false);
    const [bulkSelectionProgress, setBulkSelectionProgress] = useState(0);
    const [loadedSelection, setLoadedSelection] = useState(null);
    const bulkRequest = useRef(null);
    const bulkRequestId = useRef(0);
    const scopeKey = JSON.stringify(baseVars);
    const selectionContextKey = useMemo(() => {
      const { accountIds: _accountIds, ...context } = baseVars;
      return JSON.stringify(context);
    }, [baseVars]);
    const bulkSelectionState = homeTableBulkSelectionState(
      bulkSelection,
      scopeKey,
      selectionContextKey
    );
    const activeBulkSelection =
      bulkSelectionState === 'active' ? bulkSelection : null;
    const parkedBulkSelection =
      bulkSelectionState === 'parked' ? bulkSelection : null;
    const loadedSelectionState = homeTableLoadedSelectionState(
      loadedSelection?.scopeKey ?? null,
      scopeKey,
      companyFilter.length > 0
    );
    const loadedSelectionActive = loadedSelectionState === 'active';
    const parkedLoadedSelection = loadedSelectionState === 'parked';
    useEffect(() => {
      bulkRequest.current?.controller.abort();
      bulkRequestId.current += 1;
      bulkRequest.current = null;
      setBulkSelectionBusy(false);
      setBulkSelectionProgress(0);
      setExtra([]);
      setHasMore(true);
      if (bulkSelectionState === 'stale') onBulkSelectionChange(null);
      // eslint-disable-next-line react-hooks/exhaustive-deps -- the two keys fully describe exact and surrounding server selection identity
    }, [scopeKey, selectionContextKey]);
    useEffect(() => {
      setLoadedSelection(null);
    }, [selectionContextKey]);
    useEffect(
      () => () => {
        bulkRequest.current?.controller.abort();
        bulkRequestId.current += 1;
      },
      []
    );
    useEffect(() => {
      bulkRequest.current?.controller.abort();
      bulkRequestId.current += 1;
      bulkRequest.current = null;
      setBulkSelectionBusy(false);
      setBulkSelectionProgress(0);
      setLoadedSelection(null);
    }, [selectionResetVersion]);
    const invalidateBulkSelection = useCallback(() => {
      bulkRequest.current?.controller.abort();
      bulkRequestId.current += 1;
      bulkRequest.current = null;
      setBulkSelectionBusy(false);
      setBulkSelectionProgress(0);
      setLoadedSelection(null);
      if (bulkSelection) onBulkSelectionChange(null);
    }, [bulkSelection, onBulkSelectionChange]);
    const updateCompanyFilter = useCallback((value) => {
      // The account filter is an inspection scope: stop any in-flight expansion
      // immediately, but keep the last completed bulk cursor parked so returning
      // to its exact account view can resume from the same place.
      bulkRequest.current?.controller.abort();
      bulkRequestId.current += 1;
      bulkRequest.current = null;
      setBulkSelectionBusy(false);
      setBulkSelectionProgress(0);
      setCompanyFilter(value);
    }, []);

    // ── Working set pinned on top. The audience bar is the source of truth for
    // what's selected; useWorkingSetPin turns the live selection into the ids to
    // pin — a snapshot (frozen between view-entry events, incl. modal close via
    // bumpPin) plus live arrivals — so checkboxes never reorder rows yet
    // just-added / discovered people still surface at the top. A focused saved
    // list is a strict query scope, so it disables this injection entirely:
    // changing list focus is allowed to rebuild the table, and selected outsiders
    // must remain in the audience bar rather than leaking into the filtered rows.
    const audiencePersonIds = pb.res.recipients.map((r) => r.personId);
    const loadedIds = useMemo(
      () => new Set([...firstPage, ...extra].map((r) => r.id)),
      [firstPage, extra]
    );
    const nonPinningIds = useMemo(
      () => [
        ...new Set([
          ...(bulkSelection?.ids ?? []),
          ...(loadedSelection?.ids ?? []),
        ]),
      ],
      [bulkSelection?.ids, loadedSelection?.ids]
    );
    const { pinnedIds, bump: bumpPin } = useWorkingSetPin({
      audienceIds: audiencePersonIds,
      loadedIds,
      // Account filtering is temporary inspection, not a view-entry event:
      // keeping this key stable prevents selected rows from being resnapshotted
      // and promoted when the filter changes.
      viewKey: selectionContextKey,
      resetKey: selectionResetVersion,
      disabled: !!focusedListName,
      nonPinningIds,
    });
    // App passes `skip: pinnedIds.length === 0`; the mock hook is synchronous,
    // and the pinnedRows memo below guards the empty case identically.
    const { data: pinRaw, previousData: pinPrev } = T.Data.useHomePeople({
      // Outside list focus, the active text search + company filter still apply
      // so a search cannot leave non-matching selected rows pinned at the top.
      userId: scopeUserId,
      personIds: pinnedIds,
      search: debouncedQuery || undefined,
      accountIds: companyFilter.length ? companyFilter : undefined,
      skip: 0,
      take: 500,
    });
    const pinnedRows = useMemo(() => {
      if (pinnedIds.length === 0) return [];
      const rows = (pinRaw ?? pinPrev)?.people ?? [];
      // Newest-created first → a just-added contact sits atop the selected group.
      return [...rows].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
    }, [pinRaw, pinPrev, pinnedIds.length]);
    const pinnedIdSet = useMemo(
      () => new Set(pinnedRows.map((r) => r.id)),
      [pinnedRows]
    );

    const rawPeople = useMemo(() => {
      const seen = new Set();
      const out = [];
      // Locally-created rows lead, then the pinned working set (newest-first),
      // then the paged rows. A local row is dropped as soon as the server returns
      // the same id, so it is a bridge across the round-trip rather than a second
      // source of truth that could drift.
      const serverIds = new Set(
        [...pinnedRows, ...firstPage, ...extra].map((r) => r.id)
      );
      // Scoped to THIS query as well as un-returned: a bridge row belongs to the
      // search it was created under, so it must not leak into the results of a
      // different one.
      const local = created
        .filter((c) => c.scopeKey === scopeKey && !serverIds.has(c.row.id))
        .map((c) => c.row);
      for (const row of [...local, ...pinnedRows, ...firstPage, ...extra]) {
        if (seen.has(row.id)) continue;
        seen.add(row.id);
        out.push(row);
      }
      return out;
    }, [created, scopeKey, pinnedRows, firstPage, extra]);
    const loadMore = useCallback(async () => {
      if (loadingMore || !hasMore || loading) return;
      setLoadingMore(true);
      try {
        const { data: page } = await queryHomePeople({
          ...baseVars,
          skip: firstPage.length + extra.length,
          take: HOME_PEOPLE_PAGE_SIZE,
        });
        const rows = page?.people ?? [];
        homeAnalytics.loadedMore({
          table: 'people',
          loaded: rows.length,
          total_loaded: firstPage.length + extra.length + rows.length,
        });
        setHasMore(rows.length === HOME_PEOPLE_PAGE_SIZE);
        setExtra((prev) => [...prev, ...rows]);
      } finally {
        setLoadingMore(false);
      }
      // eslint-disable-next-line react-hooks/exhaustive-deps -- vars objects are rebuilt per render; scopeKey covers their content
    }, [
      scopeKey,
      extra.length,
      firstPage.length,
      hasMore,
      loading,
      loadingMore,
    ]);
    const scrollerRootRef = useRef(null);
    const setScrollerRoot = useCallback((node) => {
      scrollerRootRef.current = node;
    }, []);
    const sentinelRef = useRef(null);
    useEffect(() => {
      if (!hasMore || loading) return;
      const el = sentinelRef.current;
      if (!el || !scrollerRootRef.current) return;
      const io = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) void loadMore();
        },
        { root: scrollerRootRef.current, rootMargin: '200px' }
      );
      io.observe(el);
      return () => io.disconnect();
    }, [hasMore, loading, loadMore]);

    // Account-filter options come from peopleFilterStats.accountCounts — NOT from
    // the loaded rows. Deriving them from rows made the filter single-select:
    // selecting an account refetched the rows down to that account, collapsing
    // the option list so a second account could never be added. The stats
    // dimension ignores its own account filter server-side, so the option list
    // stays complete regardless of what's selected. `orderedAccountIds` (count
    // desc, then id) drives the dropdown; the hook hydrates name/logo in pages of
    // 50 and keeps the current selection resolvable even when it falls outside
    // the page (TRA-1359).
    const { data: statsRaw, previousData: statsPrev } =
      T.Data.useHomePeopleAccountStats({
        scopeOr: scopeOr ?? undefined,
        userId: scopeUserId,
        accountIds: companyFilter.length ? companyFilter : undefined,
        search: debouncedQuery || undefined,
      });
    // Toggling a selection changes the vars; cache-and-network briefly returns
    // undefined for the new key, so fall back to the prior stats to avoid the
    // dropdown blinking empty mid-click (mirrors People.tsx).
    const liveStats =
      statsRaw?.peopleFilterStats ?? statsPrev?.peopleFilterStats;
    const orderedAccountIds = useMemo(() => {
      if (!liveStats) return [];
      return [...(liveStats.accountCounts ?? [])]
        .sort((a, b) => {
          if (b.count !== a.count) return b.count - a.count;
          return a.accountId.localeCompare(b.accountId);
        })
        .map((c) => c.accountId);
    }, [liveStats]);
    const accountPeopleCounts = useMemo(() => {
      const m = new Map();
      for (const c of liveStats?.accountCounts ?? [])
        m.set(c.accountId, c.count);
      return m;
    }, [liveStats]);
    const accountFilter = useAccountFilterOptions(true, {
      ids: orderedAccountIds,
      selectedIds: companyFilter,
    });

    // Scope is applied server-side (scopeOr arg); rows ARE the scoped set.
    const people = rawPeople;
    const peopleLoadedCount = Math.min(
      firstPage.length + extra.length,
      peopleMatchCount
    );

    // Whether the <table> branch renders at all (vs the loading / empty states).
    const tableRendered = people.length > 0 || beyondActive;

    // Match Accounts: spinner only when search/filter variables change, not on
    // cache-and-network background refetch when the tab mounts.
    const refetching =
      networkStatus === NetworkStatus.setVariables && rawPeople.length > 0;

    useEffect(() => {
      onCountChange?.(people.length);
    }, [people.length, onCountChange]);

    // See SignalsTab: settled query only; length only, because a people search is
    // very often a named individual; and the FRESH count rather than the
    // `?? previousData` value the table paints with, so the number belongs to
    // this search instead of the previous one.
    const searchReportedFor = useRef(null);
    const freshPeopleCount = peopleCountData?.peopleCount;
    useEffect(() => {
      if (!debouncedQuery || freshPeopleCount === undefined) return;
      if (searchReportedFor.current === debouncedQuery) return;
      searchReportedFor.current = debouncedQuery;
      homeAnalytics.searched({
        table: 'people',
        query_length: debouncedQuery.length,
        result_count: freshPeopleCount,
      });
    }, [debouncedQuery, freshPeopleCount]);

    const peopleEmpty = !loading && people.length === 0;
    // Deduped on (empty, reason). `peopleEmpty` derives from `loading`, which flips on
    // every refetch AND every poll tick, so the raw effect re-fired constantly:
    // an empty tenant in live-onboarding mode (2.5s polling for up to 6 minutes)
    // logged well over a hundred events for one dead end. The ref re-arms when
    // the table stops being empty, so a genuine second dead end still counts.
    const emptyReportedRef = useRef(null);
    useEffect(() => {
      // Re-arm ONLY when the table actually has rows. Keying the reset on
      // `!peopleEmpty` re-armed on `loading` too, which flips on every refetch and
      // every poll tick — so an empty tenant polling at 2.5s during onboarding
      // logged a fresh dead end every couple of seconds. A mid-fetch empty is
      // not a dead end the user reached; it is a table waiting.
      if (people.length > 0) {
        emptyReportedRef.current = null;
        return;
      }
      if (!peopleEmpty) return;
      const reason = filtersActive ? 'filtered' : 'no_data';
      if (emptyReportedRef.current === reason) return;
      emptyReportedRef.current = reason;
      homeAnalytics.emptyStateViewed({ table: 'people', reason });
    }, [peopleEmpty, people.length, filtersActive]);

    // Row order comes entirely from the pin (pinned working set first, then paged
    // rows). Membership for the section separators is the pinned set — NOT live
    // `pb.hasPerson` — so a checkbox toggle never re-buckets or reorders a row.

    // Bucket separators precomputed (running-var reassign during render is
    // disallowed by the react-compiler lint).
    const bucketLabels = useMemo(() => {
      const m = new Map();
      let last = null;
      for (const p of rawPeople) {
        const bucket = pinnedIdSet.has(p.id) ? 'in' : 'all';
        m.set(
          p.id,
          bucket !== last && pinnedIdSet.size > 0
            ? bucket === 'in'
              ? 'In this list'
              : 'All contacts'
            : null
        );
        last = bucket;
      }
      return m;
    }, [rawPeople, pinnedIdSet]);

    const selectablePeople = useMemo(
      () => people.filter((person) => !!(person.accountId ?? person.account?.id)),
      [people]
    );
    const selectedLoadedCount = useMemo(
      () => selectablePeople.filter((person) => pb.hasPerson(person.id)).length,
      [pb, selectablePeople]
    );
    const loadedPersonIds = useMemo(
      () => selectablePeople.map((person) => person.id),
      [selectablePeople]
    );
    const selectedBulkMatchingCount = countSelectedMatchingIds(
      activeBulkSelection?.ids ?? null,
      (id) => pb.hasPerson(id)
    );
    const selectedLoadedSelectionCount = countSelectedMatchingIds(
      loadedSelectionActive ? loadedSelection.ids : null,
      (id) => pb.hasPerson(id)
    );
    const selectionScopeCount =
      selectedBulkMatchingCount ??
      selectedLoadedSelectionCount ??
      selectedLoadedCount;
    const allLoadedSelected =
      selectablePeople.length > 0 &&
      selectedLoadedCount === selectablePeople.length;
    const anySelection = hasAudienceSelection(pb.audience);
    const headerChecked = allLoadedSelected
      ? true
      : anySelection
        ? 'indeterminate'
        : false;

    const bulkSelectionComplete =
      activeBulkSelection !== null &&
      selectedBulkMatchingCount === activeBulkSelection.ids.length;
    const loadedSelectionComplete =
      loadedSelectionActive &&
      selectedLoadedSelectionCount === loadedSelection.ids.length;

    const clearSelection = useCallback(() => {
      bulkRequest.current?.controller.abort();
      setLoadedSelection(null);
      onClearSelection();
    }, [onClearSelection]);

    const toggleLoadedSelection = useCallback(() => {
      if (anySelection) {
        clearSelection();
        return;
      }
      const added = pb.mergeAudience(
        peopleToAudienceBatch(selectablePeople),
        'people-table'
      );
      homeAnalytics.bulkAdded({
        table: 'people',
        via: 'people-table',
        matched: selectablePeople.length,
        added,
        capped: false,
        audience_people_before: pb.res.counts.people,
        audience_accounts_before: pb.res.counts.accounts,
      });
      setLoadedSelection({ ids: loadedPersonIds, scopeKey });
    }, [
      anySelection,
      clearSelection,
      loadedPersonIds,
      pb,
      scopeKey,
      selectablePeople,
    ]);

    const selectAllMatching = useCallback(async () => {
      if (selectablePeopleMatchCount <= 0 || bulkSelectionBusy) return;
      bulkRequest.current?.controller.abort();
      const controller = new AbortController();
      const requestId = ++bulkRequestId.current;
      bulkRequest.current = { id: requestId, controller };
      setBulkSelectionBusy(true);
      setBulkSelectionProgress(0);
      try {
        const selectionIds =
          activeBulkSelection?.ids ??
          (loadedSelectionActive ? loadedSelection.ids : loadedPersonIds);
        const progressBase = selectionScopeCount;
        const result = await fetchAllMatchingRows({
          totalCount: selectablePeopleMatchCount,
          startAt: activeBulkSelection?.nextOffset ?? 0,
          excludeIds: selectionIds,
          getRowId: (row) => row.id,
          signal: controller.signal,
          onProgress: (count) => setBulkSelectionProgress(progressBase + count),
          fetchPage: async (skip, take, signal) => {
            const { data: page } = await queryHomePeople({
              ...baseVars,
              hasAccount: true,
              skip,
              take,
            });
            return page.people ?? [];
          },
        });
        if (controller.signal.aborted || bulkRequest.current?.id !== requestId) {
          return;
        }
        const added = pb.mergeAudience(
          peopleToAudienceBatch(result.rows),
          'people-table'
        );
        homeAnalytics.bulkAdded({
          table: 'people',
          via: 'people-table',
          matched: selectablePeopleMatchCount,
          added,
          capped: result.outcome === 'capped',
          audience_people_before: pb.res.counts.people,
          audience_accounts_before: pb.res.counts.accounts,
        });
        const ids = [
          ...new Set([...selectionIds, ...result.rows.map((row) => row.id)]),
        ];
        onBulkSelectionChange({
          scopeKey,
          contextKey: selectionContextKey,
          ids,
          nextOffset: result.nextOffset,
          totalCount: selectablePeopleMatchCount,
          outcome: result.outcome,
        });
        setLoadedSelection(null);
        toast.dismiss('home-people-select-all');
      } catch (error) {
        if (
          controller.signal.aborted ||
          (error instanceof Error && error.name === 'AbortError')
        ) {
          return;
        }
        toast.error('Couldn’t select all matching people. Try again.', {
          id: 'home-people-select-all',
        });
      } finally {
        if (bulkRequest.current?.id === requestId) {
          bulkRequest.current = null;
          setBulkSelectionBusy(false);
          setBulkSelectionProgress(0);
        }
      }
    }, [
      activeBulkSelection,
      baseVars,
      bulkSelectionBusy,
      loadedPersonIds,
      loadedSelection,
      loadedSelectionActive,
      onBulkSelectionChange,
      pb,
      scopeKey,
      selectionContextKey,
      selectionScopeCount,
      selectablePeopleMatchCount,
    ]);

    const showSelectionScope = shouldShowHomeTableSelectionScope({
      selectedBulkMatchingCount,
      selectedLoadedSelectionCount,
      allLoadedSelected,
      totalCount: selectablePeopleMatchCount,
      loadedCount: selectedLoadedCount,
    });
    /** Any of the three mutually-exclusive rows main renders above the data —
     *  list notice, parked-selection notice, or the selection scope bar. Each is
     *  sticky at the header height, so the "available to add" band has to park
     *  below whichever one is showing. Mirrors the JSX condition exactly. */
    /** The table element, so the sticky band's offset can be measured
     *  rather than declared. */
    const peopleTableRef = useRef(null);
    const selectionRowShowing = Boolean(
      parkedBulkSelection || parkedLoadedSelection || showSelectionScope
    );
    useExternalBandOffset(peopleTableRef, selectionRowShowing);
    const selectedCompanyNames = companyFilter
      .map(
        (id) => accountFilter.accounts.find((account) => account.id === id)?.name
      )
      .filter((name) => !!name);
    const parkedViewLabel =
      selectedCompanyNames.length === 1
        ? `Viewing people at ${selectedCompanyNames[0]} only`
        : companyFilter.length === 1
          ? 'Viewing people at the selected account only'
          : companyFilter.length > 1
            ? `Viewing people at ${companyFilter.length.toLocaleString()} accounts only`
            : 'Viewing people across all accounts';

    return (
      <div className="flex min-h-0 flex-1 flex-col">
        <div className="htbl-tools">
          <HomeTableSearch
            value={query}
            onChange={(value) => {
              invalidateBulkSelection();
              setQuery(value);
            }}
            placeholder="Search people by name, role, or account"
            searching={refetching}
            trailing={
              // While a directory search is running the count has to describe TWO
              // populations, so `N of M` (which counted a scope the user can't see
              // and contradicted the rows on screen) gives way to `1 yours · 12 to
              // add`. Outside that case the ordinary single count stands.
              beyondActive ? (
                <HomeSearchSplitCount
                  // Server match total, not loaded rows — see AccountsTab.
                  owned={peopleMatchCount}
                  external={visibleBeyond.length}
                  limit={PEOPLE_SEARCH_LIMIT}
                />
              ) : showResultCount ? (
                <HomeTableResultCount
                  count={resultCount}
                  baseline={resultBaseline}
                />
              ) : undefined
            }
          />
          {toolbarExtra}
          <AccountMultiSelect
            accounts={accountFilter.accounts}
            selected={companyFilter}
            // The People tab's only filter, and it was silent — so "how do people
            // filter" was answerable for Signals alone.
            onChange={(next) => {
              homeAnalytics.filterChanged({
                table: 'people',
                filter: 'account',
                active: next.length > 0,
                selected_count: next.length,
                result_count: freshPeopleCount,
              });
              updateCompanyFilter(next);
            }}
            placeholder="All accounts"
            eventCounts={accountPeopleCounts}
            onSearchChange={accountFilter.onSearchChange}
            onLoadMore={accountFilter.loadMore}
            hasMore={accountFilter.hasMore}
            loading={accountFilter.loading}
          />
          <Button
            variant="default"
            size="xs"
            className="ml-auto"
            onClick={() => setImportOpen(true)}
          >
            <Plus />
            Import CSV
          </Button>
        </div>

        <AddContactsDialog
          open={importOpen}
          onOpenChange={(o) => {
            // Closed after submitting but before the rows landed — the "continue
            // in background" escape, same signal the accounts importer reports.
            if (!o && importSubmittedRef.current) {
              importSubmittedRef.current = false;
              homeAnalytics.importAbandoned({
                kind: 'contacts',
                elapsed_ms: importOpenedAt.current
                  ? Date.now() - importOpenedAt.current
                  : 0,
              });
            }
            setImportOpen(o);
            // Closing the modal is a view-entry event: re-snapshot so everything
            // added this session folds into the pinned set at the top, then one
            // refresh. The list was hidden while adding, so no visible churn.
            if (!o) {
              bumpPin();
              void refetch();
            }
          }}
          // The accounts twin reports all of opened → submitted → rows-ready; this
          // one used to skip `submitted`, so slicing the import funnel by `kind`
          // showed contacts falling off a cliff at a step that never existed.
          onImportSubmitted={(info) => {
            importSubmittedRef.current = true;
            homeAnalytics.importSubmitted({
              kind: 'contacts',
              named_list: Boolean(info.listId),
            });
          }}
          onImportReady={(info) => {
            importSubmittedRef.current = false;
            homeAnalytics.importRowsReady({
              kind: 'contacts',
              duration_ms: importOpenedAt.current
                ? Date.now() - importOpenedAt.current
                : 0,
            });
            return onImportReady?.(info);
          }}
          showImportTagInput={false}
        />

        {/* TRA-1428: X and Y scroll stay on the SAME element (the region's inner
            scroller) so `.htbl thead th { position: sticky; top: 0 }` keeps
            working, and the `N more` affordance renders on the non-scrolling
            anchor so it stays pinned to the visible edge. The outer
            `overflow-hidden htbl-card` in UserHome stays a clipper (TRA-1140). */}
        <TableScrollRegion
          onScrollerChange={setScrollerRoot}
          // The refetch dim lives on the ANCHOR, not the scroller: the edge fades
          // and the `N more` badge are siblings of the scroller, so dimming only
          // the scroller would leave a bright `from-surface-card` band and a
          // full-strength badge over a 60%-opacity table (TRA-1428).
          rootClassName={cn(
            'flex min-h-0 flex-1 flex-col transition-opacity duration-150',
            // Don't dim while rows stream in — content appearing is the point; a
            // dim reads as a flicker (TRA-1359).
            refetching && !pollMs && 'opacity-60'
          )}
          className="min-h-0 flex-1 overflow-y-auto"
        >
          {loading && people.length === 0 && !beyondActive ? (
            <div className="flex items-center justify-center py-16 text-text-muted">
              <Loader2 className="mr-2 size-4 animate-spin" />
              Loading people
            </div>
          ) : people.length === 0 && !beyondActive ? (
            <div className="py-16 text-center text-body text-text-muted">
              No people match. Clear the filters, or open a signal to add
              contacts.
            </div>
          ) : (
            <table
              className="htbl htbl-w-people"
              // `useExternalBandOffset` MEASURES the sticky stack above the band
              // (header + selection row) and writes `--htbl-band-top` here. A
              // declared offset was wrong by ~11px because the header's real
              // height differs from its variable and the selection row's height
              // follows a button size.
              ref={peopleTableRef}
            >
              <thead>
                <tr>
                  <th className="htbl-c-check">
                    <HomeTableSelectAllCheckbox
                      checked={headerChecked}
                      busy={bulkSelectionBusy}
                      disabled={selectablePeople.length === 0}
                      entityName="person"
                      entityPlural="people"
                      loadedCount={selectablePeople.length}
                      onToggle={toggleLoadedSelection}
                    />
                  </th>
                  <HomeTableSortTh
                    label="Person"
                    field="fullName"
                    sortField={sortField}
                    sortDir={sortDir}
                    onSort={toggleSort}
                    className="htbl-c-person"
                  />
                  <HomeTableSortTh
                    label="Role"
                    field="title"
                    sortField={sortField}
                    sortDir={sortDir}
                    onSort={toggleSort}
                    className="htbl-c-role"
                  />
                  <HomeTableSortTh
                    label="Account"
                    field="account"
                    sortField={sortField}
                    sortDir={sortDir}
                    onSort={toggleSort}
                    className="htbl-c-co"
                  />
                  <HomeTableSortTh
                    label="Enriched"
                    field="enrichment"
                    sortField={sortField}
                    sortDir={sortDir}
                    onSort={toggleSort}
                    className="htbl-c-enriched"
                  />
                  <HomeTableSortTh
                    label="Last outreach"
                    field="lastOutreach"
                    sortField={sortField}
                    sortDir={sortDir}
                    onSort={toggleSort}
                    className="htbl-c-last"
                  />

                  <th className="htbl-c-tw" />
                </tr>
              </thead>
              <tbody>
                {parkedBulkSelection || parkedLoadedSelection ? (
                  <HomeTableFilteredSelectionNoticeRow
                    colSpan={7}
                    viewLabel={parkedViewLabel}
                    onClear={clearSelection}
                  />
                ) : showSelectionScope ? (
                  <HomeTableSelectionScopeRow
                    colSpan={7}
                    selectedCount={selectionScopeCount}
                    totalCount={selectablePeopleMatchCount}
                    remainingCount={
                      activeBulkSelection
                        ? Math.max(
                            0,
                            selectablePeopleMatchCount -
                              activeBulkSelection.nextOffset
                          )
                        : Math.max(
                            0,
                            selectablePeopleMatchCount - selectionScopeCount
                          )
                    }
                    entityName="person"
                    entityPlural="people"
                    bulkOutcome={activeBulkSelection?.outcome ?? null}
                    bulkSelectionComplete={bulkSelectionComplete}
                    loadedSelectionComplete={loadedSelectionComplete}
                    busy={bulkSelectionBusy}
                    progressCount={bulkSelectionProgress}
                    onSelectAll={() => void selectAllMatching()}
                    onClear={clearSelection}
                  />
                ) : null}
                {people.map((p) => (
                  <PersonRowView
                    key={p.id}
                    person={p}
                    bucketLabel={bucketLabels.get(p.id) ?? null}
                    open={openPersonId === p.id}
                    onOpen={() => onOpenPerson(p)}
                  />
                ))}

                {/* The owned-side empty state still renders when the directory
                    group below has rows. Skipping it made a screen of entirely
                    unowned results look like ordinary search results. */}
                {beyondActive && people.length === 0 && !loading && (
                  <tr className="htbl-ext-none">
                    <td colSpan={7} data-testid="home-owned-empty">
                      No people in your workspace match “{debouncedQuery.trim()}”.
                    </td>
                  </tr>
                )}

                {beyondActive && (
                  <>
                    <ExternalGroupBand
                      colSpan={7}
                      count={visibleBeyond.length}
                      noun="people"
                      limit={PEOPLE_SEARCH_LIMIT}
                    />
                    {beyondLoading && beyond.length === 0 ? (
                      <tr>
                        <td
                          colSpan={7}
                          className="px-4 py-4 text-meta text-text-muted"
                        >
                          <span className="inline-flex items-center gap-2">
                            <Loader2 className="size-4 animate-spin" />
                            Searching people…
                          </span>
                        </td>
                      </tr>
                    ) : visibleBeyond.length === 0 ? (
                      <tr>
                        <td
                          colSpan={7}
                          className="px-4 py-3 text-meta text-text-muted"
                        >
                          No new people found for “{debouncedQuery.trim()}”.
                        </td>
                      </tr>
                    ) : (
                      visibleBeyond.map((c) => (
                        <BeyondPersonRow
                          key={c.employeeId}
                          c={c}
                          pending={pendingAdds.has(c.employeeId)}
                          onAdd={() => void addBeyondPerson(c)}
                        />
                      ))
                    )}
                  </>
                )}
              </tbody>
            </table>
          )}
          {/* TRA-1428: the table's in-flow siblings inside the scroller are laid
              out at the scroller's CLIENT width, not the table's min-width floor,
              so they stay anchored at scroll origin and slide out of view as soon
              as the table is scrolled right. Two different remedies, because the
              two kinds of sibling want different things:

              - the zero-height sentinel gets the same `htbl-w-*` floor, so it
                spans the scrolled coordinate space and never leaves the
                IntersectionObserver's reach (a clipped sentinel stalls infinite
                scroll with no way to load more while scrolled right);
              - anything VISIBLE gets `sticky left-0` instead. A floor would make
                a `justify-center` box center on 1046px rather than on the
                scrollport — off-centre at rest, and fully off-screen once the
                card is narrower than 523px. Sticky keeps the box client-width
                wide and pinned to the visible left edge on both axes. */}
          {query.trim().length >= 3 && !loading && (
            <div className="sticky left-0 px-4 py-3">
              <SearchInFindLink query={query} />
            </div>
          )}
          {/* …and the floor only applies while the table is actually rendered.
              The two table-less branches above (loading / "no people match")
              would otherwise get ~1046px of scrollWidth behind a single centered
              line: a horizontal scrollbar over blank space, with no `thead th`
              for `useHiddenColumns` to count — so no fade and no `N more` badge
              to explain it. */}
          {hasMore && people.length > 0 && (
            <>
              <div
                ref={sentinelRef}
                className={cn(tableRendered && 'htbl-w-people')}
              />
              <div className="sticky left-0 flex items-center justify-center py-6 text-meta text-text-muted">
                {loadingMore ? (
                  <>
                    <Loader2 className="mr-2 size-4 animate-spin" />
                    Loading more
                  </>
                ) : (
                  `Showing ${peopleLoadedCount.toLocaleString()} of ${peopleMatchCount.toLocaleString()} people`
                )}
              </div>
            </>
          )}
        </TableScrollRegion>
      </div>
    );
  }

  function PersonRowView({ person, bucketLabel, open, onOpen }) {
    const { HomeSelectCell, EnrichedCell, formatOutreach, homeAnalytics } = T;
    const pb = T.useAudience();
    const on = pb.hasPerson(person.id);
    const accountId = person.accountId ?? person.account?.id ?? '';

    return (
      <>
        {bucketLabel && (
          <tr className="htbl-day">
            <td colSpan={7}>{bucketLabel}</td>
          </tr>
        )}
        <tr
          onClick={onOpen}
          className={`htbl-row${open ? ' is-open' : ''}${on ? ' is-sel' : ''}`}
        >
          <HomeSelectCell
            checked={on}
            disabled={!accountId}
            ariaLabel={on ? 'Remove from list' : 'Add to list'}
            onToggle={() => {
              // The audience mutation is captured in the provider; this is the
              // table GESTURE, so a row tick reads differently from the same
              // person arriving via a drawer or a bulk add.
              homeAnalytics.rowSelected({ table: 'people', selected: !on });
              return on
                ? pb.removePerson(person.id, 'people-table')
                : pb.addPerson(accountId, person.id, 'people-table', undefined, undefined, {
                    // Row already has the person's display data — pass it so
                    // the bar paints the real face/name first frame (TRA-1359).
                    fullName: person.fullName,
                    title: person.title,
                    profileImageUrl: person.profileImageUrl,
                  });
            }}
          />
          <td>
            <span className="htbl-acct">
              {/* Circles read ~2px inset vs the square logo tiles at the same
                  box x - optical nudge keeps the columns aligned across tabs. */}
              <PersonAvatar
                src={person.profileImageUrl}
                personId={person.id}
                name={person.fullName}
                className="-ml-[3px] size-[26px] shrink-0"
              />
              <span className="htbl-acct-t">
                <span className="htbl-acct-name">{person.fullName}</span>
              </span>
            </span>
          </td>
          <td>
            <span className="htbl-muted" title={person.title ?? undefined}>
              {person.title || '-'}
            </span>
          </td>
          <td>
            {person.account ? (
              <span className="flex min-w-0 max-w-full items-center gap-1.5">
                <LogoAvatar
                  src={person.account.logoUrl}
                  domain={person.account.url}
                  alt={person.account.name}
                  fallbackText={person.account.name.charAt(0).toUpperCase()}
                  size="xs"
                  className="size-[20px] shrink-0 rounded-[6px]"
                />
                <span className="htbl-muted min-w-0" title={person.account.name}>
                  {person.account.name}
                </span>
              </span>
            ) : (
              <span className="htbl-muted">-</span>
            )}
          </td>
          <td>
            <EnrichedCell
              hasEmail={!!person.deliverableEmail}
              hasPhone={!!person.deliverablePhone}
            />
          </td>
          <td>
            <span className="htbl-when">
              {formatOutreach(person.lastContactedAt) || '-'}
            </span>
          </td>
          <td>
            <span className="htbl-tw">
              <ChevronDown />
            </span>
          </td>
        </tr>
      </>
    );
  }

  /** A "Beyond your people" row: a CoreSignal person not yet in the tenant.
   *  Adding follows the CSV-import path — create the account if needed, create
   *  the person linked to it, drop into the bar. Rows with no company on record
   *  render but can't be added (nothing to link them to). */
  function BeyondPersonRow({ c, pending, onAdd }) {
    const { HomeSelectCell, cn } = T;
    const addable = !!c.accountName;
    return (
      // No row-level click: adding is an explicit act via the checkbox or the
      // button. `.htbl-ext` also drops the pointer cursor `.htbl-row` sets for
      // owned rows (which DO open a drawer on click), so the row never advertises
      // a click it will not honour.
      <tr className={cn('htbl-row htbl-ext', pending && 'is-sel')}>
        {/* The SAME cell component as an owned row, deliberately: one click, one
            gesture, no second control to learn — and, critically, ONE place that
            owns the click semantics. Hand-rolling this cell meant the Radix
            checkbox's own handler AND the cell's onClick both fired, which on a
            row whose toggle CREATES a record produced two contacts per click.
            The operation still differs from an owned row's — it creates the
            contact, then adds it to the current selection — hence `busy`, which
            swaps a spinner into the checkbox's own slot so the control does not
            move. Unticking is not offered: the record now exists, and the row has
            moved to the owned group where the normal checkbox governs it. */}
        <HomeSelectCell
          checked={false}
          disabled={!addable}
          busy={pending}
          ariaLabel={`Add ${c.name} to your workspace`}
          onToggle={onAdd}
        />
        <td className="htbl-c-person">
          <span className="htbl-acct">
            <PersonAvatar
              src={c.profilePictureUrl}
              personId={c.employeeId}
              name={c.name}
              className="-ml-[3px] size-[26px] shrink-0"
            />
            <span className="htbl-acct-t">
              <span className="htbl-acct-name">{c.name}</span>
            </span>
          </span>
        </td>
        <td className="htbl-c-role">
          <span className="htbl-muted">{c.title || ''}</span>
        </td>
        <td className="htbl-c-co">
          {c.accountName ? (
            <span className="htbl-acct">
              <LogoAvatar
                domain={c.accountDomain ?? undefined}
                alt={c.accountName}
                fallbackText={getInitials(c.accountName)}
                size="row"
                className="shrink-0"
              />
              <span className="htbl-acct-t">
                <span className="htbl-acct-name">{c.accountName}</span>
              </span>
            </span>
          ) : null}
        </td>
        <td colSpan={3} className="text-right">
          <span className="inline-flex items-center justify-end pr-2">
            <Button
              variant="secondary"
              size="xs"
              // Pinned width: "Add" and "Adding…" are different lengths and the
              // cell is right-aligned, so without it the button jumps at the row
              // edge on every click.
              className="min-w-[5.25rem] justify-center"
              loading={pending}
              disabled={!addable}
              onClick={onAdd}
              title={
                addable
                  ? 'Create this contact (and their account) and add to your list'
                  : 'No company on record — can’t add'
              }
            >
              {!pending && <Plus />}
              {pending ? 'Adding…' : 'Add'}
            </Button>
          </span>
        </td>
      </tr>
    );
  }

  Object.assign(window.T, {
    PeopleTab,
    PersonRowView,
    BeyondPersonRow,
    // people-query.ts pieces UserHome's import hand-off prefetch shares.
    HOME_PEOPLE_PAGE_SIZE,
    homePeopleVars,
    queryHomePeople,
  });
})();
