"use client";

/**
 * RelatedContentSection
 *
 * Renders content references grouped by type using the canonical card
 * components. MOVED from the hub (`components/shared/related-content-card.tsx`)
 * so any consuming app can embed it; the hub keeps a thin wrapper that
 * pre-binds its host-specific injections (nav hook, URL recomposition,
 * program configs, admin campaign card).
 *
 * THREE data modes (precedence top-down):
 *   1. CONTROLLED — `contentRefs` provided (even `[]`): render exactly those
 *      refs, no suggestion fetch (the original investor-update behavior).
 *   2. SUGGESTION — `entityType` + `entityId` provided: self-fetch
 *      `GET {apiBaseUrl}/api/related-content?entityType&entityId[&count][&excludeTypes]`
 *      (the generic 5-tier engine's second web service). `minResults` maps to
 *      `count`; absent → param not sent (server default applies). Each ref
 *      carries a `reason` (data-only — never rendered, matching the
 *      FaqSection/FaqWithReason precedent).
 *   3. SSR-HYDRATED suggestion — also pass `initialItems` (the server page
 *      called the engine directly); the first client fetch is skipped per the
 *      `useSelfFetch` initialData contract.
 *
 * Group layout (list vs grid) + card size (lg vs default) come from
 * `CONTENT_REF_GROUPS` in `../../utils/content-ref-groups` — single source of
 * truth, no per-type logic in this file. Skeletons come from
 * `renderSkeletonForType` so the placeholder height matches the loaded card
 * exactly (zero layout shift on resolve).
 *
 * One API call per content type via the shared list-URL builder
 * (`buildListUrl` — injectable; defaults to the lib's byte-parity-tested
 * builder prefixed with `apiBaseUrl`). Fetching uses `useSelfFetch` (plain
 * fetch, NO react-query) so third-party embedders need no QueryClientProvider;
 * cards are imported via DEEP module paths (not the chat barrel) so this
 * chunk never reaches `@tanstack/react-query`.
 *
 * LOCKSTEP NOTE: this file's per-type card/skeleton dispatch is the SIZED
 * sibling of the chat-side `CHAT_CARD_REGISTRY` (`../chat/entity-cards/
 * dispatch.tsx`), which renders compact `size='sm'` cards wired to the chat
 * runtime. Two dispatchers by design — when registering a new fetch-mode
 * content type, add it BOTH there and here (cards + skeleton + list URL).
 */

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
  CONTENT_REF_GROUPS,
  getContentRefLabelOrTitleCase,
  orderContentRefTypes,
  type ContentRefGroupConfig,
} from '../../utils/content-ref-groups';
import type { ContentRef, ContentRefWithReason } from '../../types/content-ref';
import { useSelfFetch } from '../../hooks/use-self-fetch';
import { Pagination } from '../pagination';
import { extractItems, extractItemId } from '../../utils/extract-items';
import { buildListUrl as libBuildListUrl, canonicalContentRefType } from '../../utils/list-url';
import { buildSuggestionUrl } from '../../utils/suggestion-url';
import { decideNewTab } from '../chat/utils/decide-new-tab';
// DEEP card imports — NOT the `../chat` barrel (the barrel statically reaches
// @tanstack/react-query via embeddable-chat + its hooks). Deep paths keep this
// component's SOURCE graph react-query-free. Note: tsup's shared-chunk
// splitting may still colocate the cards with chat hooks in one dist chunk
// (react-query is a required peerDep, so resolution always succeeds) — the
// guarantee that matters here is the RUNTIME one: nothing on this path ever
// instantiates a QueryClient, so embedders need NO QueryClientProvider.
import { BlogCard, BlogCardSkeleton } from '../chat/entity-cards/blog-card';
import { WhatIShippedCard, WhatIShippedCardSkeleton } from '../chat/entity-cards/what-i-shipped-card';
import { HowIWorkCard, HowIWorkCardSkeleton } from '../chat/entity-cards/how-i-work-card';
import { CaseStudyCard, CaseStudyCardSkeleton } from '../chat/entity-cards/case-study-card';
import { CustomerInterviewCard, CustomerInterviewCardSkeleton } from '../chat/entity-cards/customer-interview-card';
import { ProductReleaseCard, ProductReleaseCardSkeleton } from '../chat/entity-cards/product-release-card';
import { buildProductReleaseCardProps } from '../chat/entity-cards/product-release-card-defaults';
import { ProgramCard, ProgramCardSkeleton } from '../chat/entity-cards/program-card';
import { InvestorUpdateCard, InvestorUpdateCardSkeleton } from '../chat/entity-cards/investor-update-card';
import { OnboardingGuideCard, OnboardingGuideCardSkeleton } from '../chat/entity-cards/onboarding-guide-card';
import { RoadmapCard, RoadmapCardSkeleton } from '../chat/entity-cards/roadmap-card';
// Type-only — erased at build, no runtime dependency on the dispatch module.
import type { ChatCardDispatchExtras } from '../chat/entity-cards/dispatch';

