const { useState, useMemo, useEffect, useRef, Fragment } = React;

// ═══════════════════════════════════════════════════════════════
// STAT GRID — sheet-driven label/value pairs (detailStats)
// ═══════════════════════════════════════════════════════════════
// Visually matches the Project Overview's stat grid (see <Metric/> below):
// body font, 13px values at weight 400, 10px lowercase muted labels, 14px gap.
// Caller passes muted / text colors so dark-mode resolves correctly.
// CAPITAL CALL SCHEDULE — the obligation, as opposed to the J-curve's shape.
// The chart above answers "when is my money at risk"; this answers "what do I
// wire, and when", which is the thing a reader forwards to their accountant.
//
// `pct` is a share of commitment because neither project model carries a
// per-investor schedule, so percentages are the only figures true at every
// check size. Multiplying by the panel's own `amount` means the column tracks
// the calculator the visitor has already set rather than a fixed example.
//
// A single-call schedule (both loans fund in one advance) renders as one line
// of prose, not a one-row table.
function CallSchedule({ calls, amount, lang, t, muted, text, border }) {
  if (!Array.isArray(calls) || calls.length === 0) return null;

  // A single-call schedule is both loans. It gets prose rather than a one-row
  // table. The copy deliberately describes the facility's form (drawn against
  // milestones, as the project needs it) rather than naming the advance month,
  // so it does not read as a fixed lump sum (Chris, 2026-09-11).
  if (calls.length === 1) {
    return (
      <p className="r-detail-body-p" style={{ marginTop: 8, color: muted }}>
        {t.callScheduleSingle}
      </p>
    );
  }

  const total = calls.reduce((a, c) => a + (Number(c.pct) || 0), 0);
  const cell = { padding: "5px 10px", borderBottom: `1px solid ${border}`, textAlign: "right" };
  const head = {
    ...cell,
    textAlign: "right",
    fontFamily: "var(--font-mono)",
    fontSize: 9.5,
    letterSpacing: "0.12em",
    textTransform: "uppercase",
    color: muted,
    fontWeight: 400,
  };

  return (
    <div style={{ marginTop: 20 }}>
      <h4 className="r-detail-h4" style={{ color: text, margin: "0 0 4px" }}>
        {t.callScheduleHeading}
      </h4>
      <p style={{ margin: "0 0 10px", fontSize: 12.5, color: muted, maxWidth: "60ch" }}>
        {t.callScheduleNote}
      </p>
      <div style={{ overflowX: "auto" }}>
        <table style={{ borderCollapse: "collapse", width: "100%", fontSize: 12.5 }}>
          <thead>
            <tr>
              <th style={{ ...head, textAlign: "left" }}>{t.callScheduleCall}</th>
              <th style={{ ...head, textAlign: "left" }}>{t.callScheduleDate}</th>
              <th style={head}>{t.callSchedulePct}</th>
              <th style={head}>{t.callScheduleAmount}</th>
            </tr>
          </thead>
          <tbody>
            {calls.map((c, i) => {
              // Flag the call that is more than a third of the commitment. On
              // both projects that is the month the land closes, and it is the
              // one row a reader must not skim past.
              const heavy = Number(c.pct) > 33;
              return (
                <tr key={c.date} style={heavy ? { fontWeight: 500 } : null}>
                  <td style={{ ...cell, textAlign: "left", fontVariantNumeric: "tabular-nums" }}>{i + 1}</td>
                  <td style={{ ...cell, textAlign: "left" }}>
                    {window.formatLocalizedDate(c.date, lang)}
                  </td>
                  <td style={{ ...cell, fontVariantNumeric: "tabular-nums" }}>
                    {Number(c.pct).toFixed(2)}%
                  </td>
                  <td style={{ ...cell, fontVariantNumeric: "tabular-nums" }}>
                    {window.formatCurrency(Math.round((amount * Number(c.pct)) / 100))}
                  </td>
                </tr>
              );
            })}
          </tbody>
          <tfoot>
            <tr style={{ fontWeight: 500 }}>
              <td style={{ ...cell, textAlign: "left", borderBottom: "none" }}/>
              <td style={{ ...cell, textAlign: "left", borderBottom: "none" }}>{t.callScheduleTotal}</td>
              <td style={{ ...cell, borderBottom: "none", fontVariantNumeric: "tabular-nums" }}>
                {total.toFixed(2)}%
              </td>
              <td style={{ ...cell, borderBottom: "none", fontVariantNumeric: "tabular-nums" }}>
                {window.formatCurrency(amount)}
              </td>
            </tr>
          </tfoot>
        </table>
      </div>
    </div>
  );
}

function StatGrid({ stats, lang, muted, text }) {
  if (!stats || stats.length === 0) return null;
  return (
    <div className="r-stat-grid">
      {stats.map((s, i) => {
        const label = lang === "es" && s.labelEs ? s.labelEs : s.labelEn;
        const value = lang === "es" && s.valueEs ? s.valueEs : s.valueEn;
        return (
          <div key={i}>
            <div className="r-detail-metric-label" style={{ color: muted }}>{label}</div>
            <div className="r-detail-metric-value" style={{ color: text }}>{value}</div>
          </div>
        );
      })}
    </div>
  );
}

// RETURN BAR: a note's return as base, target and cap (product.returnStructure;
// the target is targetReturn.irr). The scale runs from 0 to the cap: the base
// rate is the solid segment, lime is the participation up to the target, and
// the hatched rest is the room left to the cap. Zamora 15's target sits on its
// cap, so its lime runs to the end. Chris picked this layout (option B) on
// 2026-09-24; scripts/product-schema.ts keeps the three numbers in step with
// the copy.
function ReturnBar({ structure, target, t, text, muted, dark }) {
  const { basePct, capPct } = structure;
  const pos = (v) => `${(v / capPct) * 100}%`;
  const label = (key, v) => `${t[key]} ${v}%`;
  const share = target / capPct;
  const align = share >= 0.85 ? "end" : share <= 0.15 ? "start" : "center";
  return (
    <>
    <div
      className="r-rs"
      role="img"
      aria-label={[label("returnBarBase", basePct), label("returnBarTarget", target), label("returnBarCap", capPct)].join(", ")}
      style={{ "--rs-ink": text, "--rs-hatch": dark ? "rgba(250,247,243,0.18)" : "rgba(57,52,51,0.16)" }}
    >
      <div className={`r-rs-target r-rs-align-${align}`} style={{ left: pos(target), color: text }}>
        {label("returnBarTarget", target)}
      </div>
      <div className="r-rs-track">
        <div className="r-rs-kicker" style={{ left: pos(basePct), width: pos(target - basePct) }} />
        <div className="r-rs-base" style={{ width: pos(basePct) }} />
        <div className={`r-rs-marker${share >= 1 ? " r-rs-marker-end" : ""}`} style={{ left: pos(target) }} />
      </div>
      <div className="r-rs-scale">
        <span style={{ color: muted }}>0%</span>
        <span className="r-rs-base-label" style={{ left: pos(basePct), color: text }}>{label("returnBarBase", basePct)}</span>
        <span style={{ color: text }}>{label("returnBarCap", capPct)}</span>
      </div>
    </div>
    {/* The base is a simple rate while target and cap are IRRs; this line
        restates the base as an IRR on the model's dates (Chris, 2026-09-24). */}
    {structure.baseIrrPct != null && (
      <p className="r-rs-note" style={{ color: muted }}>
        {window.formatTemplate(t.returnBarNote, { irr: structure.baseIrrPct })}
      </p>
    )}
    </>
  );
}