type CardSize = 'lg' | 'default' | 'sm';

/** Anchor prop bundle the per-card link surface receives — same shape the
 *  hub's `useNavLink` returns and the chat dispatcher's anchor builders
 *  produce. `null` = non-anchor mode (no URL). */
export interface CardLinkAnchorProps {
  href: string;
  target?: '_blank';
  rel?: 'noopener noreferrer';
  onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}

/** Render-prop component injection for the navigation decision — keeps hook
 *  calls legal (hooks live INSIDE the injected component; `CardForType`
 *  itself calls zero hooks). The hub injects a `useNavLink`-backed provider;
 *  the default is hook-free (pure `decideNewTab`). MUST be defined at module
 *  scope by hosts — an inline arrow would remount every card each render. */
export interface CardLinkProviderProps {
  href: string | null;
  targetPlatform: string | null;
  children: (linkProps: CardLinkAnchorProps | null) => React.ReactElement | null;
}
export type CardLinkProvider = React.ComponentType<CardLinkProviderProps>;

/** Default link provider for standalone embeds: relative/-same-origin hrefs
 *  stay same-tab, cross-origin pops a new tab (pure `decideNewTab` with no
 *  platform context — `currentSource: ''` falls through to the origin
 *  check). No router integration, no hooks. */
function DefaultLinkPropsProvider({ href, targetPlatform, children }: CardLinkProviderProps): React.ReactElement | null {
  if (!href) return children(null);
  const newTab = decideNewTab({ href, targetPlatform, currentSource: '' });
  return children(
    newTab
      ? { href, target: '_blank', rel: 'noopener noreferrer' }
      : { href },
  );
}

/** Default href resolution: trust the ref's stored url/targetPlatform as the
 *  API composed them. The hub overrides this with its `buildContentURL`
 *  re-composition so dev gets localhost and prod gets platform domains. */
function defaultResolveHref(ref: ContentRef): { href: string | null; targetPlatform: string | null } {
  return { href: ref.url || null, targetPlatform: ref.targetPlatform ?? null };
}

/** Host-injected renderer pair for the admin-only `marketing_campaign` type.
 *  Absent (every non-hub embed) → the type renders nothing (its list URL
 *  hits `/api/admin`, unreachable outside the hub anyway). */
export interface AdminCampaignCardSlot {
  Card: React.ComponentType<{ campaign: any }>;
  Skeleton: React.ComponentType<{ size?: 'default' | 'sm' }>;
}

/**
 * Per-type skeleton dispatch — returns the SAME colocated skeleton the
 * resolved card renders, sized to match (zero layout shift on resolve).
 * The chat-side `CHAT_CARD_REGISTRY` already does this via
 * `entry.skeleton()`; this surface exposes the same discipline to the
 * related-content rail.
 */
function renderSkeletonForType(
  type: string,
  size: CardSize,
  adminCampaignCard?: AdminCampaignCardSlot,
): React.ReactNode {
  // Most card skeletons accept only `{default, sm}`. `'lg'` collapses to
  // `'default'`. ProductReleaseCardSkeleton uses lg/sm pair.
  const legacySize: 'default' | 'sm' = size === 'sm' ? 'sm' : 'default';
  switch (type) {
    case 'blog_post_existing':
      return <BlogCardSkeleton size={legacySize} />;
    case 'case_study':
      return <CaseStudyCardSkeleton size={legacySize} />;
    case 'customer_interview':
      return <CustomerInterviewCardSkeleton size={legacySize} />;
    case 'product_release':
      return <ProductReleaseCardSkeleton size={size === 'sm' ? 'sm' : 'lg'} />;
    case 'podcast':
    case 'webinar':
    case 'event':
      return <ProgramCardSkeleton size={legacySize} />;
    case 'investor_update':
      return <InvestorUpdateCardSkeleton size={legacySize} />;
    case 'onboarding_guide':
      // The rich catalog variant (hero + author grid, clamped description) —
      // the step-numbered 'default' variant is for the guide detail page's
      // "More in section" rail, not this full-width row.
      return <OnboardingGuideCardSkeleton size={size === 'sm' ? 'sm' : 'catalog'} />;
    case 'what_i_shipped':
      // Matches the WhatIShippedCard (AdminContentCard 3:2) shape.
      return <WhatIShippedCardSkeleton />;
    case 'how_i_work':
      // Same shared employee-entry shape as What I Shipped.
      return <HowIWorkCardSkeleton />;
    case 'marketing_campaign':
      return adminCampaignCard ? <adminCampaignCard.Skeleton size={legacySize} /> : null;
    case 'roadmap_item':
    case 'delivery_item':
    case 'internal_task':
      return <RoadmapCardSkeleton size={legacySize} />;
    default:
      return null;
  }
}