// ═══════════════════════════════════════════════════════════════
// SCROLLSPY NAV — sticky tab bar with a sliding lime active-indicator.
// Same widget on desktop + mobile (scrolls horizontally on a phone).
// The indicator moves via transform: translateX/scaleX (never width), routed
// through window.MOTION so reduced-motion degrades to an instant move.
// ═══════════════════════════════════════════════════════════════
// Height (px) of the sticky nav bar. jumpTo() lands a section's top just below
// it, and the active-section detector reads the same line — keep them equal so
// "click a tab" and "the resting highlight" always agree.
const PANEL_NAV_OFFSET = 52;

function ScrollspyNav({ sections, activeId, onJump, dark, border, pin }) {
  const barRef = React.useRef(null);
  const btnRefs = React.useRef({});
  const text = dark ? "#FAF7F3" : "#393433";
  const muted = dark ? "#8A827F" : "#6B6361";
  const hover = dark ? "#22201E" : "#F6F2EC";

  // Keep the active tab in view on the horizontal chip bar (mobile).
  React.useLayoutEffect(() => {
    const btn = btnRefs.current[activeId];
    const bar = barRef.current;
    if (!btn || !bar) return;
    const w = btn.offsetWidth;
    if (btn.offsetLeft < bar.scrollLeft || btn.offsetLeft + w > bar.scrollLeft + bar.clientWidth) {
      btn.scrollIntoView({ inline: "center", block: "nearest", behavior: window.MOTION && window.MOTION.reduce ? "auto" : "smooth" });
    }
  }, [activeId, sections.length]);

  return (
    <div
      className="r-panel-nav"
      ref={barRef}
      role="tablist"
      aria-label="Sections"
      style={{
        background: dark ? "#1A1817" : "#FFFFFF",
        borderBottom: `1px solid ${border}`,
        "--rp-border": border,
        "--rp-text": text,
        "--rp-muted": muted,
        "--rp-hover": hover,
        // The bar's own surface, exposed so the pin's ::before can repaint the
        // scrollport strip in the BAR's colour rather than its own. The pin is
        // an inverted badge now, so `background: inherit` on that pseudo would
        // drag a dark strip across the bar's left padding.
        "--rp-surface": dark ? "#1A1817" : "#FFFFFF",
      }}
    >
      {/* Which product you are reading, held in view once the title scrolls
          away. Deliberately NOT a .r-panel-nav-tab and NOT role="tab":
          scripts/panel.smoke.ts clicks the LAST tab in this bar, and the
          indicator maths counts tabs. aria-hidden because the panel title,
          the subhead and the section heading all already name the tier, so
          this is a visual reminder rather than new information. */}
      {pin && (
        <span className="r-panel-nav-pin" aria-hidden="true">
          {pin}
        </span>
      )}
      {sections.map((s) => (
        <button
          key={s.id}
          ref={(el) => { btnRefs.current[s.id] = el; }}
          type="button"
          role="tab"
          aria-selected={activeId === s.id}
          className={"r-panel-nav-tab" + (activeId === s.id ? " is-active" : "")}
          onClick={() => onJump(s.id)}
        >
          {s.label}
        </button>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════
// DETAIL SLIDE-OVER
// ═══════════════════════════════════════════════════════════════
function DetailPanel({ product, col, mode = "offering", onClose, onOpenProject, t, lang, dark, profile, allProducts, onSelectProduct }) {
  const isProject = mode === "project";
  const [amount, setAmount] = useState(product?.minimumInvestment.amount || 100000);
  useEffect(() => { if (product) setAmount(product.minimumInvestment.amount); }, [product?.id]);

  // Hide the J-curve below 700px — tick labels overlap and the chart stops
  // reading. The timeline section renders the chart at the panel inner width.
  const TIMELINE_MIN_VW = 700;
  const PANEL_MAX_W = 960; // keep in sync with .r-detail width in index.html
  const [vw, setVw] = useState(
    typeof window !== "undefined" ? window.innerWidth : 1200
  );
  useEffect(() => {
    const onResize = () => setVw(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  const showTimeline = vw >= TIMELINE_MIN_VW;
  // Chart fills the panel content column (panel width − 28px padding each side).
  const timelineWidth = Math.min(PANEL_MAX_W, vw) - 56;

  const show = isProject ? !!col : !!product;

  // Lock the page behind the overlay while it's open (mobile is a normal page
  // scroll, so the sheet's scroll would otherwise chain into the feed). Pairs
  // with overscroll-behavior:contain on .r-detail.
  useEffect(() => {
    if (!show) return;
    const el = document.documentElement;
    el.classList.add("r-overlay-open");
    return () => el.classList.remove("r-overlay-open");
  }, [show]);

  // ESC closes the panel
  useEffect(() => {
    if (!show) return;
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [show, onClose]);

  // Focus management (a11y): move focus into the panel when it opens, restore
  // to the previously-focused element on close.
  const panelRef = useRef(null);
  const lastFocusRef = useRef(null);
  const wasOpenRef = useRef(false);
  useEffect(() => {
    if (show && !wasOpenRef.current) {
      wasOpenRef.current = true;
      lastFocusRef.current = document.activeElement;
      if (panelRef.current) panelRef.current.focus();
    } else if (!show && wasOpenRef.current) {
      wasOpenRef.current = false;
      if (lastFocusRef.current && lastFocusRef.current.focus) lastFocusRef.current.focus();
    }
  }, [show]);

  const text = dark ? "#FAF7F3" : "#393433";
  const muted = dark ? "#8A827F" : "#6B6361";
  const faint = dark ? "#5A5654" : "#C1C1C1";
  const border = dark ? "#2A2825" : "#E2E0DF";
  const bg = dark ? "#1A1817" : "#FFFFFF";
  const surface = dark ? "#0D0D0D" : "#FAF7F3";
  const accent = "#4866B2";
  const lime = "#EDFE38";

  // Determine check-size-aware slider max (sheet-overridable via SETTINGS.checkMax).
  const checkMax = window.SETTINGS?.checkMax ?? { "$50k-$100k": 100000, "$100k-$500k": 500000, "$500k-$1M": 1000000, "$1M+": 2000000 };
  const profileCap = profile && profile.check ? checkMax[profile.check] : null;
  const sliderMax = product
    ? Math.max(product.minimumInvestment.amount * 2, profileCap || 2000000)
    : 2000000;

  // Single source of truth: defer to buildTimelineData so the Calculator
  // headline and the J-curve terminal value never disagree. For LT-hold this
  // means projectedExit includes appreciated NAV (matches the dashed
  // "Total holder value" line on the chart, not the flat yield-only formula
  // that used to live here).
  const projectedExit = useMemo(() => {
    if (!product) return 0;
    try {
      const data = window.buildTimelineData && window.buildTimelineData(product, amount);
      if (data && data.scenarios && data.scenarios.mid) return data.scenarios.mid.totalReturn;
    } catch (e) {
      // Fall through to legacy formula if timeline build can't handle the type.
    }
    const r = product.targetReturn;
    const [minY, maxY] = product.holdPeriodYears;
    const midY = (minY + maxY) / 2;
    if (r.multiple) return amount * ((r.multiple[0] + r.multiple[1]) / 2);
    if (r.irr) return amount * Math.pow(1 + ((r.irr[0] + r.irr[1]) / 2) / 100, midY);
    if (r.yield) return amount * (1 + ((r.yield[0] + r.yield[1]) / 2) / 100 * midY);
    return amount;
  }, [product, amount]);

  const tier = product ? window.productTier(product) : null;
  const tierIdx = tier ? TIER_ORDER.indexOf(tier) : -1;
  // Which product you are looking at, in words. Same expression the subhead
  // uses so the two never disagree, but WITHOUT the tier glyph: the emoji
  // exception in CLAUDE.md covers exactly two surfaces, and this is neither.
  const tierWord = product
    ? ((product.headerLabel && window.pickLang(product.headerLabel, lang))
        || (tier && TIER_LABELS[tier] && TIER_LABELS[tier][lang])
        || "")
    : "";

  // Eligibility for current product
  const el = useMemo(() => product ? window.checkEligibility(product, profile || {}) : null, [product, profile]);
  const filterReasons = el && Array.isArray(el.reasons)
    ? el.reasons.filter(r => r && typeof r === "object" && r.filterKey)
    : [];
  const showFilteredOut = filterReasons.length > 0;
  // Strategic-only (OPCO/DEVCO): they're never "eligible" — they're fully
  // funded — but the notice copy and color flip based on whether the
  // investor's check size meets the conversation threshold.
  const isStrategic = !!(el && el.strategicOnly);
  const strategicReady = !!(isStrategic && el.strategicActive);
  // Suppress the threshold warning entirely when no profile inputs are set —
  // there's no mismatch to flag yet. Once the user picks anything, the
  // notice reads as a real signal rather than noise on a blank profile.
  const profileSet = (window.countProfile ? window.countProfile(profile) : 0) > 0;

  // Display status: strategic offerings always render as "funded" (matches the
  // matrix tower head). Otherwise fall back to the product's sheet status.
  // The funded display also fires when the raise is fully committed.
  const isFundedDisplay = product && (
    product.strategicOnly ||
    (product.raiseSizeTargetUsd > 0 && product.raiseSizeCommittedUsd >= product.raiseSizeTargetUsd)
  );
  const displayStatus = isFundedDisplay ? "funded" : (product?.status || "open");

  // Project-level "reminder" facts — independent of the offering. Pulled from
  // 01-Projects via the importer (denormalized onto each product). Hidden for
  // portfolio entities (OpCo/DevCo) that don't carry project economics.
  // Source for the project-overview block. In offering mode this is the clicked
  // offering; in project mode (`product` is null) we borrow the denormalized
  // project-level fields — identical across every tranche — from a representative
  // product in the collection, so the project panel reuses the same overview.
  const sourceProduct = isProject
    ? ((col?.products || []).find(p =>
        window.pickLang(p.projectOverview, lang) ||
        (p.projectDetailStats && p.projectDetailStats.length)
      ) || (col?.products || [])[0] || null)
    : product;
  const projectKindLabelText = sourceProduct ? window.pickLang(sourceProduct.projectKindLabel, lang) : "";
  const showProjectDetails = !!sourceProduct
    && sourceProduct.projectKind !== "portfolio"
    && (sourceProduct.projectTotalCostUsd > 0 || projectKindLabelText || sourceProduct.projectProgramSummary);
  // Sheet-driven project stats from 02-ProjectStats; preferred over the
  // legacy typed Metric grid when populated.
  const projectStatRows = (sourceProduct?.projectDetailStats && sourceProduct.projectDetailStats.length > 0)
    ? sourceProduct.projectDetailStats : null;
  const showAnyProjectStats = !!projectStatRows || showProjectDetails;
  // Development timeline: prefer the construction-debt sibling's hold (which
  // matches the build window). Fall back to the source product's hold[1].
  const devTimelineYears = useMemo(() => {
    if (!sourceProduct) return null;
    // The build window used to be read off the construction-debt sibling. Those
    // offerings were retired, so derive it from the project's development-stage
    // offerings instead, excluding the long-term hold and the unit pre-sale whose
    // horizons are not the build window. Reproduces the prior values (GD207 3,
    // Z15 3, Padre Mier 4) without depending on a product existing.
    const yrs = (allProducts || [])
      .filter(p => p.project === sourceProduct.project
        && p.type !== "long-term-hold" && p.type !== "pre-sale"
        && p.holdPeriodYears)
      .map(p => p.holdPeriodYears[1])
      .filter(n => n > 0);
    if (yrs.length) return Math.min(...yrs);
    return sourceProduct.holdPeriodYears ? sourceProduct.holdPeriodYears[1] : null;
  }, [sourceProduct, allProducts]);

  // Other tranches in this project — used by the "Position in stack" nav.
  // Filtered to offerings the profile qualifies for (or strategic-only).
  const siblings = useMemo(() => {
    if (!product || !allProducts) return [];
    return allProducts.filter(p => {
      if (p.project !== product.project) return false;
      if (p.id === product.id) return false;
      const sel = window.checkEligibility(p, profile);
      return sel.eligible || sel.strategicOnly;
    });
  }, [product, allProducts, profile]);

  // Offerings in the same DisplayGroup as the clicked one. Empty profile shows
  // them all; as filters narrow, the ineligible ones drop out. Includes the
  // active product so the user sees all options under that tier together.
  const tierMates = useMemo(() => {
    if (!product || !allProducts) return [];
    return allProducts.filter(p => {
      if (p.project !== product.project) return false;
      if (p.displayGroupId !== product.displayGroupId) return false;
      if (p.id === product.id) return true;
      const sel = window.checkEligibility(p, profile);
      return sel.eligible || sel.strategicOnly;
    });
  }, [product, allProducts, profile]);

  // ─────────────────────────────────────────────────────────────
  // Scrollspy: project-level content + section model.
  // ─────────────────────────────────────────────────────────────
  const projectName = isProject ? (col && col.name) : (product && product.project);
  const projectContent = projectName && window.PROJECT_CONTENT
    ? window.PROJECT_CONTENT[projectName]
    : null;

  // Resolve project media into window.Carousel item shape (alt is bilingual).
  const mediaItems = useMemo(() => {
    if (!projectContent || !Array.isArray(projectContent.media)) return [];
    return projectContent.media.map((m) => ({
      image: m.image, video: m.video, poster: m.poster,
      width: m.width, height: m.height,
      alt: window.pickLang ? window.pickLang(m.alt, lang) : (m.alt && (m.alt[lang] || m.alt.en)) || "",
      caption: m.caption ? (window.pickLang ? window.pickLang(m.caption, lang) : (m.caption[lang] || m.caption.en)) : "",
    }));
  }, [projectContent, lang]);
  const [mediaIndex, setMediaIndex] = useState(0);
  useEffect(() => { setMediaIndex(0); }, [product?.id, col?.name, mode]);
  const handleMediaIndex = (i) => {
    setMediaIndex(i);
    if (product && window.visitorTracker && window.visitorTracker.onPanelMediaView) {
      window.visitorTracker.onPanelMediaView(product.id, i);
    }
  };

  // Which sections actually render (drives the tab bar + observer). Order
  // matches the DOM order below. Keep ids stable — they're the tracking
  // `section` value and the smooth-scroll anchors.
  const hasMedia = mediaItems.length > 0;
  const hasHighlights = !!(projectContent && projectContent.highlights
    && Array.isArray(projectContent.highlights.bullets)
    && projectContent.highlights.bullets.some(b => window.pickLang(b, lang)));
  const hasImpact = !!(projectContent && projectContent.impact && window.pickLang(projectContent.impact.body, lang));
  const hasPlan = !!(projectContent && projectContent.plan && window.pickLang(projectContent.plan.body, lang));
  const hasOfferingProjectOverview = !!(product && (showAnyProjectStats || window.pickLang(product.projectOverview, lang)));
  // Every `tax` block written so far describes U.S. treatment and addresses the reader in the
  // second person ("your U.S. federal tax"), so it is gated to U.S. residents rather than to
  // "residency is set at all". If an MX-audience tax block is ever written, this gate is why the
  // section does not appear: widen it here (a per-product audience field) rather than deleting it.
  const hasTax = !!(product && profile && profile.residency === "us" && window.pickLang(product.tax, lang));
  // Project-panel FAQ entries. Keyed off the stable product-id prefix
  // (e.g. "GD207-EQ-MX" → "GD207"), not col.name — names are SSOT-editable and
  // have broken matching before. An entry appears here when its `projects`
  // list includes this key (per the source doc's tagging table).
  const faqProjectKey = (isProject && col && Array.isArray(col.products) && col.products[0])
    ? String(col.products[0].id).split("-")[0]
    : null;
  const panelFaq = useMemo(
    () => (isProject && faqProjectKey && window.faqEntries)
      ? window.faqEntries({ project: faqProjectKey, profile })
      : [],
    [isProject, faqProjectKey, profile]
  );
  const hasFaq = panelFaq.length > 0;
  const sections = useMemo(() => {
    if (isProject) {
      if (!col) return [];
      const out = [];
      out.push({ id: "project", label: lang === "es" ? "Resumen" : "Overview" });
      if (hasImpact) out.push({ id: "impact", label: window.pickLang(projectContent.impact.heading, lang) || (lang === "es" ? "Impacto" : "Impact") });
      if (hasHighlights) out.push({ id: "highlights", label: lang === "es" ? "Puntos destacados" : "Highlights" });
      if (hasPlan) out.push({ id: "plan", label: lang === "es" ? "Plan de desarrollo" : "Development Plan" });
      if (hasMedia) out.push({ id: "photos", label: lang === "es" ? "Fotos" : "Photos" });
      if (hasFaq) out.push({ id: "faq", label: lang === "es" ? "Preguntas frecuentes" : "FAQ" });
      out.push({ id: "invest", label: lang === "es" ? "Cómo invertir" : "Ways to invest" });
      return out;
    }
    if (!product) return [];
    const out = [];
    if (hasOfferingProjectOverview) out.push({ id: "project", label: lang === "es" ? "Resumen del proyecto" : "Project Overview" });
    out.push({ id: "offering", label: lang === "es" ? "Resumen de inversión" : "Investment Overview" });
    out.push({ id: "terms", label: lang === "es" ? "Estructura y términos" : "Structure & terms" });
    if (hasTax) out.push({ id: "tax", label: lang === "es" ? "Impuestos" : "Tax" });
    return out;
  }, [product?.id, col?.name, mode, lang, hasMedia, hasHighlights, hasImpact, hasPlan, hasFaq, hasOfferingProjectOverview, hasTax]);

  const scrollRootRef = useRef(null);
  const sectionRefs = useRef({});
  const [activeSection, setActiveSection] = useState(null);
  const dwellRef = useRef({}); // section id -> timestamp it entered view
  // While a tab-click smooth-scroll is in flight the click owns the highlight;
  // the scroll detector defers to it so it can't be reassigned mid-animation.
  const jumpLockRef = useRef(false);
  const jumpTimerRef = useRef(null);

  useEffect(() => { setActiveSection(sections[0] ? sections[0].id : null); }, [product?.id, col?.name, mode]);

  // IntersectionObserver drives per-section dwell tracking (panel_section_viewed
  // fired when a section leaves view). The active-tab highlight is handled
  // separately by the scroll detector below.
  useEffect(() => {
    if (!show) return;
    const root = scrollRootRef.current;
    if (!root) return;
    const nodes = sections.map((s) => sectionRefs.current[s.id]).filter(Boolean);
    if (nodes.length === 0) return;
    const trackId = product ? product.id : null;
    const io = new IntersectionObserver((entries) => {
      const now = Date.now();
      entries.forEach((e) => {
        const id = e.target.getAttribute("data-section");
        if (e.isIntersecting) {
          if (!dwellRef.current[id]) dwellRef.current[id] = now;
        } else {
          const started = dwellRef.current[id];
          if (started) {
            const dwell = now - started;
            dwellRef.current[id] = 0;
            if (dwell > 400 && trackId && window.visitorTracker && window.visitorTracker.onPanelSectionView) {
              window.visitorTracker.onPanelSectionView(trackId, id, dwell);
            }
          }
        }
      });
    }, { root: root, rootMargin: "-45% 0px -45% 0px", threshold: 0 });
    nodes.forEach((n) => io.observe(n));
    return () => {
      // Flush any open dwell timers on unmount/close.
      const now = Date.now();
      Object.keys(dwellRef.current).forEach((id) => {
        const started = dwellRef.current[id];
        if (started) {
          const dwell = now - started;
          if (dwell > 400 && trackId && window.visitorTracker && window.visitorTracker.onPanelSectionView) {
            window.visitorTracker.onPanelSectionView(trackId, id, dwell);
          }
        }
      });
      dwellRef.current = {};
      io.disconnect();
    };
  }, [product?.id, col?.name, mode, sections.length]);

  // Active-tab highlight: the active section is the last one whose top has
  // scrolled up past the sticky-nav line (scrollTop + nav height). This mirrors
  // jumpTo()'s landing position exactly, so a click and the resting highlight
  // always agree. (A viewport-center rule used to mis-highlight the *next*
  // section whenever the target was shorter than half the viewport.)
  useEffect(() => {
    if (!show) return;
    const root = scrollRootRef.current;
    if (!root) return;
    let raf = 0;
    const compute = () => {
      raf = 0;
      if (jumpLockRef.current) return; // a tab-click owns the highlight
      const ids = sections.map((s) => s.id);
      if (ids.length === 0) return;
      const atBottom = root.scrollTop + root.clientHeight >= root.scrollHeight - 2;
      let current = ids[0];
      if (atBottom) {
        current = ids[ids.length - 1]; // last section can't reach the top — pin it at the bottom
      } else {
        const line = root.scrollTop + PANEL_NAV_OFFSET + 1;
        for (const id of ids) {
          const node = sectionRefs.current[id];
          if (node && node.offsetTop <= line) current = id; else break;
        }
      }
      setActiveSection(current);
    };
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(compute); };
    root.addEventListener("scroll", onScroll, { passive: true });
    compute();
    return () => {
      root.removeEventListener("scroll", onScroll);
      if (raf) cancelAnimationFrame(raf);
    };
  }, [product?.id, col?.name, mode, sections.length]);

  const jumpTo = (id) => {
    const node = sectionRefs.current[id];
    const root = scrollRootRef.current;
    if (!node || !root) return;
    setActiveSection(id);
    // Hold the highlight on the clicked tab until the smooth scroll settles, so
    // the scroll detector can't briefly reassign it to a section the animation
    // passes through (or to a neighbour when the target is short).
    jumpLockRef.current = true;
    if (jumpTimerRef.current) clearTimeout(jumpTimerRef.current);
    const release = () => {
      jumpLockRef.current = false;
      if (jumpTimerRef.current) { clearTimeout(jumpTimerRef.current); jumpTimerRef.current = null; }
      root.removeEventListener("scrollend", release);
    };
    const behavior = window.MOTION && window.MOTION.reduce ? "auto" : "smooth";
    const top = node.offsetTop - PANEL_NAV_OFFSET;
    root.scrollTo({ top: top < 0 ? 0 : top, behavior });
    // 'scrollend' releases the lock when the smooth scroll finishes; the timeout
    // is a fallback for browsers without it and for jumps that don't move.
    root.addEventListener("scrollend", release);
    jumpTimerRef.current = setTimeout(release, 800);
  };

  // Helper to register a section wrapper.
  const sectionProps = (id) => ({
    "data-section": id,
    ref: (el) => { sectionRefs.current[id] = el; },
  });

  return (
    <>
      {/* Backdrop */}
      <div className="r-detail-backdrop" onClick={onClose} style={{
        opacity: show ? 1 : 0, pointerEvents: show ? "auto" : "none",
      }}/>
      {/* Panel. Closed, it waits 48px past the screen's right edge: its shadow reaches 44px to its
          left (12px offset plus 32px blur), which at translateX(100%) showed down the edge of the page. */}
      <aside ref={(el) => { scrollRootRef.current = el; panelRef.current = el; }} tabIndex={-1} className="r-detail" style={{
        background: bg, borderLeft: `1px solid ${border}`, color: text,
        transform: show ? "translateX(0)" : "translateX(calc(100% + 48px))",
        boxShadow: dark ? "none" : "-12px 0 32px rgba(0,0,0,0.08)",
      }}>
        {show && (
          <>
            {/* Header */}
            <div className="r-detail-header" style={{ borderBottom: `1px solid ${border}` }}>
              <button className="r-detail-close" onClick={onClose} aria-label="Close" style={{
                border: `1px solid ${border}`, color: muted,
              }}>
                <svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="1.2"/></svg>
              </button>
              {/* Breadcrumb — project↔offering context. Project mode: plain
                  project name. Offering mode: ‹ project › tier, where the
                  project segment links up to the project panel (only when that
                  project has its own content). */}
              {isProject ? (
                <div className="r-detail-crumb" style={{ color: muted }}>{projectName}</div>
              ) : (product && col && (window.canOpenProject ? window.canOpenProject(col) : (window.hasProjectContent && window.hasProjectContent(col.name)))) ? (
                <div className="r-detail-crumb" style={{ color: muted }}>
                  <button
                    type="button"
                    className="r-detail-crumb-back"
                    onClick={() => onOpenProject && onOpenProject(col)}
                  >
                    ‹ {col.name}
                  </button>
                  {" › "}
                  {tierWord}
                </div>
              ) : null}
              {product && (
                <div className="r-detail-status-row">
                  <span className="r-detail-status-dot" style={{ background: STATUS_DOT[displayStatus] }}/>
                  <span className="r-detail-kicker" style={{ color: muted }}>
                    {(displayStatus !== "funded" && window.pickLang(product.projectStatusLabel, lang)) || STATUS_LABEL[displayStatus][lang]}
                  </span>
                </div>
              )}
              <h2 className="r-detail-title" style={{ color: text }}>
                {isProject ? projectName : product.project}
              </h2>
              {product && (
                <div className="r-detail-subhead" style={{ color: muted }}>
                  {(product.headerLabel && window.pickLang(product.headerLabel, lang)) || (TIER_LABELS[tier] && TIER_LABELS[tier][lang])}
                </div>
              )}
            </div>

            {/* Scrollspy nav — sticky tab bar. Same widget desktop + mobile. */}
            {sections.length > 1 && (
              <ScrollspyNav
                sections={sections}
                activeId={activeSection}
                onJump={jumpTo}
                dark={dark}
                border={border}
                pin={isProject ? null : tierWord}
              />
            )}

            {/* Offering-mode project strip — compact link back to the full
                project view. Hidden when no col is passed (legacy mount). */}
            {!isProject && col && (window.canOpenProject ? window.canOpenProject(col) : (window.hasProjectContent && window.hasProjectContent(col.name))) && (
              <button className="r-detail-strip" onClick={() => onOpenProject && onOpenProject(col)}>
                {col.image && <img src={col.image} alt="" className="r-detail-strip-img"/>}
                <span className="r-detail-strip-name">{col.name}</span>
                <span className="r-detail-strip-link">{lang === "es" ? "Detalles, planos y fotos del proyecto" : "Project Details, Plans & Photos"} →</span>
              </button>
            )}

            {/* Filtered out — structured reasons from checkEligibility.
                When present, suppresses the strategic notice (collision rule).
                Styled to match the strategic check-size notice: faint red
                wash + lime-highlighted mono heading. */}
            {!isProject && showFilteredOut && (
              <aside
                aria-labelledby="r-filtered-out-heading"
                className="r-detail-section-sm"
                style={{
                  borderBottom: `1px solid ${border}`,
                  background: dark ? "rgba(217, 87, 87, 0.10)" : "#FBEEEC",
                }}
              >
                <h3 id="r-filtered-out-heading" className="r-detail-h3-tight" style={{ color: text }}>
                  {window.pickLang(window.COPY?.filteredOutHeading, lang) || (lang === "es" ? "Filtrado" : "Filtered out")}
                </h3>
                <div className="r-detail-reason-list">
                  {filterReasons.map(reason => {
                    const template = window.pickLang(window.COPY?.filteredOutReason, lang)
                      || (lang === "es" ? "{filter} = {value} excluye esta oferta." : "{filter} = {value} excludes this offering.");
                    const phrase = template
                      .replace("{filter}", window.pickLang(reason.filterLabel, lang) || reason.filterKey)
                      .replace("{value}", window.pickLang(reason.userValue, lang) || "");
                    return (
                      <button
                        key={reason.filterKey}
                        type="button"
                        className="r-detail-reason-btn"
                        onClick={() => {
                          if (typeof window.focusFilter === "function") window.focusFilter(reason.filterKey);
                          if (typeof onClose === "function") onClose();
                        }}
                        style={{ color: text }}
                      >
                        {phrase}
                      </button>
                    );
                  })}
                </div>
              </aside>
            )}

            {/* ── Project Overview — stat grid + project thesis. Shared by both
                modes: the project panel reuses the offering panel's project-
                overview content verbatim (sourced from a representative tranche),
                minus the offering-only "View full project" strip above. The
                heading reads "Overview" in project mode, "Project Overview" in
                offering mode. ── */}
            {(showAnyProjectStats || window.pickLang(sourceProduct?.projectOverview, lang)) && (
              <section {...sectionProps("project")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {isProject ? (lang === "es" ? "Resumen" : "Overview") : t.projectOverview}
                  </h3>
                  {projectStatRows ? (
                    <StatGrid stats={projectStatRows} lang={lang} muted={muted} text={text} />
                  ) : showProjectDetails && (
                    <div className="r-stat-grid">
                      {sourceProduct.projectTotalCostUsd > 0 && (
                        <Metric label={t.totalProjectCost} value={window.formatCurrencyShort(sourceProduct.projectTotalCostUsd)} muted={muted} text={text} bold={false} />
                      )}
                      {projectKindLabelText && (
                        <Metric label={t.projectType} value={projectKindLabelText} muted={muted} text={text} bold={false} />
                      )}
                      {devTimelineYears != null && (
                        <Metric label={t.devTimeline} value={`~${devTimelineYears} ${t.yrs}`} muted={muted} text={text} bold={false} />
                      )}
                      {sourceProduct.projectProgramSummary && (
                        <Metric label={t.program} value={sourceProduct.projectProgramSummary} muted={muted} text={text} bold={false} />
                      )}
                    </div>
                  )}
                  {showAnyProjectStats && window.pickLang(sourceProduct?.projectOverview, lang) && (
                    <div aria-hidden="true" className="r-detail-divider" style={{
                      borderTop: `1px dashed ${dark ? "#3F3D3A" : "#C8C4BF"}`,
                    }}/>
                  )}
                  {window.pickLang(sourceProduct?.projectOverview, lang) && (
                    <p className="r-detail-body-p" style={{ color: muted }}>
                      {window.pickLang(sourceProduct.projectOverview, lang)}
                    </p>
                  )}
                </div>
              </section>
            )}

            {/* ── Impact narrative (project-level) ── */}
            {isProject && hasImpact && (
              <section {...sectionProps("impact")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {window.pickLang(projectContent.impact.heading, lang) || (lang === "es" ? "Impacto" : "Impact")}
                  </h3>
                  <p className="r-detail-body-p" style={{ color: muted, whiteSpace: "pre-line" }}>
                    {window.pickLang(projectContent.impact.body, lang)}
                  </p>
                </div>
              </section>
            )}

            {/* ── Highlights (project-level bullets) ── */}
            {isProject && hasHighlights && (
              <section {...sectionProps("highlights")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {window.pickLang(projectContent.highlights.heading, lang) || (lang === "es" ? "Puntos destacados" : "Highlights")}
                  </h3>
                  <ul className="r-detail-bullets" style={{ color: muted }}>
                    {projectContent.highlights.bullets.map((b, i) => {
                      const txt = window.pickLang(b, lang);
                      return txt ? <li key={i}>{txt}</li> : null;
                    })}
                  </ul>
                </div>
              </section>
            )}

            {/* ── Development Plan (project-level, text + photos) ── */}
            {isProject && hasPlan && (
              <section {...sectionProps("plan")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {window.pickLang(projectContent.plan.heading, lang) || (lang === "es" ? "Plan de desarrollo" : "Development Plan")}
                  </h3>
                  <p className="r-detail-body-p" style={{ color: muted, whiteSpace: "pre-line" }}>
                    {window.pickLang(projectContent.plan.body, lang)}
                  </p>
                  {Array.isArray(projectContent.plan.images) && projectContent.plan.images.map((m, i) => (
                    <img
                      key={i}
                      src={m.image}
                      alt={window.pickLang(m.alt, lang)}
                      style={{ width: "100%", height: "auto", display: "block", marginTop: i === 0 ? 12 : 8 }}
                    />
                  ))}
                </div>
              </section>
            )}

            {/* ── Photos (project media carousel; single image = no chrome) ── */}
            {isProject && hasMedia && (
              <section {...sectionProps("photos")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                {/* Heading + lead note — clarifies the images are renderings of
                    the planned development. Per-project override via
                    projectContent.mediaNote; otherwise a shared default. */}
                <div className="r-panel-media-head">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {lang === "es" ? "Fotos" : "Photos"}
                  </h3>
                  <p className="r-detail-body-p r-panel-media-intro" style={{ color: muted }}>
                    {(projectContent && window.pickLang(projectContent.mediaNote, lang))
                      || (lang === "es"
                        ? "Las imágenes a continuación son renders arquitectónicos que ilustran nuestra visión para el proyecto."
                        : "The images below are architectural renderings that illustrate our vision for the project.")}
                  </p>
                </div>
                <div className="r-panel-media">
                  {mediaItems.length === 1 ? (
                    <img
                      src={mediaItems[0].image}
                      alt={mediaItems[0].alt}
                      style={{ width: "100%", height: "auto", display: "block" }}
                    />
                  ) : (
                    <window.Carousel items={mediaItems} index={mediaIndex} onIndex={handleMediaIndex} dark={dark} controlsBelow loop />
                  )}
                </div>
              </section>
            )}

            {/* ── FAQ (project mode) — questions tagged to this project.
                Links out to the dedicated general FAQ surface. ── */}
            {isProject && hasFaq && (
              <section {...sectionProps("faq")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {lang === "es" ? "Preguntas frecuentes" : "Frequently asked questions"}
                  </h3>
                  <p className="r-detail-body-p" style={{ color: muted, marginBottom: 4 }}>
                    {lang === "es" ? "Preguntas relevantes para este proyecto." : "Questions relevant to this project."}
                  </p>
                  <window.FaqList entries={panelFaq} lang={lang} dark={dark} accordionName="r-faq-panel" />
                  <button className="r-faq-morelink" onClick={() => {
                    if (window.posthog) window.posthog.capture("faq_opened", { source: "panel", project: faqProjectKey });
                    window.openFaq && window.openFaq();
                  }}>
                    {lang === "es" ? "Más preguntas generales sobre Reurbano →" : "More general questions about Reurbano →"}
                  </button>
                </div>
              </section>
            )}

            {/* ── Ways to invest (project mode) — tier comparison; a row
                click opens that offering in offering mode. ── */}
            {isProject && (
              <section {...sectionProps("invest")} style={{ background: bg, borderBottom: `1px solid ${border}` }}>
                <div className="r-detail-section">
                  <h3 className="r-detail-h3" style={{ color: text }}>
                    {lang === "es" ? "Cómo invertir" : "Ways to invest"}
                  </h3>
                  <window.WaysToInvest col={col} lang={lang} profile={profile} onOpenOffering={(p) => onSelectProduct && onSelectProduct(p)} />
                </div>
              </section>
            )}

            {/* Strategic-only success notice — OPCO/DEVCO. The threshold
                warning variant was removed per product direction. */}
            {!isProject && isStrategic && strategicReady && !showFilteredOut && (
              <div className="r-detail-section-strategic-ok" style={{
                borderBottom: `1px solid ${border}`,
                background: dark ? "rgba(72, 102, 178, 0.10)" : "#EEF2FB",
              }}>
                <h3 className="r-detail-h3-mini r-detail-accent-text">
                  {t.strategicOpenTitle}
                </h3>
                <div className="r-detail-strategic-body" style={{ color: text }}>
                  {t.strategicOpenSub}
                </div>
              </div>
            )}

            {/* Group B — Investment Opportunity. */}
            {!isProject && (
            <section {...sectionProps("offering")} style={{ background: surface, borderBottom: `1px solid ${border}` }}>
              {/* §3 Offering Details — labels can be overridden per ProductType
                  via yieldLabelOverride / holdLabelOverride / minLabelOverride
                  (sheet: 02-ProductTypes). Used to soften "fixed-product"
                  framing on debt where structures are negotiated. Equity-class
                  offerings fold MOIC / pref / promote / historical CAGR into
                  the same block below the primary grid. */}
              <div className="r-detail-section">
                {/* Umbrella label — one heading for the whole offering pair. */}
                <h3 className="r-detail-h3" style={{ color: text }}>
                  {tierWord
                    ? window.formatTemplate(t.offeringOverviewTier, { tier: tierWord })
                    : t.offeringOverview}
                </h3>
                {product.returnStructure && Array.isArray(product.targetReturn?.irr) && (
                  <ReturnBar structure={product.returnStructure} target={product.targetReturn.irr[1]} t={t} text={text} muted={muted} dark={dark} />
                )}
                {product.detailStats && product.detailStats.length > 0 ? (
                  <StatGrid
                    // The bar already shows these; the matrix cell still reads them.
                    stats={product.returnStructure
                      ? product.detailStats.filter((s) => !(product.returnStructure.replaces || []).includes(s.labelEn))
                      : product.detailStats}
                    lang={lang} muted={muted} text={text} />
                ) : (
                  <>
                    <div className="r-detail-grid-16">
                      {product.riskClass === "debt" ? (
                        /* Debt offerings deliberately omit a numeric yield —
                           interest rate is negotiated per structure (Dirk's
                           feedback). Show the override label as the headline
                           phrase, no range. */
                        <Metric label="" value={window.pickLang(product.yieldLabelOverride, lang) || window.returnKindLabel(product, t)} muted={muted} text={text} bold={false} />
                      ) : (
                        <Metric label={window.pickLang(product.yieldLabelOverride, lang) || window.returnKindLabel(product, t)} value={window.formatRangeStr(product)} muted={muted} text={text} lime />
                      )}
                      <Metric label={window.pickLang(product.holdLabelOverride, lang) || t.holdPeriod} value={`${product.riskClass === "equity" && product.type !== "pre-sale" ? "~" : ""}${product.holdPeriodYears[1]} ${t.yrs}`} muted={muted} text={text} />
                      <Metric label={window.pickLang(product.minLabelOverride, lang) || t.minInvestment} value={product.minimumInvestment.amount != null ? window.formatCurrency(product.minimumInvestment.amount) : "—"} muted={muted} text={text} />
                    </div>
                    {product.riskClass === "equity" && (product.moic > 0 || product.prefReturnPct > 0 || window.pickLang(product.promoteSplit, lang) || product.appreciationCagrHistorical > 0) && (
                      <div className="r-detail-grid-16" style={{ marginTop: 16 }}>
                        {product.moic > 0 && (
                          <Metric label={t.moicLabel} value={`${product.moic.toFixed(2)}x`} muted={muted} text={text} />
                        )}
                        {product.prefReturnPct > 0 && (
                          <Metric label={t.prefReturnLabel} value={`${product.prefReturnPct}%`} muted={muted} text={text} />
                        )}
                        {window.pickLang(product.promoteSplit, lang) && (
                          <Metric label={t.promoteSplitLabel} value={window.pickLang(product.promoteSplit, lang)} muted={muted} text={text} />
                        )}
                        {product.appreciationCagrHistorical > 0 && (
                          <Metric label={t.apprecCagrLabel} value={`${product.appreciationCagrHistorical}%`} muted={muted} text={text} />
                        )}
                      </div>
                    )}
                  </>
                )}
                {window.pickLang(product.offeringOverview, lang) && (
                  <>
                    <div aria-hidden="true" className="r-detail-divider" style={{
                      borderTop: `1px dashed ${dark ? "#3F3D3A" : "#C8C4BF"}`,
                    }}/>
                    <p className="r-detail-body-p" style={{ color: muted }}>
                      {window.pickLang(product.offeringOverview, lang)}
                    </p>
                  </>
                )}
                {product.firstCapitalCall && (
                  <p className="r-detail-body-p" style={{ marginTop: 12, color: muted }}>
                    {lang === "es" ? "Primera llamada de capital: aproximadamente " : "First capital call: approximately "}
                    {/* Month and year, never the day: the first call is a target,
                        not a settled wire date (Chris, 2026-09-08). The month
                        alone left "February" without its year. */}
                    {window.formatLocalizedMonthYear(product.firstCapitalCall, lang, "long")}
                  </p>
                )}
                <CallSchedule
                  calls={product.capitalCalls}
                  amount={amount}
                  lang={lang}
                  t={t}
                  muted={muted}
                  text={text}
                  border={border}
                />
                {product.type === "construction-debt" && (
                  <button className="r-detail-cta-inline r-detail-cta-inline-mt" onClick={() => {
                    if (window.posthog) window.posthog.capture("schedule_call_clicked", { source: "detail_inline_debt", product_id: product?.id, project: product?.project });
                    window.openSchedulingModal && window.openSchedulingModal();
                  }}>
                    {t.discussStructure}
                    <svg width="12" height="12" viewBox="0 0 12 12"><path d="M1 6h10M7 2l4 4-4 4" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
                  </button>
                )}
              </div>

              {/* §5 Return Calculator — slider + projected-exit + J-curve fused
                  into one block. Debt and pre-sale render alternate calculators
                  (indicative terms / financing options) inside this same slot.
                  Background is white to break visually from the cream offering
                  overview above. Hidden for portfolio entities (OpCo/DevCo) —
                  their economics aren't a per-investment projection.
                  Also hidden when SSOT sets showReturnsVisuals=false on the
                  offering (default off for debt, where rates are negotiated
                  per structure and a slider implies false precision). */}
              {product.projectKind !== "portfolio" && product.showReturnsVisuals !== false && (
              <div {...sectionProps("returns")} className="r-detail-calc-wrap" style={{ borderTop: `1px solid ${border}`, background: bg }}>
            {product.type === "pre-sale" ? (
              /* Buy-a-unit: a single residence is primarily a place to live,
                 not a passive return vehicle. Skip the projected-exit
                 calculator and cash-distribution timeline — they over-emphasize
                 the investment-product framing. Render the financing panel
                 only when 02-ProductTypes.financing_copy is populated; the
                 copy is editorial and lives in the sheet, not in code. */
              window.pickLang(product.financingCopy, lang) ? (
                <div className="r-detail-section-sm">
                  <h3 className="r-detail-h3-tight" style={{ color: text }}>
                    {t.financingOptions}
                  </h3>
                  <p className="r-detail-body-p" style={{ color: text, marginBottom: 14 }}>
                    {window.pickLang(product.financingCopy, lang)}
                  </p>
                  <button className="r-detail-cta-inline" onClick={() => {
                    if (window.posthog) window.posthog.capture("schedule_call_clicked", { source: "detail_inline_financing", product_id: product?.id, project: product?.project });
                    window.openSchedulingModal && window.openSchedulingModal();
                  }}>
                    {t.discussFinancing}
                    <svg width="12" height="12" viewBox="0 0 12 12"><path d="M1 6h10M7 2l4 4-4 4" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
                  </button>
                </div>
              ) : null
            ) : product.type === "construction-debt" ? (
              <>
              {/* Illustrative debt scenario — fixed assumptions, explicitly
                  framed as not an offer. Principal is the only knob.
                  Non-compounding interest: total = principal * (1 + rate * years). */}
              {(() => {
                // Scenario constants are sheet-driven from 02-ProductTypes
                // (PT-DEBT row). Fallback values keep the panel sane if the
                // cells are ever cleared.
                const scenarioRate = product.scenarioRatePct > 0 ? product.scenarioRatePct : (window.SETTINGS?.scenarioCalc?.defaultRate ?? 8);
                const scenarioYears = product.scenarioYears > 0 ? product.scenarioYears : (window.SETTINGS?.scenarioCalc?.defaultYears ?? 2);
                const scenarioStructureCopy = window.pickLang(product.scenarioStructureCopy, lang)
                  || t.scenarioStructureFallback;
                const principalMin = product.minimumInvestment.amount;
                const principalMax = Math.max(principalMin * 5, profileCap || 1000000);
                const totalAtMaturity = amount * (1 + (scenarioRate / 100) * scenarioYears);
                return (
                  <div className="r-detail-section-sm" style={{ borderTop: `1px solid ${border}` }}>
                    <h3 className="r-detail-h3-mini" style={{ color: text }}>
                      {t.exampleScenario}
                    </h3>
                    <div className="r-detail-body-p" style={{ color: muted, marginBottom: 12 }}>
                      {window.formatTemplate(t.illustrativeScenario, { rate: scenarioRate, years: scenarioYears, structure: scenarioStructureCopy.toLowerCase() })}
                    </div>
                    <div className="r-detail-slider-row">
                      <span className="r-detail-slider-label" style={{ color: muted }}>{t.principal}</span>
                      <span className="r-detail-slider-value" style={{ color: text }}>{window.formatCurrency(amount)}</span>
                    </div>
                    <input type="range" min={principalMin} max={principalMax}
                      step={principalMin < 100000 ? 5000 : 25000}
                      value={amount} onChange={(e) => {
                        const v = Number(e.target.value);
                        setAmount(v);
                        if (window.posthog) window.posthog.capture("calculator_amount_changed", { product_id: product?.id, project: product?.project, amount: v });
                      }}
                      className="r-detail-slider-input"/>
                    <div className="r-detail-slider-ends" style={{ color: muted }}>
                      <span>{window.formatCurrency(principalMin)}</span>
                      <span>{window.formatCurrencyShort(principalMax)}</span>
                    </div>
                    <div className="r-detail-calc-box" style={{ background: surface, border: `1px solid ${border}` }}>
                      <div className="r-detail-calc-label" style={{ color: muted }}>
                        {t.totalAtMaturity}
                      </div>
                      <div className="r-detail-calc-value">
                        {window.formatCurrency(totalAtMaturity)}
                      </div>
                      <div className="r-detail-calc-meta" style={{ color: muted }}>
                        +{window.formatCurrency(totalAtMaturity - amount)} {t.interestOver} {scenarioYears} {t.yrs}
                      </div>
                      <div className="r-detail-calc-rate" style={{ color: muted }}>
                        @ {scenarioRate}% {t.nonCompounding}
                      </div>
                    </div>
                  </div>
                );
              })()}
              </>
            ) : (
            <div className="r-detail-section-sm">
              <h3 className="r-detail-h3-mb12" style={{ color: text }}>
                {t.calculator}
              </h3>
              <div className="r-detail-slider-row">
                <span className="r-detail-slider-label" style={{ color: muted }}>{t.investmentAmount}</span>
                <span className="r-detail-slider-value" style={{ color: text }}>{window.formatCurrency(amount)}</span>
              </div>
              <input type="range" min={product.minimumInvestment.amount} max={sliderMax}
                step={product.minimumInvestment.amount < 100000 ? 5000 : 25000}
                value={amount} onChange={(e) => {
                  const v = Number(e.target.value);
                  setAmount(v);
                  if (window.posthog) window.posthog.capture("calculator_amount_changed", { product_id: product?.id, project: product?.project, amount: v });
                }}
                className="r-detail-slider-input"/>
              <div className="r-detail-slider-ends" style={{ color: muted }}>
                <span>{window.formatCurrency(product.minimumInvestment.amount)}</span>
                <span className="r-detail-slider-cap">
                  {profileCap && profileCap < 2000000 && (
                    <span className="r-detail-slider-cap-pill r-detail-accent-text">
                      {t.checkYourCheckCap}
                    </span>
                  )}
                  <span>{window.formatCurrencyShort(sliderMax)}</span>
                </span>
              </div>
              <div className="r-detail-calc-box" style={{ background: surface, border: `1px solid ${border}` }}>
                <div className="r-detail-calc-label" style={{ color: muted }}>{product.type === "long-term-hold" ? (lang === "es" ? "Valor total proyectado" : "Projected total value") : t.projectedExit}</div>
                <div className="r-detail-calc-value">
                  {window.formatCurrency(projectedExit)}
                </div>
                <div className="r-detail-calc-meta" style={{ color: muted }}>
                  +{window.formatCurrency(projectedExit - amount)} {t.over} {((product.holdPeriodYears[0]+product.holdPeriodYears[1])/2).toFixed(1)} {t.yrs}
                </div>
                {/* Show the assumed return rate so the projection isn't a black box.
                    Mid-of-range matches the J-curve and projectedExit math above. */}
                {(() => {
                  const r = product.targetReturn;
                  let label = null;
                  if (r.irr) label = `@ ${((r.irr[0] + r.irr[1]) / 2).toFixed(1)}% ${lang === "es" ? "TIR" : "IRR"}`;
                  else if (r.yield) label = `@ ${((r.yield[0] + r.yield[1]) / 2).toFixed(1)}% ${lang === "es" ? "rendimiento" : "yield"}`;
                  else if (r.multiple) label = `@ ${((r.multiple[0] + r.multiple[1]) / 2).toFixed(2)}x`;
                  if (!label) return null;
                  return (
                    <div className="r-detail-calc-rate" style={{ color: muted }}>
                      {label}
                    </div>
                  );
                })()}
              </div>
            </div>
            )}

              {/* Cash distribution timeline — hidden below 700px viewport
                  where tick labels overlap and the chart stops reading.
                  Also hidden for debt (point-estimate cumulative line is
                  misleading when structure is negotiated) and for pre-sale
                  buy-a-unit (a residence isn't a yield-stream product). */}
              {showTimeline && window.TimelineStory && product.type !== "construction-debt" && product.type !== "pre-sale" && (
                <window.TimelineStory
                  product={product}
                  amount={amount}
                  dark={dark}
                  width={timelineWidth}
                  lang={lang}
                />
              )}
              </div>
              )}
            </section>
            )}

            {/* Group C — cream background, same tone as Group A. Contains
                §6 How You Participate (per-offering leg copy) and §7
                Indicative Terms (sheet-driven editorial). Hides entirely
                when both children are empty. */}
            {!isProject && (() => {
              // When the user hasn't picked a residency yet, the matrix shows an
              // arbitrary residency-wrapper offering (MEZZ-US vs EQ-MX). Swap in
              // the project-level generic equity participation paragraph so the
              // copy isn't accidentally jurisdiction-specific.
              const isEquity = product.displayGroupId === "DG-EQUITY";
              const noResidency = !(profile && profile.residency);
              const genericEquity = isEquity && noResidency
                ? window.pickLang(product.projectEquityParticipation, lang)
                : "";
              const participationCopy = genericEquity || window.pickLang(product.participation, lang);
              const indicativeCopy = window.pickLang(product.indicativeTerms, lang);
              // Tax copy is per-offering (10-ProjectOfferings.tax_en/_es). Only
              // surfaces once residency is picked — otherwise the matrix is
              // showing an arbitrary residency wrapper (MEZZ-US vs EQ-MX) and
              // jurisdiction-specific tax wording would mislead.
              if (!participationCopy && !indicativeCopy) return null;
              return (
                <section {...sectionProps("terms")} style={{ background: surface, borderBottom: `1px solid ${border}` }}>
                  {participationCopy && (
                    <div className="r-detail-section-sm">
                      <h3 className="r-detail-h3-tight" style={{ color: text }}>
                        {t.howYouParticipate}
                      </h3>
                      {/* pre-line so the "Why this structure" paragraph break in the
                          content survives; matches the body paragraphs above. */}
                      <p className="r-detail-body-p" style={{ color: muted, whiteSpace: "pre-line" }}>
                        {participationCopy}
                      </p>
                    </div>
                  )}
                  {indicativeCopy && (
                    <div className={participationCopy ? "r-detail-indicative-after" : "r-detail-section-sm"}>
                      <h3 className="r-detail-h3-tight" style={{ color: text }}>
                        {t.indicativeTerms}
                      </h3>
                      <p className="r-detail-body-p" style={{ color: muted }}>
                        {indicativeCopy}
                      </p>
                    </div>
                  )}
                </section>
              );
            })()}

            {/* ── Tax (offering-level) ── */}
            {!isProject && hasTax && (() => {
              const taxCopy = window.pickLang(product.tax, lang);
              const taxLabel = (t && t.tax) || (lang === "es" ? "Impuestos" : "Tax");
              const useVisual = !!product.taxVisual
                && product.type === "mezz-equity"
                && profile && profile.residency === "us"
                && window.MezzanineTaxFlow;
              return (
                <section {...sectionProps("tax")} style={{ background: surface, borderBottom: `1px solid ${border}` }}>
                  <div className="r-detail-section-sm">
                    {useVisual ? (
                      <window.MezzanineTaxFlow
                        product={product}
                        amount={amount}
                        projectedExit={projectedExit}
                        lang={lang}
                        dark={dark}
                        t={t}
                        taxCopy={taxCopy}
                        taxVisual={product.taxVisual}
                      />
                    ) : (
                      <>
                        <h3 className="r-detail-h3-tight" style={{ color: text }}>{taxLabel}</h3>
                        <p className="r-detail-body-p" style={{ color: muted }}>{taxCopy}</p>
                      </>
                    )}
                  </div>
                </section>
              );
            })()}

            {/* Next steps — primary CTA + 2 secondary actions */}
            {!isProject && (
            <div {...sectionProps("next")} className="r-detail-next-steps">
              <h3 className="r-detail-h3-mb12" style={{ color: text }}>
                {t.nextSteps}
              </h3>

              {/* Primary action — same Schedule a call CTA used in the sidebar */}
              <button className="r-detail-cta-primary" onClick={() => {
                if (window.posthog) window.posthog.capture("schedule_call_clicked", { source: "detail_panel", product_id: product?.id, project: product?.project });
                window.openSchedulingModal && window.openSchedulingModal();
              }}>
                {t.contact}
                <svg width="12" height="12" viewBox="0 0 12 12"><path d="M1 6h10M7 2l4 4-4 4" stroke="currentColor" strokeWidth="1.4" fill="none"/></svg>
              </button>

              <div className="r-detail-disclaimer" style={{ color: muted }}>
                {t.disclaimer}
              </div>
            </div>
            )}
          </>
        )}
      </aside>
    </>
  );
}

function Metric({ label, value, muted, text, lime, bold = true }) {
  return (
    <div>
      <div className="r-detail-metric-label" style={{ color: muted }}>{label}</div>
      <div className="r-detail-metric-value" style={{ fontWeight: bold ? 600 : 400, color: text }}>
        {value}
      </div>
    </div>
  );
}

window.DetailPanel = DetailPanel;