/**
 * Per-type card dispatch — renders the right card with the right size.
 * Sized cards (`'lg'` / `'default'`) are unique to this rail — the chat
 * dispatcher only renders `'sm'`, so we go directly through the per-type
 * cards here.
 *
 * PURE FUNCTION COMPONENT WITH ZERO HOOK CALLS: the placeholder comes from a
 * plain `extras.buildOgPlaceholderUrl` call (the chat `dispatch.tsx`
 * pattern) and the anchor-prop bundle arrives via the `LinkProvider`
 * render-prop from the parent — so per-card hook legality is owned by the
 * injected provider component, not by this switch.
 *
 * `href` comes from the host's `resolveHref(ref)` (hub: live
 * `buildContentURL` recomposition; default: the ref's stored url).
 */
function CardForType({
  type,
  item,
  size,
  href,
  targetPlatform,
  linkProps,
  extras,
  adminCampaignCard,
}: {
  type: string;
  item: any;
  size: CardSize;
  href: string;
  targetPlatform: string | null;
  linkProps: CardLinkAnchorProps | null;
  extras?: ChatCardDispatchExtras;
  adminCampaignCard?: AdminCampaignCardSlot;
}): React.ReactNode {
  // Most card variants accept only `{default, sm}`. `'lg'` collapses to
  // `'default'` for those. ProductReleaseCard uses its own lg/sm pair.
  const legacySize: 'default' | 'sm' = size === 'sm' ? 'sm' : 'default';
  // OG placeholder URL — injected into the pure-presentation cards so they
  // render a branded fallback when the row's featured image is null. Plain
  // function call (NOT a hook). Title is the universal field across all card
  // item shapes used here.
  const placeholderUrl =
    extras?.buildOgPlaceholderUrl?.((item?.title as string | undefined) ?? '') ?? undefined;

  // Top-level target/rel for cards that take them as separate props
  // (BlogCard, CaseStudyCard, …). ProductReleaseCard takes the bundle as a
  // single `anchorProps={...}` and uses `linkProps` directly. When the host
  // didn't surface a URL, `linkProps` is null and the card stays in
  // non-anchor mode.
  const anchorAttrs: Pick<CardLinkAnchorProps, 'target' | 'rel'> = linkProps
    ? { target: linkProps.target, rel: linkProps.rel }
    : {};

  switch (type) {
    case 'blog_post_existing':
      return <BlogCard post={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
    case 'case_study':
      return <CaseStudyCard study={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
    case 'customer_interview':
      return <CustomerInterviewCard interview={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
    case 'product_release': {
      // Anchor-prop pattern: build product-release lg-variant props from the
      // shared `buildProductReleaseCardProps` so this rail and the /releases
      // catalog page render byte-identically. The card wraps in
      // `<a {...anchorProps}>` ONLY when `anchorProps.href` is set — pass
      // `undefined` (not an empty object) when href is empty so the card
      // stays in non-anchor mode without rendering a dead <a> tag.
      const releaseSize = size === 'sm' ? 'sm' : 'lg';
      const buildReleaseProps = extras?.buildProductReleaseCardProps ?? buildProductReleaseCardProps;
      const releaseProps = buildReleaseProps(item);
      return (
        <ProductReleaseCard
          size={releaseSize}
          title={item.title}
          summary={item.summary}
          version={item.version}
          {...releaseProps}
          anchorProps={linkProps ?? undefined}
        />
      );
    }
    case 'podcast':
      return extras?.programConfigs?.podcast
        ? <ProgramCard config={extras.programConfigs.podcast} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
        : null;
    case 'webinar':
      return extras?.programConfigs?.webinar
        ? <ProgramCard config={extras.programConfigs.webinar} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
        : null;
    case 'event':
      return extras?.programConfigs?.event
        ? <ProgramCard config={extras.programConfigs.event} item={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />
        : null;
    case 'investor_update':
      return <InvestorUpdateCard update={item} size={legacySize} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
    case 'onboarding_guide':
      // Catalog variant (see skeleton note) — full-width rich card with a
      // line-clamped description instead of the step-numbered rail card.
      return <OnboardingGuideCard guide={item} size={size === 'sm' ? 'sm' : 'catalog'} href={href} targetPlatform={targetPlatform} placeholderUrl={placeholderUrl} {...anchorAttrs} />;
    case 'what_i_shipped':
      // THE single What I Shipped card — same lib component the people-hub
      // dashboard renders, so the card is identical in the rail and the
      // dashboard. `anchorProps` makes the whole card a click-through link
      // (rail is read-only — no owner actions).
      return (
        <WhatIShippedCard
          entry={item}
          placeholderUrl={placeholderUrl}
          // Only pass anchorProps when there's a REAL href — a fallback object
          // with `href: undefined` is still truthy and would make WhatIShippedCard
          // wrap the card in a dead <a> (no URL). Mirrors the ProductReleaseCard
          // `linkProps ?? undefined` pattern above.
          anchorProps={
            linkProps ??
            (href ? ({ href, ...anchorAttrs } as React.AnchorHTMLAttributes<HTMLAnchorElement>) : undefined)
          }
        />
      );
    case 'how_i_work':
      // THE single How I Work card — same lib component the people-hub dashboard
      // renders. Same `anchorProps` contract as What I Shipped above (a real
      // href only; a fallback object would wrap the card in a dead <a>).
      return (
        <HowIWorkCard
          entry={item}
          placeholderUrl={placeholderUrl}
          anchorProps={
            linkProps ??
            (href ? ({ href, ...anchorAttrs } as React.AnchorHTMLAttributes<HTMLAnchorElement>) : undefined)
          }
        />
      );
    case 'marketing_campaign':
      return adminCampaignCard ? <adminCampaignCard.Card campaign={item} /> : null;
    case 'roadmap_item':
    case 'delivery_item':
    case 'internal_task':
      return (
        <RoadmapCard
          item={item}
          href={href ?? ''}
          targetPlatform={targetPlatform}
          userVote={null}
          onVote={() => {}}
          size={legacySize}
          cardType={type as 'roadmap_item' | 'delivery_item' | 'internal_task'}
          {...anchorAttrs}
        />
      );
    default:
      return null;
  }
}

// =============================================================================
// Fetch all items for a type in ONE server-sorted call, via the injectable
// list-URL builder. `useSelfFetch` (URL = cache key) replaces the hub's old
// react-query usage: `enabled` ≙ `url === null`, `!res.ok`/network error ≙
// `error → items null → group renders nothing`. Accepted deltas vs
// react-query: no retry/backoff, no focus refetch, no cross-mount cache.
// =============================================================================

function useGroupItems(
  type: string,
  refs: ContentRef[],
  buildUrl: (type: string, ids: string[]) => string | null,
) {
  const ids = refs.map((r) => r.id);
  const url = ids.length > 0 ? buildUrl(type, ids) : null;
  const { data, isLoading } = useSelfFetch<unknown>(url);
  const items = data != null ? extractItems(data) : null;
  return { items, isLoading };
}

// =============================================================================
// Per-group renderer — one API call, server-sorted, then render cards via the
// dispatcher with per-type skeletons + per-type layout from CONTENT_REF_GROUPS.
// =============================================================================

/** Map columns prop → tailwind grid class. Only consulted for grid-layout
 *  groups; list-layout groups stack vertically. */
function gridClassFor(columns: 2 | 3): string {
  return columns === 3
    ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'
    : 'grid grid-cols-1 sm:grid-cols-2 gap-6';
}

/** Resolve the group config for a type, falling back to a grid layout with
 *  the default card size for unregistered types so the section still renders
 *  rather than silently dropping them. The `label` field on the fallback is
 *  intentionally a placeholder — the section heading goes through
 *  `getContentRefLabelOrTitleCase(type)` instead so cross-surface labels
 *  stay consistent between this rail and the investor-email builder. */
function resolveGroupConfig(type: string): ContentRefGroupConfig {
  return CONTENT_REF_GROUPS[type] ?? {
    label: type,
    order: 999,
    layout: 'grid',
    gridSize: 'default',
  };
}

/** Items per page within one type group. Groups larger than this paginate
 *  with the standard Pagination control (NO nested scrolling — a bounded
 *  scrollbox inside the page traps wheel events and hides the sections
 *  below it). MUST stay at or above the largest suggestion fill
 *  (RELATED_SAME_TYPE_COUNT in the hub's lib/constants/suggestions.ts) so
 *  current rails never paginate — only genuinely big groups (author pages)
 *  do. Exported through the subpath barrel for the hub's module-load
 *  assertion of that relation (entity-suggestion-sections.tsx). */
export const GROUP_PAGE_SIZE = 12;

function ContentGroup({
  type,
  refs,
  columns,
  buildUrl,
  resolveHref,
  LinkProvider,
  extras,
  adminCampaignCard,
  heading,
}: {
  type: string;
  refs: ContentRef[];
  columns: 2 | 3;
  buildUrl: (type: string, ids: string[]) => string | null;
  resolveHref: (ref: ContentRef) => { href: string | null; targetPlatform: string | null };
  LinkProvider: CardLinkProvider;
  extras?: ChatCardDispatchExtras;
  adminCampaignCard?: AdminCampaignCardSlot;
  /** Group heading, rendered INSIDE the group so a group that resolves to
   *  nothing (fetch miss / unsupported type / missing program config) drops
   *  its heading too — no orphaned titles. */
  heading: React.ReactNode;
}) {
  const { items, isLoading } = useGroupItems(type, refs, buildUrl);
  const config = resolveGroupConfig(type);
  const isListLayout = config.layout === 'list';
  const cardSize = config.gridSize;

  // Per-group pagination for big groups (author pages): GROUP_PAGE_SIZE items
  // per page with the standard Pagination control below the group. Client-side
  // slicing — useGroupItems already fetched every row in one batched call, so
  // page flips are instant. Hooks live above every early return (file
  // convention). Page is clamped so a shrinking refs array (suggestion
  // refetch) can never strand the view past the last page, and RESET when the
  // ref set actually changes (shrink→grow must not return to a stale page).
  const [page, setPage] = useState(1);
  const refsKey = refs.map((r) => r.id).join('|');
  const prevRefsKeyRef = useRef(refsKey);
  useEffect(() => {
    if (prevRefsKeyRef.current !== refsKey) {
      prevRefsKeyRef.current = refsKey;
      setPage(1);
    }
  }, [refsKey]);
  const totalGroupPages = Math.max(1, Math.ceil(refs.length / GROUP_PAGE_SIZE));
  const safePage = Math.min(page, totalGroupPages);
  const visibleGroupRefs =
    refs.length > GROUP_PAGE_SIZE
      ? refs.slice((safePage - 1) * GROUP_PAGE_SIZE, safePage * GROUP_PAGE_SIZE)
      : refs;
  const groupPagination =
    totalGroupPages > 1 ? (
      <Pagination currentPage={safePage} totalPages={totalGroupPages} onPageChange={setPage} />
    ) : null;

  // Skeleton gate: `isLoading && !items` — SSR HTML and the client's first
  // paint render identical skeletons (useSelfFetch starts isLoading=true on
  // both sides), and once items exist they are never replaced by skeletons.
  if (isLoading && !items) {
    const skeletons = visibleGroupRefs.map((r) => (
      <div key={r.id}>{renderSkeletonForType(type, cardSize, adminCampaignCard)}</div>
    ));
    return (
      <div className="space-y-4">
        {heading}
        {isListLayout ? (
          <div className="space-y-4">{skeletons}</div>
        ) : (
          <div className={gridClassFor(columns)}>{skeletons}</div>
        )}
      </div>
    );
  }

  if (!items || items.length === 0) return null;

  // Index fetched rows by id, then render in REF order — refs carry the
  // intended sequence (suggestion mode: the engine's tier order, so
  // same-platform/tag-matched items lead; controlled mode: the curated
  // display_order). The list APIs return rows date-sorted, which would
  // otherwise scramble that ordering (same-platform items sinking below
  // newer cross-platform ones).
  // Shared extractor (NOT raw `.id`) — some API shapes key items differently
  // (e.g. external_id types); raw access would silently drop valid items.
  const itemById = new Map(
    (items as any[]).map((it) => [extractItemId(type, it) ?? String((it as any)?.id), it]),
  );

  const cards = visibleGroupRefs
    .map((contentRef) => {
      const itemId = String(contentRef.id);
      const item = itemById.get(itemId);
      if (!item) return null;
      // Re-compose the URL via the host's resolver (hub: buildContentURL so
      // dev gets localhost and prod the right platform domain; default: the
      // ref's stored url as the API composed it).
      const resolved = resolveHref(contentRef);
      const href = resolved.href ?? '';
      const targetPlatform = resolved.targetPlatform ?? contentRef.targetPlatform ?? null;
      return (
        <div key={itemId}>
          <LinkProvider href={href || null} targetPlatform={targetPlatform}>
            {(linkProps) => (
              <CardForType
                type={type}
                item={item}
                size={cardSize}
                href={href}
                targetPlatform={targetPlatform}
                linkProps={linkProps}
                extras={extras}
                adminCampaignCard={adminCampaignCard}
              />
            )}
          </LinkProvider>
        </div>
      );
    })
    .filter(Boolean);

  if (cards.length === 0) {
    // Current PAGE resolved zero cards (rows deleted between the ref fetch
    // and the group fetch, or a stricter list-API gate dropped them). When a
    // pager exists the user must keep the controls to navigate back —
    // dropping the whole group would strand them. A genuinely empty group
    // (no pager) still vanishes with its heading.
    if (groupPagination) {
      return (
        <div className="space-y-4">
          {heading}
          {groupPagination}
        </div>
      );
    }
    return null;
  }

  return (
    <div className="space-y-4">
      {heading}
      {isListLayout ? (
        <div className="space-y-4">{cards}</div>
      ) : (
        <div className={gridClassFor(columns)}>{cards}</div>
      )}
      {groupPagination}
    </div>
  );
}

// =============================================================================
// Main component
// =============================================================================

interface RelatedContentResponse {
  refs: ContentRefWithReason[];
}

export interface RelatedContentSectionProps {
  /** CONTROLLED mode (the original behavior). When defined — even `[]` — no
   *  suggestion fetch runs and exactly these refs render. */
  contentRefs?: ContentRef[];
  /** SUGGESTION mode (with `entityId`): self-fetch suggestions for this host
   *  entity from `{apiBaseUrl}/api/related-content`. Ignored when
   *  `contentRefs` is provided. */
  entityType?: string;
  entityId?: number | string;
  /** AUTHOR mode: self-fetch ALL published content authored by this profile
   *  from `{apiBaseUrl}/api/related-content?authorId=…` (grouped per type,
   *  endless within each group). Ignored when `contentRefs` is provided;
   *  takes precedence over the entityType/entityId suggestion scope.
   *  SSR-hydrate via `initialItems`, same as suggestion mode. */
  authorId?: string;
  /** Maps to the suggestion API's `count` param — the PER-TYPE fill target
   *  for every candidate type EXCEPT the host's own. Absent → param not sent
   *  (server default applies). */
  minResults?: number;
  /** Maps to the suggestion API's `sameTypeCount` param — the budget for the
   *  candidate type MATCHING the host's own `entityType` (same-type boost:
   *  a blog post's rail leads with more blog posts). Absent → param not
   *  sent (host's type uses the server's `count`). */
  sameTypeMinResults?: number;
  /** SSR hydrate for suggestion mode — the server page ran the engine and
   *  drills the refs here; the first client fetch is skipped (useSelfFetch
   *  initialData contract). */
  initialItems?: ContentRefWithReason[];
  /** Section title (default: "Related Content") */
  title?: string;
  /**
   * Grid columns at desktop. 2 = denser cards / wider summary (original
   * investor-update layout); 3 = more cards per row for dashboards.
   * Only consulted for grid-layout groups. Default: 2.
   */
  columns?: 2 | 3;
  /**
   * ContentRef.type values to exclude. Honored in ALL modes — controlled
   * mode post-filters (original behavior); suggestion mode ALSO forwards the
   * list verbatim as the API's `excludeTypes=` param so excluded types never
   * consume engine fill slots (`minResults` stays honored). The subtraction
   * happens SERVER-side — this component never mirrors the hub's candidate
   * list.
   */
  excludeTypes?: string[];
  /**
   * SUGGESTION-mode allow-list (rail vocabulary): which content types
   * participate in this rail. Sent verbatim as the API's `types=` param —
   * the SERVER intersects it with its own allowed candidate set, and
   * platform policy gates (e.g. internal-only types) ALWAYS win: the client
   * cannot request its way past them. Absent → all server-side candidates.
   */
  includeTypes?: string[];
  /** Fetch-URL prefix for third-party embeds / reverse proxies
   *  ('' = same-origin). Applied to BOTH the suggestion fetch and the
   *  default per-group list fetches. */
  apiBaseUrl?: string;
  /** Host injection bundle — REUSES the chat dispatcher's
   *  `ChatCardDispatchExtras` (programConfigs, buildOgPlaceholderUrl,
   *  buildProductReleaseCardProps override). Program groups render nothing
   *  when their config is absent. */
  extras?: ChatCardDispatchExtras;
  /** Hub injects its `buildContentURL` recomposition; default uses the
   *  ref's stored `url`/`targetPlatform` as the API composed them. */
  resolveHref?: (ref: ContentRef) => { href: string | null; targetPlatform: string | null };
  /** Hub injects its registry-driven entity-list-api builder; default = the
   *  lib's `buildListUrl(type, ids, apiBaseUrl)`. */
  buildListUrl?: (type: string, ids: string[]) => string | null;
  /** Hub injects a `useNavLink`-backed render-prop provider; default = pure
   *  anchor via `decideNewTab`. MUST be a module-scope component. */
  LinkProvider?: CardLinkProvider;
  /** Renderer pair for the admin-only `marketing_campaign` type. Absent →
   *  the type renders nothing. */
  adminCampaignCard?: AdminCampaignCardSlot;
  /** When true, render the section shell (title + an empty-state line) even
   *  with ZERO refs, instead of returning null. Default false (the original
   *  behavior — empty rail = no shell). Opt-in per host page (e.g. people-hub's
   *  "What I Shipped", where the section should always be present). */
  showWhenEmpty?: boolean;
  /** Empty-state copy shown under the title when `showWhenEmpty` and no refs.
   *  Default: "No related content yet." */
  emptyStateText?: string;
  /** Custom empty-state node (e.g. a hub `<EmptyState/>`) rendered under the
   *  title when `showWhenEmpty` and there are no refs — overrides
   *  `emptyStateText`. Lets a host match its canonical empty state. */
  emptyState?: React.ReactNode;
}

export function RelatedContentSection({
  contentRefs,
  entityType,
  entityId,
  authorId,
  minResults,
  sameTypeMinResults,
  includeTypes,
  initialItems,
  title = 'Related Content',
  columns = 2,
  excludeTypes,
  apiBaseUrl = '',
  extras,
  resolveHref = defaultResolveHref,
  buildListUrl,
  LinkProvider = DefaultLinkPropsProvider,
  adminCampaignCard,
  showWhenEmpty = false,
  emptyStateText = 'No related content yet.',
  emptyState,
}: RelatedContentSectionProps) {
  // ── Hooks above EVERY early return (the original `if (!contentRefs.length)
  // return null` guard moved below them). ──

  // Suggestion-mode fetch URL — null in controlled mode (contentRefs defined,
  // even []) or when the entity scope is incomplete.
  // `includeTypes: []` is an explicit "nothing participates" — skip the fetch
  // entirely (an empty-string `types=` param would be dropped by the URL
  // builder and read server-side as "all candidates") AND ignore SSR refs.
  const suggestionsDisabled = includeTypes?.length === 0;
  // Shared type-filter params — one spelling for both fetch modes so a future
  // normalization (trim/dedupe) can't diverge between them.
  const typeFilterParams = {
    types: includeTypes !== undefined ? includeTypes.join(',') : undefined,
    excludeTypes: excludeTypes && excludeTypes.length > 0 ? excludeTypes.join(',') : undefined,
  };
  // AUTHOR mode beats suggestion mode: when `authorId` is set the rail lists
  // everything that profile authored (the server returns ALL, no count).
  const authorUrl =
    contentRefs === undefined && authorId && !suggestionsDisabled
      ? buildSuggestionUrl('/api/related-content', {
          apiBaseUrl,
          extraParams: { authorId, ...typeFilterParams },
        })
      : null;
  const suggestUrl =
    authorUrl ??
    (contentRefs === undefined &&
    entityType &&
    entityId !== undefined &&
    entityId !== null &&
    entityId !== '' &&
    !suggestionsDisabled
      ? buildSuggestionUrl('/api/related-content', {
          apiBaseUrl,
          entityType,
          entityId,
          count: minResults,
          extraParams: {
            sameTypeCount: sameTypeMinResults !== undefined ? String(sameTypeMinResults) : undefined,
            ...typeFilterParams,
          },
        })
      : null);
  // Memoize the initialData wrapper — useSelfFetch re-syncs on [initialData],
  // and a fresh per-render object would loop setState under re-rendering
  // parents (the latent FaqSection bug, fixed there in the same change).
  const initialData = useMemo<RelatedContentResponse | undefined>(
    // An explicitly disabled rail (includeTypes: []) must ignore SSR-hydrated
    // refs too — otherwise useSelfFetch(null, {initialData}) keeps serving
    // initialItems and the "nothing participates" contract silently breaks.
    () => (!suggestionsDisabled && initialItems ? { refs: initialItems } : undefined),
    [initialItems, suggestionsDisabled],
  );
  const { data, isLoading } = useSelfFetch<RelatedContentResponse>(suggestUrl, { initialData });

  // Default group fetcher: the lib's byte-parity-tested builder, prefixed for
  // embeds. Memoized so group-fetch URLs stay value-stable across renders.
  const effectiveBuildListUrl = useMemo(
    () => buildListUrl ?? ((type: string, ids: string[]) => libBuildListUrl(type, ids, apiBaseUrl)),
    [buildListUrl, apiBaseUrl],
  );

  const refs: ContentRef[] = contentRefs ?? data?.refs ?? [];

  // Per-consumer type gating — drops refs whose `type` is in the exclude
  // list. In suggestion mode the server already subtracted these (the param
  // is forwarded above); the client filter stays as an idempotent guard and
  // IS the mechanism in controlled mode (original behavior).
  const exclude = new Set(excludeTypes || []);
  const visibleRefs = exclude.size > 0 ? refs.filter((r) => !exclude.has(r.type)) : refs;
  // Zero refs (still loading in suggestion mode, or genuinely empty). Default:
  // no empty shell. Opt-in (`showWhenEmpty`): render the title + an empty-state
  // line so the section is always present (e.g. people-hub "What I Shipped").
  if (!visibleRefs.length) {
    if (!showWhenEmpty) return null; // non-showWhenEmpty consumers stay blank (unchanged)
    // Client-fetch loading (author/suggestion mode, no SSR initialItems): render a
    // SKELETON grid — reserves height + matches the rest of the app's loading, so
    // there's no blank-then-pop jump (the prior `return null` collapsed the tab to
    // zero height during the fetch). SSR controlled mode has isLoading=false → it
    // skips straight to the empty state below. Skeleton type = the requested rail
    // type (author mode passes a single `includeTypes`).
    if (isLoading) {
      const skeletonType = includeTypes?.[0] ?? entityType ?? 'blog_post_existing';
      return (
        <div className="space-y-8">
          <h2 className="text-h2 text-ods-text-primary">{title}</h2>
          <div className={gridClassFor(columns)}>
            {Array.from({ length: 3 }).map((_, i) => (
              <div key={i}>{renderSkeletonForType(skeletonType, 'default', adminCampaignCard)}</div>
            ))}
          </div>
        </div>
      );
    }
    return (
      <div className="space-y-8">
        <h2 className="text-2xl font-bold text-ods-text-primary">{title}</h2>
        {emptyState ?? <p className="text-ods-text-secondary">{emptyStateText}</p>}
      </div>
    );
  }

  const grouped: Record<string, ContentRef[]> = {};
  for (const ref of visibleRefs) {
    if (!grouped[ref.type]) grouped[ref.type] = [];
    grouped[ref.type].push(ref);
  }

  // Registered types in CONTENT_REF_GROUPS order, then any unregistered
  // types appended (same shape the investor-email builder uses — both
  // consume `orderContentRefTypes` so cross-surface ordering matches).
  // SAME-TYPE FIRST: when a host entityType is known (suggestion / SSR
  // modes), its own content-type group is hoisted to the top — a blog
  // post's rail leads with blog posts. Rail group keys are compared via
  // the shared alias canonicalizer (blog_post_existing ↔ blog_post).
  let orderedTypes = orderContentRefTypes(Object.keys(grouped));
  if (entityType) {
    // Canonicalize BOTH sides — hosts pass registry vocab ('blog_post') but
    // rail-vocab aliases ('blog_post_existing') are also legal inputs; a raw
    // comparison would silently lose the same-type-first hoist for aliases.
    const canonicalEntityType = canonicalContentRefType(entityType);
    const sameType = orderedTypes.filter((t) => canonicalContentRefType(t) === canonicalEntityType);
    if (sameType.length > 0) {
      orderedTypes = [...sameType, ...orderedTypes.filter((t) => canonicalContentRefType(t) !== canonicalEntityType)];
    }
  }

  return (
    <div className="space-y-8">
      <h2 className="text-h2 text-ods-text-primary">{title}</h2>
      {orderedTypes.map((type) => (
        <ContentGroup
          key={type}
          type={type}
          refs={grouped[type]}
          columns={columns}
          buildUrl={effectiveBuildListUrl}
          resolveHref={resolveHref}
          LinkProvider={LinkProvider}
          extras={extras}
          adminCampaignCard={adminCampaignCard}
          heading={
            <h3 className="text-h5 font-semibold text-ods-text-secondary">
              {getContentRefLabelOrTitleCase(type)}
            </h3>
          }
        />
      ))}
    </div>
  );
}
