// KOFA multi-corpus SPA — React via CDN + Babel-standalone.
// Components lifted from Presedens v3, adapted to real /api/* JSON responses.
// Inline styles preserved from the prototype to keep the visual diff tight.

const { useState, useEffect, useRef, useReducer, useMemo, useCallback } = React;
const Api = window.KofaApi;

// Surface any uncaught error directly on the page so we never get the
// silent "white screen" failure mode.
window.addEventListener('error', (e) => {
  const root = document.getElementById('root');
  if (root) {
    root.innerHTML = `<pre style="padding:24px;color:#c33;white-space:pre-wrap;font:13px 'IBM Plex Sans',monospace">App-feil: ${e.message}\n${e.error && e.error.stack || ''}</pre>`;
  }
});

class ErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) { console.error('Render error:', err, info); }
  render() {
    if (this.state.err) {
      return (
        <pre style={{ padding: 24, color: '#c33', whiteSpace: 'pre-wrap', font: "13px 'IBM Plex Sans', monospace" }}>
          App-feil under render: {String(this.state.err)}{'\n'}{this.state.err.stack || ''}
        </pre>
      );
    }
    return this.props.children;
  }
}

const ASSET_V = (typeof window !== 'undefined' && window.__BOOTSTRAP__ && window.__BOOTSTRAP__.asset_version) || '';
const asset = (path) => ASSET_V ? `${path}?v=${ASSET_V}` : path;

// ──────────────────────────────────────────────────────────────────────────
// Icons (lifted from Presedens)
// ──────────────────────────────────────────────────────────────────────────
const Icon = ({ name, size = 16, color = 'currentColor' }) => {
  const icons = {
    search: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="4" stroke={color} strokeWidth="1.4"/><path d="M10 10L13.5 13.5" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    x: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M4 4L12 12M12 4L4 12" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    chevronDown: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M4 6L8 10L12 6" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    chevronRight: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M6 4L10 8L6 12" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    chevronLeft: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M10 4L6 8L10 12" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    sliders: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke={color} strokeWidth="1.3" strokeLinecap="round"/><circle cx="6" cy="4" r="1.5" fill={color}/><circle cx="10" cy="8" r="1.5" fill={color}/><circle cx="5" cy="12" r="1.5" fill={color}/></svg>,
    list: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M3 5h10M3 8h10M3 11h10" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    table: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><rect x="2" y="2" width="12" height="12" rx="1.5" stroke={color} strokeWidth="1.4"/><path d="M2 6h12M6 6v8" stroke={color} strokeWidth="1.4"/></svg>,
    // Paragraph (§) — used as the middle segment label of the Lovdata 3-way
    // toggle (Ledd / Paragraf / Dokument). SVG <text> with a serif fallback
    // chain so the § glyph renders crisp at small sizes.
    //
    // `dominantBaseline="central"` (with y=8) centers the glyph optically at
    // the viewBox midline. The naïve `y="13"` (baseline anchor) puts the §
    // visual center at ~y=8.45 — close to 8 but ~0.4 px low next to the
    // list icon, which the user noticed.
    paragraph: <svg width={size} height={size} viewBox="0 0 16 16"><text x="8" y="8" textAnchor="middle" dominantBaseline="central" fontFamily="Georgia, 'Times New Roman', serif" fontSize="14" fontWeight="600" fill={color}>§</text></svg>,
    // Document — rectangle (the document frame) with three horizontal bars
    // inside (content lines, mirroring the `list` icon's bar style).
    //
    // Layout uses **equal center-to-center spacing of 3 px** across the four
    // horizontal lines (rect-top, bar-1, bar-2, bar-3, rect-bottom): 2 → 5 →
    // 8 → 11 → 14. Rect 10 × 12 at (3, 2) → portrait, evenly inset 3 px
    // from the left/right viewBox edges and 2 px from top/bottom. Bars span
    // x=5.5 to x=10.5 (length 5, 2.5 px inset from each rect side), so the
    // round stroke caps leave a 1.1 px breathing gap to the rect's inner
    // edge — the previous 0.6 px gap was what made it read as "off".
    document: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1.5" stroke={color} strokeWidth="1.4"/><path d="M5.5 5h5M5.5 8h5M5.5 11h5" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    externalLink: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M7 3H3a1 1 0 00-1 1v9a1 1 0 001 1h9a1 1 0 001-1V9" stroke={color} strokeWidth="1.4" strokeLinecap="round"/><path d="M10 2h4v4M14 2L8.5 7.5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    user: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><circle cx="8" cy="5.5" r="2.5" stroke={color} strokeWidth="1.4"/><path d="M2.5 13.5c0-3.038 2.462-5.5 5.5-5.5s5.5 2.462 5.5 5.5" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    moon: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M13 9.5A5 5 0 016.5 3 5.5 5.5 0 1013 9.5z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/></svg>,
    sun: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="3" stroke={color} strokeWidth="1.4"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.5 1.5M11.5 11.5L13 13M3 13l1.5-1.5M11.5 4.5L13 3" stroke={color} strokeWidth="1.4" strokeLinecap="round"/></svg>,
    feedback: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M2 3h12v8H8.5L5 14v-3H2V3z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/></svg>,
    dot: <svg width={size} height={size} viewBox="0 0 16 16"><circle cx="8" cy="8" r="3" fill={color}/></svg>,
    help: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.3" stroke={color} strokeWidth="1.4"/><path d="M6 6.3c0-1.1 0.9-2 2-2s2 0.9 2 2c0 1.5-2 1.4-2 3" stroke={color} strokeWidth="1.4" strokeLinecap="round" fill="none"/><circle cx="8" cy="11.4" r="0.8" fill={color}/></svg>,
    book: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M3 13V3a1 1 0 011-1h3.5v11H4a1 1 0 01-1-1z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/><path d="M13 13V3a1 1 0 00-1-1H8.5v11H12a1 1 0 001-1z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/></svg>,
    bookOpen: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M2.5 4a1 1 0 011-1h3.5a1.5 1.5 0 011.5 1.5V13M13.5 4a1 1 0 00-1-1H9a1.5 1.5 0 00-1.5 1.5V13M2.5 4v9h5M13.5 4v9h-5" stroke={color} strokeWidth="1.3" strokeLinejoin="round"/></svg>,
    headset: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M3 9V8a5 5 0 0110 0v1" stroke={color} strokeWidth="1.4" strokeLinecap="round"/><rect x="2" y="9" width="2.5" height="4" rx="1" stroke={color} strokeWidth="1.4"/><path d="M13.5 9h-1a1 1 0 00-1 1v2a1 1 0 001 1h.7a1 1 0 001-.85l.45-3a1 1 0 00-1-1.15h-.15z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/></svg>,
    copy: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="8" height="8" rx="1.2" stroke={color} strokeWidth="1.4"/><path d="M3 11V4a1 1 0 011-1h7" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    check: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M3 8.5L6.5 12L13 4.5" stroke={color} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>,
    folder: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M2 4.5a1 1 0 011-1h3.5l1.5 1.5H13a1 1 0 011 1V12a1 1 0 01-1 1H3a1 1 0 01-1-1V4.5z" stroke={color} strokeWidth="1.4" strokeLinejoin="round"/></svg>,
    plus: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke={color} strokeWidth="1.5" strokeLinecap="round"/></svg>,
    moreH: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><circle cx="3.5" cy="8" r="1.2" fill={color}/><circle cx="8" cy="8" r="1.2" fill={color}/><circle cx="12.5" cy="8" r="1.2" fill={color}/></svg>,
    bookmark: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M4 2.5h8v11l-4-2.5-4 2.5z" stroke={color} strokeWidth="1.4" strokeLinejoin="round" fill="none"/></svg>,
    trash: <svg width={size} height={size} viewBox="0 0 16 16" fill="none"><path d="M3 5h10M6 5V3.5a1 1 0 011-1h2a1 1 0 011 1V5M5 5l0.7 8a1 1 0 001 0.9h2.6a1 1 0 001-0.9L11 5" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>,
  };
  return icons[name] || null;
};

const VennMark = ({ size = 20, blue = '#5A6FF0', black = '#0A0A0A' }) => (
  <svg width={size} height={size} viewBox="0 0 40 32" fill="none" aria-hidden="true">
    <circle cx="14" cy="16" r="12.5" stroke={black} strokeWidth="3" fill="none"/>
    <circle cx="22" cy="16" r="12.5" fill={blue}/>
  </svg>
);

const ReliableLogo = ({ height = 20, color = 'var(--text)' }) => (
  <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
    <VennMark size={Math.round(height * 1.25)}/>
    <span style={{ fontWeight: 400, fontSize: Math.round(height * 0.95), color, letterSpacing: '-0.01em' }}>
      Reliable ai
    </span>
  </div>
);

// ──────────────────────────────────────────────────────────────────────────
// Badges
// ──────────────────────────────────────────────────────────────────────────
function decisionType(label) {
  if (!label) return 'neutral';
  const s = label.toLowerCase();
  if (s.includes('ikke brudd') || s.includes('intet brudd') || s.includes('intet')) return 'ikke-brudd';
  if (s.includes('brudd') || s.includes('overtredelses') || s.includes('annullér')) return 'brudd';
  if (s.includes('avvis') || s.includes('trukket') || s.includes('avvist')) return 'avvist';
  return 'neutral';
}

const AvgjørelseBadge = ({ label, wrap = false }) => {
  if (!label) return null;
  const t = decisionType(label);
  const styles = {
    'brudd':       { background: 'var(--red-bg)',     color: 'var(--red)' },
    'ikke-brudd':  { background: 'var(--green-bg)',   color: 'var(--green)' },
    'avvist':      { background: 'var(--surface-2)',  color: 'var(--text-2)' },
    'neutral':     { background: 'var(--surface-2)',  color: 'var(--text-2)' },
  };
  const s = styles[t];
  return (
    <span style={{ ...s, padding: '2px 7px', borderRadius: 3, fontSize: 11, fontWeight: 500, whiteSpace: wrap ? 'normal' : 'nowrap', lineHeight: 1.35, display: 'inline-block' }}>
      {label}
    </span>
  );
};

const TypeBadge = ({ label, wrap = false }) => {
  if (!label) return null;
  return (
    <span style={{ background: 'var(--accent-bg)', color: 'var(--accent)', padding: '2px 7px', borderRadius: 3, fontSize: 11, fontWeight: 500, lineHeight: wrap ? 1.35 : '18px', display: 'inline-block', whiteSpace: wrap ? 'normal' : 'nowrap' }}>
      {label}
    </span>
  );
};

// EU court — Norwegian labels for the two instances and the 13 languages we ingest.
const COURT_LABELS_NB = {
  cjeu: 'Domstolen',
  general_court: 'Underretten',
};
// EU court — section_code labels. Codes mirror the KOFA section taxonomy:
// i=intro/headnote, b=background/legal context, s=procedure, kv=findings of
// the court (the analytical heart), k=operative part. The default unfiltered
// view shows all five; the killer use case is the kv-only filter.
const SECTION_CODE_LABELS_NB = {
  i:  'Innledning',
  b:  'Bakgrunn',
  s:  'Prosedyre',
  kv: 'Rettens vurdering',
  k:  'Slutning',
};
const SECTION_CODE_LABELS_DA = {
  i:  'Indledning',
  b:  'Baggrund',
  s:  'Procedure',
  kv: 'Domstolens vurdering',
  k:  'Slutning',
};
const LANGUAGE_LABELS_NB = {
  EN: 'engelsk',
  FR: 'fransk',
  DE: 'tysk',
  IT: 'italiensk',
  EL: 'gresk',
  DA: 'dansk',
  PL: 'polsk',
  PT: 'portugisisk',
  SL: 'slovensk',
  NL: 'nederlandsk',
  BG: 'bulgarsk',
  ES: 'spansk',
  HU: 'ungarsk',
};
const langLabel = (code) => (code ? (LANGUAGE_LABELS_NB[code] || String(code).toLowerCase()) : '');

// ──────────────────────────────────────────────────────────────────────────
// Top bar
// ──────────────────────────────────────────────────────────────────────────
// Search-mode labels resolve by app locale. NO + DK both show Norwegian (the
// chooser is "Norwegian-only" by product decision); DE shows German. The else
// branch reproduces the pre-German-edition NO/DK text verbatim.
const SEARCH_MODE_OPTIONS_NB = [
  { value: 'best',     label: 'Hybrid',    sub: 'Nøkkelord + meningssøk' },
  { value: 'keyword',  label: 'Nøkkelord', sub: 'Kun nøkkelord-søk' },
  { value: 'semantic', label: 'Meningssøk', sub: 'Kun semantisk likhet' },
];
const SEARCH_MODE_OPTIONS_DE = [
  { value: 'best',     label: 'Hybrid',    sub: 'Schlagwort + Bedeutungssuche' },
  { value: 'keyword',  label: 'Schlagwort', sub: 'Nur Schlagwortsuche' },
  { value: 'semantic', label: 'Bedeutungssuche', sub: 'Nur semantische Ähnlichkeit' },
];
const searchModeOptions = (locale) => (locale === 'de' ? SEARCH_MODE_OPTIONS_DE : SEARCH_MODE_OPTIONS_NB);

function SearchModePopover({ value, onChange, anchorRef, rerank, onSetRerank, rerankEnabled, locale }) {
  // Close on outside click / Escape.
  const ref = useRef(null);
  useEffect(() => {
    const onDocClick = (e) => {
      if (!ref.current) return;
      if (ref.current.contains(e.target)) return;
      if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return;
      onChange(null);
    };
    const onKey = (e) => { if (e.key === 'Escape') onChange(null); };
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('keydown', onKey);
    };
  }, [onChange, anchorRef]);
  return (
    <div ref={ref} style={{
      // zIndex 95 sits above the onboarding ring/overlay (91) and tour popover
      // (92), so the dropdown is visible during the tour's search-mode step.
      position: 'absolute', top: 'calc(100% + 8px)', left: 0, zIndex: 95,
      background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8,
      boxShadow: '0 12px 40px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06)',
      minWidth: 240, padding: 6,
    }}>
      <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', padding: '6px 8px 4px' }}>{locale === 'de' ? 'Suchmodus' : 'Søkemodus'}</div>
      {searchModeOptions(locale).map(o => {
        const active = o.value === value;
        return (
          <button key={o.value} onClick={() => onChange(o.value)}
            style={{
              display: 'block', width: '100%', textAlign: 'left',
              padding: '8px 10px', borderRadius: 6, border: 'none',
              background: active ? 'var(--accent-bg)' : 'transparent',
              color: active ? 'var(--accent)' : 'var(--text)',
              cursor: 'pointer',
            }}>
            <div style={{ fontSize: 12.5, fontWeight: active ? 600 : 500 }}>{o.label}</div>
            <div style={{ fontSize: 11, color: active ? 'var(--accent)' : 'var(--text-3)', opacity: active ? 0.8 : 1 }}>{o.sub}</div>
          </button>
        );
      })}
      {rerankEnabled && (
        <>
          <div style={{ height: 1, background: 'var(--border)', margin: '5px 8px' }}/>
          <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', padding: '6px 8px 4px' }}>{locale === 'de' ? 'Gründlichkeit' : 'Grundighet'}</div>
          <button onClick={() => onSetRerank(!rerank)}
            style={{ display: 'flex', width: '100%', alignItems: 'center', gap: 10, textAlign: 'left', padding: '8px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer' }}>
            <span style={{ width: 36, height: 21, borderRadius: 12, background: rerank ? 'var(--accent)' : '#cbc3b8', position: 'relative', flexShrink: 0, transition: 'background 0.15s' }}>
              <span style={{ position: 'absolute', width: 17, height: 17, borderRadius: '50%', background: '#fff', top: 2, left: rerank ? 17 : 2, transition: 'left 0.15s', boxShadow: '0 1px 2px rgba(0,0,0,0.25)' }}/>
            </span>
            <span>
              <div style={{ fontSize: 12.5, fontWeight: rerank ? 600 : 500, color: rerank ? 'var(--accent)' : 'var(--text)' }}>{locale === 'de' ? `Gründlichere Sortierung ${rerank ? 'an' : 'aus'}` : `Grundigere sortering ${rerank ? 'på' : 'av'}`}</div>
              <div style={{ fontSize: 11, color: 'var(--text-3)' }}>{locale === 'de' ? 'Lässt das Modell die besten Treffer erneut bewerten' : 'Lar modellen vurdere de beste treffene på nytt'}</div>
            </span>
          </button>
        </>
      )}
    </div>
  );
}

// Anchored popover under the header "Support" button. Two clear contact
// affordances — phone (tel:) and email (mailto:) — and closes on outside
// click / Escape. Phone displayed with Norwegian mobile spacing (469 06 444),
// dialed as +47 prefixed.
function SupportPopover({ anchorRef, onClose, strings }) {
  const ref = useRef(null);
  useEffect(() => {
    const onDoc = (e) => {
      if (!ref.current) return;
      if (ref.current.contains(e.target)) return;
      if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return;
      onClose();
    };
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose, anchorRef]);
  return (
    <div ref={ref} role="dialog" style={{
      position: 'absolute', top: 'calc(100% + 8px)', right: 0, zIndex: 70,
      width: 280, background: 'var(--surface)', border: '1px solid var(--border)',
      borderRadius: 10, padding: 14,
      boxShadow: '0 16px 40px rgba(0,0,0,0.14), 0 2px 6px rgba(0,0,0,0.06)',
    }}>
      <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', marginBottom: 4, fontFamily: 'Georgia, serif' }}>
        {strings.support_popover_title || 'Kontakt support'}
      </div>
      <div style={{ fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5, marginBottom: 12 }}>
        {strings.support_popover_body || 'Spørsmål eller problemer? Ring oss eller send e-post.'}
      </div>
      <a href="tel:+4746906444" style={{
        display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
        borderRadius: 8, background: 'var(--accent-bg)', color: 'var(--text)',
        textDecoration: 'none', marginBottom: 8,
      }}>
        <span style={{ fontSize: 16 }}>📞</span>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
            {strings.support_phone_label || 'Telefon'}
          </div>
          <div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' }}>
            {strings.support_phone_display || '469 06 444'}
          </div>
        </div>
      </a>
      <a href="mailto:support@reliableai.no" style={{
        display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
        borderRadius: 8, background: 'var(--accent-bg)', color: 'var(--text)',
        textDecoration: 'none',
      }}>
        <Icon name="feedback" size={15} color="var(--accent)"/>
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
            {strings.support_email_label || 'E-post'}
          </div>
          <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)' }}>
            support@reliableai.no
          </div>
        </div>
      </a>
    </div>
  );
}

function TopBar({ brand, dark, onToggleDark, onLogout, onToggleSupport, supportOpen, onCloseSupport, strings }) {
  const supportRef = useRef(null);
  return (
    <div style={{ height: 52, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 24px', borderBottom: '1px solid var(--border-light)', background: 'var(--surface)', flexShrink: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        <img src={asset(dark ? '/static/images/logo-dark.png' : '/static/images/Logos-05.png')} alt={brand} onClick={() => window.location.reload()} style={{ height: 20, cursor: 'pointer' }}/>
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
        <div ref={supportRef} style={{ position: 'relative' }}>
          <button onClick={onToggleSupport} title={strings.support_button_title || 'Kontakt support'}
            style={{
              display: 'inline-flex', alignItems: 'center', gap: 7,
              padding: '6px 12px', borderRadius: 999,
              background: 'transparent', color: 'var(--text-2)', border: '1px solid var(--border)',
              fontSize: 12.5, fontWeight: 500, letterSpacing: '0.01em', cursor: 'pointer',
              transition: 'background 140ms var(--ease-standard), border-color 140ms var(--ease-standard), color 140ms var(--ease-standard)',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-2)'; e.currentTarget.style.color = 'var(--text)'; e.currentTarget.style.borderColor = 'var(--text-3)'; }}
            onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-2)'; e.currentTarget.style.borderColor = 'var(--border)'; }}>
            <Icon name="headset" size={13} color="var(--text-2)"/>
            {strings.support_button_label || 'Support'}
          </button>
          {supportOpen && (
            <SupportPopover anchorRef={supportRef} onClose={onCloseSupport} strings={strings}/>
          )}
        </div>
        <button onClick={onToggleDark} title={strings.toggle_dark_mode} style={iconBtn}>
          <Icon name={dark ? 'sun' : 'moon'} size={15} color="var(--text-2)"/>
        </button>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, opacity: 0.75 }}>
          <span style={{ fontSize: 10.5, color: 'var(--text-3)', letterSpacing: '0.04em', textTransform: 'uppercase', fontWeight: 500 }}>Powered by</span>
          <img src={asset(dark ? '/static/images/logo-dark.png' : '/static/images/Logos-05.png')} alt="Reliable AI" style={{ height: 15 }}/>
        </div>
      </div>
    </div>
  );
}

const iconBtn = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 6, borderRadius: 6, background: 'transparent', cursor: 'pointer', border: 'none' };

// ──────────────────────────────────────────────────────────────────────────
// Left rail (collapsed by default)
// ──────────────────────────────────────────────────────────────────────────
function LeftRail({ expanded, onToggle, logoutUrl, strings, onHelp, onGuide, projectsAvailable, projectsSlot }) {
  const w = expanded ? 220 : 56;
  return (
    <div style={{ width: w, minWidth: w, height: '100vh', background: 'var(--surface-2)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', transition: 'width 0.2s ease', overflow: 'hidden' }}>
      <div style={{ height: 52, display: 'flex', alignItems: 'center', padding: expanded ? '0 16px' : '0', justifyContent: expanded ? 'space-between' : 'center', borderBottom: '1px solid rgba(0,0,0,0.05)', flexShrink: 0 }}>
        {expanded ? (
          <>
            <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)' }}>Meny</span>
            <button onClick={onToggle} style={{ ...iconBtn, color: 'var(--text-3)' }}><Icon name="chevronLeft" size={15}/></button>
          </>
        ) : (
          <button onClick={onToggle} style={iconBtn}>
            <Icon name="chevronRight" size={15} color="var(--text-3)"/>
          </button>
        )}
      </div>
      {expanded && projectsAvailable && projectsSlot}
      <div style={{ flex: 1 }}/>
      {expanded && onGuide && (
        <div style={{ padding: '0 12px 4px', flexShrink: 0 }}>
          <button onClick={onGuide} title={strings && strings.guide_button_title}
            style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 8px', borderRadius: 'var(--r)', background: 'transparent', border: 'none', color: 'var(--text-2)', cursor: 'pointer', textAlign: 'left' }}>
            <div style={{ width: 28, height: 28, borderRadius: '50%', background: 'rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
              <Icon name="book" size={14} color="var(--text-2)"/>
            </div>
            <span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{(strings && strings.guide_button_title) || 'Veiledning'}</span>
          </button>
        </div>
      )}
      {expanded && onHelp && (
        <div style={{ padding: '0 12px 6px', flexShrink: 0 }}>
          <button onClick={onHelp} title={strings && strings.help_button_title}
            style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 8px', borderRadius: 'var(--r)', background: 'transparent', border: 'none', color: 'var(--text-2)', cursor: 'pointer', textAlign: 'left' }}>
            <div style={{ width: 28, height: 28, borderRadius: '50%', background: 'rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
              <Icon name="help" size={14} color="var(--text-2)"/>
            </div>
            <span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{(strings && strings.help_button_title) || 'Hjelp'}</span>
          </button>
        </div>
      )}
      <div style={{ borderTop: '1px solid rgba(0,0,0,0.05)', padding: expanded ? '10px 12px' : '10px', flexShrink: 0 }}>
        <a href={logoutUrl || '/.auth/logout'} title={!expanded ? ((strings && strings.logout) || 'Logg ut') : undefined} style={{
          display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', color: 'var(--text-2)',
          padding: expanded ? '7px 8px' : '8px', borderRadius: 'var(--r)', justifyContent: expanded ? 'flex-start' : 'center',
        }}>
          <div style={{ width: 28, height: 28, borderRadius: '50%', background: 'rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
            <Icon name="user" size={14} color="var(--text-2)"/>
          </div>
          {expanded && <span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{(strings && strings.logout) || 'Logg ut'}</span>}
        </a>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Project view (replaces search + result list when a project is selected)
// ──────────────────────────────────────────────────────────────────────────
function ProjectView({ project, items, loading, corpora, selectedKey, strings, onBack, onSelect, onRemoveItem }) {
  if (!project) {
    return (
      <div style={{ padding: 24 }}>
        <button onClick={onBack} style={projectBackPillStyle()}>{strings.projects_back_to_search}</button>
      </div>
    );
  }
  const corpusById = (cid) => corpora.find(c => c.id === cid);

  // Group items by query_text. Null/empty becomes the "Uten spørsmål" bucket.
  // Each group is sorted newest-first; groups themselves are ordered by their
  // most-recent item's added_at desc, with the null bucket pushed to the end.
  const grouped = (() => {
    if (!items) return [];
    const buckets = new Map();
    for (const it of items) {
      const key = (it.query_text && String(it.query_text).trim()) || '';
      if (!buckets.has(key)) buckets.set(key, []);
      buckets.get(key).push(it);
    }
    // Each bucket: sort newest first
    for (const arr of buckets.values()) {
      arr.sort((a, b) => String(b.added_at || '').localeCompare(String(a.added_at || '')));
    }
    const arr = [...buckets.entries()].map(([key, gItems]) => ({
      key,
      query: key || null,
      items: gItems,
      latest: gItems.length ? gItems[0].added_at : '',
    }));
    arr.sort((a, b) => {
      // Push null-query group to the end regardless of timestamp.
      if (!a.query && b.query) return 1;
      if (a.query && !b.query) return -1;
      return String(b.latest).localeCompare(String(a.latest));
    });
    return arr;
  })();

  return (
    <div style={{ padding: '12px 24px 24px' }}>
      <div style={{ maxWidth: 1100, margin: '0 auto' }}>
        <button onClick={onBack} style={projectBackPillStyle()}>{strings.projects_back_to_search}</button>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 18px' }}>
          <Icon name="folder" size={18} color="var(--text-2)"/>
          <h2 style={{ fontSize: 19, fontWeight: 600, margin: 0, color: 'var(--text)', fontFamily: 'Georgia, serif' }}>{project.name}</h2>
          <span style={{ fontSize: 12, color: 'var(--text-3)' }}>· {items ? items.length : 0}</span>
        </div>
        {loading && (
          <div style={{ padding: '32px 0', color: 'var(--text-3)', fontSize: 13, textAlign: 'center' }}>…</div>
        )}
        {!loading && items && items.length === 0 && (
          <div style={{ padding: '32px 0', color: 'var(--text-3)', fontSize: 13, textAlign: 'center', fontStyle: 'italic' }}>{strings.projects_empty}</div>
        )}
        {!loading && grouped.map(group => (
          <section key={group.key || '__no_query__'} style={{ marginBottom: 26 }}>
            <header style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 10, paddingBottom: 8, borderBottom: '1px solid var(--border-light)' }}>
              <Icon name="search" size={13} color="var(--text-3)"/>
              <span style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
                {group.query ? strings.projects_query_prefix : strings.projects_no_query_label}
              </span>
              {group.query && (
                <span style={{ fontSize: 13.5, color: 'var(--text)', fontFamily: 'Georgia, serif', fontStyle: 'italic' }}>«{group.query}»</span>
              )}
              <span style={{ fontSize: 11, color: 'var(--text-3)', marginLeft: 'auto' }}>{group.items.length}</span>
            </header>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {group.items.map(it => {
                const cobj = corpusById(it.corpus_id);
                // KOFA/KLFU paragraphs (avsnitt) have no standalone detail view —
                // the panel shows the parent case. Their saved `case_id` is the
                // paragraph pid, which the kofa-kind /api/detail can't resolve (it
                // keys on case-id), so select by `parent_case_id` instead —
                // mirroring how a paragraph hit in the search results opens its
                // case (onSelect(hit.case_id)). Only kofa-kind corpora resolve
                // detail by case-id; pid-resolved corpora (Lovdata, EU, Høyesterett,
                // …) must keep using `case_id` (the pid) even when a parent exists.
                const detailId = (it.item_kind === 'paragraph' && it.parent_case_id && cobj && cobj.kind === 'kofa')
                  ? it.parent_case_id
                  : it.case_id;
                const key = `${it.corpus_id}-${detailId}`;
                const active = selectedKey === key;
                const onClick = () => onSelect(detailId, { _corpus_source: it.corpus_id, case_id: detailId, title: it.case_title });
                const onTrash = (e) => { e.stopPropagation(); onRemoveItem(it.id); };
                return it.item_kind === 'paragraph'
                  ? <ParagraphCard key={it.id} it={it} cobj={cobj} active={active} strings={strings} onClick={onClick} onTrash={onTrash}/>
                  : <CaseCard key={it.id} it={it} cobj={cobj} active={active} strings={strings} onClick={onClick} onTrash={onTrash}/>;
              })}
            </div>
          </section>
        ))}
      </div>
    </div>
  );
}

// Sak: title + corpus mark + horizontal row of metadata pills (Klager, Innklaget,
// Saken gjelder, Avgjørelse, Registrert).
// ──────────────────────────────────────────────────────────────────────────
// _verdictTone — map a v2 KLFU signal value to a (bg, color) badge palette.
// Green = klager won / condition met; red = klager lost / condition not met;
// amber = mixed / delvist / unresolved. Used in CaseCard + the detail-modal
// Påstande section.
// ──────────────────────────────────────────────────────────────────────────
function _verdictTone(value) {
  const v = String(value || '').toLowerCase();
  if (['medhold', 'complainant_won', 'opfyldt', 'tildelt', 'klager'].includes(v)) {
    return { bg: 'var(--green-bg)', color: 'var(--green)' };
  }
  if (['ikke_medhold', 'complainant_lost', 'ikke_opfyldt', 'ikke_tildelt', 'indklagede',
       'afvises', 'hjemvises'].includes(v)) {
    return { bg: 'var(--red-bg)', color: 'var(--red)' };
  }
  if (['delvist', 'mixed', 'udsat'].includes(v)) {
    return { bg: 'var(--amber-bg)', color: 'var(--amber)' };
  }
  return { bg: 'var(--surface-2)', color: 'var(--text-2)' };
}

// Resolve a raw enum value (e.g. "medhold", "ikke_tildelt") to its display
// label by walking ``corpus.multi_select_filters`` for the filter declaring
// ``labelPrefix`` and reading its ``value_labels[rawValue]``. Returns the
// raw value as fallback. Single source of truth — adding a new filter value
// only requires editing the filter declaration in country_config.py.
function lookupFilterLabel(corpus, labelPrefix, rawValue) {
  if (!corpus || !labelPrefix) return rawValue;
  const ms = (corpus.multi_select_filters || []).find(m => m.label_prefix === labelPrefix);
  return (ms && ms.value_labels && ms.value_labels[rawValue]) || rawValue;
}

// Boolean: does the corpus carry a filter with the given label_prefix?
// Used by CaseCard to decide whether a corpus-specific badge (Udfald, Ops.
// virkning, Fumus, Type) belongs on a saved item — replaces the old hack
// of probing ``strings[some_well_known_key]`` as a sentinel.
function corpusHasFilter(corpus, labelPrefix) {
  if (!corpus || !labelPrefix) return false;
  return (corpus.multi_select_filters || []).some(m => m.label_prefix === labelPrefix);
}

function CaseCard({ it, cobj, active, strings, onClick, onTrash }) {
  const meta = it.metadata || {};
  // Pick a curated set of pills to render. Different corpora use different keys.
  const pills = [];
  const pushIf = (label, value) => { if (value) pills.push({ label, value: String(value) }); };
  pushIf(strings.projects_metadata_complainant, meta['Klager'] || meta['klager']);
  pushIf(strings.projects_metadata_respondent,  meta['Innklaget'] || meta['indklagede']);
  pushIf(strings.projects_metadata_subject,     meta['Saken gjelder'] || meta['title']);
  pushIf(strings.projects_metadata_decision,    meta['Avgjørelse'] || meta['Type']);
  pushIf(strings.projects_metadata_registered,  meta['Registrert inn'] || meta['date']);

  // Colour-coded v2 verdict badges for KLFU saved items. The label-prefix
  // decides which filter declaration to consult; we render a badge only when
  // the corpus actually carries that filter (cross-country works because the
  // filter list rides on the corpus descriptor). Display labels come from
  // ``filter.value_labels`` via lookupFilterLabel — same source the dropdown
  // uses.
  const verdictBadges = [];
  const pushBadge = (badgeLabel, value, prefix) => {
    if (!value || !badgeLabel) return;
    verdictBadges.push({
      label: badgeLabel,
      display: lookupFilterLabel(cobj, prefix, value),
      tone: _verdictTone(value),
    });
  };
  pushBadge(corpusHasFilter(cobj, 'outcome')      ? 'Udfald'        : null, meta['outcome_summary'],     'outcome');
  pushBadge(corpusHasFilter(cobj, 'ops')          ? 'Ops. virkning' : null, meta['opsættende_virkning'], 'ops');
  pushBadge(corpusHasFilter(cobj, 'fumus')        ? 'Fumus'         : null, meta['fumus_boni_juris'],    'fumus');
  pushBadge(corpusHasFilter(cobj, 'decision')     ? 'Type'          : null, meta['decision_type'],       'decision');
  // Boolean flags — single-tag pills, only when true.
  const flagBadges = [];
  if (meta['is_annulled'])          flagBadges.push('Annulleret');
  if (meta['has_sanction'])         flagBadges.push('Sanktion');
  if (meta['damages_awarded'])      flagBadges.push('Erstatning');
  if (meta['is_stemmeflertallet'])  flagBadges.push('Stemmeflertal');

  return (
    <div onClick={onClick}
      style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 14px', borderRadius: 'var(--r)', border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'), background: 'var(--surface)', cursor: 'pointer' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        {cobj && cobj.mark && <JurisdictionMark kind={cobj.mark}/>}
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: 'Georgia, serif' }}>
            {it.case_title || it.case_id}
          </div>
          <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 1, letterSpacing: '0.04em', textTransform: 'uppercase', fontWeight: 500 }}>
            {(cobj && cobj.label) || it.corpus_id}
          </div>
        </div>
        <button onClick={onTrash} title={strings.projects_remove_item} style={{ ...iconBtn, color: 'var(--text-3)' }}>
          <Icon name="trash" size={14}/>
        </button>
      </div>
      {pills.length > 0 && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginLeft: 32 }}>
          {pills.map((p, i) => (
            <span key={i} style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4, padding: '2px 8px', borderRadius: 4, background: 'var(--surface-2)', fontSize: 11, lineHeight: 1.45, color: 'var(--text-2)', maxWidth: 320, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
              <span style={{ color: 'var(--text-3)', fontWeight: 600, letterSpacing: '0.02em' }}>{p.label}:</span>
              <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.value}</span>
            </span>
          ))}
        </div>
      )}
      {(verdictBadges.length > 0 || flagBadges.length > 0) && (
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginLeft: 32 }}>
          {verdictBadges.map((b, i) => (
            <span key={`v${i}`} style={{ display: 'inline-flex', alignItems: 'baseline', gap: 4, padding: '2px 8px', borderRadius: 4, background: b.tone.bg, fontSize: 11, lineHeight: 1.45, color: b.tone.color, fontWeight: 500 }}>
              {b.label && <span style={{ opacity: 0.7, fontWeight: 600, letterSpacing: '0.02em' }}>{b.label}:</span>}
              <span>{b.display}</span>
            </span>
          ))}
          {flagBadges.map((label, i) => (
            <span key={`f${i}`} style={{ display: 'inline-flex', padding: '2px 8px', borderRadius: 4, background: 'var(--green-bg)', fontSize: 11, lineHeight: 1.45, color: 'var(--green)', fontWeight: 500 }}>
              {label}
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

// Avsnitt: title + corpus mark + the actual paragraph text in a quoted blockquote.
function ParagraphCard({ it, cobj, active, strings, onClick, onTrash }) {
  const text = (it.paragraph_text || '').trim();
  return (
    <div onClick={onClick}
      style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '12px 14px', borderRadius: 'var(--r)', border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'), background: 'var(--surface)', cursor: 'pointer' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
        {cobj && cobj.mark && <JurisdictionMark kind={cobj.mark}/>}
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: 'Georgia, serif' }}>
            {it.case_title || it.case_id}
          </div>
          <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginTop: 1, letterSpacing: '0.04em', textTransform: 'uppercase', fontWeight: 500 }}>
            {(cobj && cobj.label) || it.corpus_id}{it.parent_case_id ? ` · ${it.parent_case_id}` : ''}
          </div>
        </div>
        <button onClick={onTrash} title={strings.projects_remove_item} style={{ ...iconBtn, color: 'var(--text-3)' }}>
          <Icon name="trash" size={14}/>
        </button>
      </div>
      {text && (
        <blockquote style={{ margin: '2px 0 0 32px', padding: '8px 12px', borderLeft: '3px solid var(--accent)', background: 'var(--surface-2)', borderRadius: '0 var(--r-sm) var(--r-sm) 0', fontSize: 12.5, lineHeight: 1.6, color: 'var(--text)', display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
          {text}
        </blockquote>
      )}
    </div>
  );
}

const projectBackPillStyle = () => ({
  display: 'inline-flex', alignItems: 'center', gap: 6,
  padding: '6px 12px', borderRadius: 999,
  border: '1px solid var(--border)', background: 'var(--surface)',
  fontSize: 12, color: 'var(--text-2)', cursor: 'pointer',
});

// ──────────────────────────────────────────────────────────────────────────
// Projects rail (LeftRail section: list of user's saved-case projects)
// ──────────────────────────────────────────────────────────────────────────
function ProjectsRail({ items, currentId, strings, onSelect, onCreate, onRename, onDelete }) {
  const [creating, setCreating] = useState(false);
  const [draftName, setDraftName] = useState('');
  const [renamingId, setRenamingId] = useState(null);
  const [renameDraft, setRenameDraft] = useState('');
  const [confirmDeleteId, setConfirmDeleteId] = useState(null);
  const [menuOpenId, setMenuOpenId] = useState(null);

  const submitCreate = () => {
    const n = draftName.trim();
    if (!n) { setCreating(false); setDraftName(''); return; }
    onCreate(n);
    setCreating(false);
    setDraftName('');
  };
  const submitRename = (pid) => {
    const n = renameDraft.trim();
    if (n && n.length <= 120) onRename(pid, n);
    setRenamingId(null);
    setRenameDraft('');
  };

  return (
    <div data-tour="left-rail-projects" style={{ padding: '14px 12px 6px', borderTop: '1px solid rgba(0,0,0,0.05)' }}>
      <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', padding: '0 6px 8px', display: 'flex', alignItems: 'center', gap: 6 }}>
        <Icon name="folder" size={11} color="var(--text-3)"/>
        <span>{strings.projects_section_title}</span>
      </div>
      {items.length === 0 && !creating && (
        <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '4px 8px 8px', fontStyle: 'italic' }}>{strings.projects_empty}</div>
      )}
      <div>
        {items.map(p => {
          const active = p.id === currentId;
          const isRenaming = renamingId === p.id;
          const isMenuOpen = menuOpenId === p.id;
          const isConfirmingDelete = confirmDeleteId === p.id;
          if (isRenaming) {
            return (
              <input key={p.id} autoFocus type="text" value={renameDraft}
                onChange={e => setRenameDraft(e.target.value.slice(0, 120))}
                onBlur={() => submitRename(p.id)}
                onKeyDown={e => {
                  if (e.key === 'Enter') { e.preventDefault(); submitRename(p.id); }
                  else if (e.key === 'Escape') { e.preventDefault(); setRenamingId(null); setRenameDraft(''); }
                }}
                style={{ display: 'block', width: '100%', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', marginBottom: 2, boxSizing: 'border-box' }}/>
            );
          }
          return (
            <div key={p.id} style={{ position: 'relative' }}
              onMouseLeave={() => { setMenuOpenId(prev => prev === p.id ? null : prev); }}>
              <button onClick={() => onSelect(active ? null : p.id)}
                title={p.name}
                style={{
                  display: 'flex', alignItems: 'center', gap: 8, width: '100%',
                  padding: '6px 8px', borderRadius: 'var(--r-sm)',
                  background: active ? 'var(--accent-bg, rgba(39, 84, 230, 0.10))' : 'transparent',
                  color: active ? 'var(--accent)' : 'var(--text-2)',
                  border: 'none', cursor: 'pointer', textAlign: 'left',
                }}>
                <Icon name="folder" size={13} color={active ? 'var(--accent)' : 'var(--text-3)'}/>
                <span style={{ fontSize: 12.5, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: active ? 600 : 500 }}>{p.name}</span>
                <span style={{ fontSize: 10.5, color: active ? 'var(--accent)' : 'var(--text-3)', background: active ? 'transparent' : 'rgba(0,0,0,0.05)', padding: '1px 6px', borderRadius: 8, minWidth: 16, textAlign: 'center' }}>{p.item_count || 0}</span>
                <span onClick={e => { e.stopPropagation(); setMenuOpenId(prev => prev === p.id ? null : p.id); }}
                  style={{ display: 'inline-flex', padding: 2, borderRadius: 4, opacity: isMenuOpen ? 1 : 0.6 }}>
                  <Icon name="moreH" size={13} color="var(--text-3)"/>
                </span>
              </button>
              {isMenuOpen && !isConfirmingDelete && (
                <div style={{ position: 'absolute', top: '100%', right: 4, zIndex: 30,
                  background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 6,
                  boxShadow: '0 8px 24px rgba(0,0,0,0.10)', padding: 4, minWidth: 130 }}>
                  <button onClick={() => { setRenamingId(p.id); setRenameDraft(p.name); setMenuOpenId(null); }}
                    style={menuItemStyle()}>{strings.projects_rename}</button>
                  <button onClick={() => { setConfirmDeleteId(p.id); setMenuOpenId(null); }}
                    style={{ ...menuItemStyle(), color: '#B83232' }}>{strings.projects_delete}</button>
                </div>
              )}
              {isConfirmingDelete && (
                <div style={{ display: 'flex', gap: 6, padding: '4px 8px 8px', alignItems: 'center' }}>
                  <span style={{ fontSize: 11, color: 'var(--text-3)' }}>{strings.projects_delete_confirm}</span>
                  <button onClick={() => { onDelete(p.id); setConfirmDeleteId(null); }}
                    style={{ padding: '2px 8px', fontSize: 11, borderRadius: 4, border: '1px solid #B83232', background: '#B83232', color: '#fff', cursor: 'pointer' }}>{strings.projects_delete}</button>
                  <button onClick={() => setConfirmDeleteId(null)}
                    style={{ padding: '2px 8px', fontSize: 11, borderRadius: 4, border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text-2)', cursor: 'pointer' }}>{strings.onboarding_back || 'Avbryt'}</button>
                </div>
              )}
            </div>
          );
        })}
      </div>
      {creating ? (
        <input autoFocus type="text" value={draftName}
          placeholder={strings.projects_new_placeholder}
          onChange={e => setDraftName(e.target.value.slice(0, 120))}
          onBlur={submitCreate}
          onKeyDown={e => {
            if (e.key === 'Enter') { e.preventDefault(); submitCreate(); }
            else if (e.key === 'Escape') { e.preventDefault(); setCreating(false); setDraftName(''); }
          }}
          style={{ display: 'block', width: '100%', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', marginTop: 4, boxSizing: 'border-box' }}/>
      ) : (
        <button onClick={() => setCreating(true)}
          style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 8px', borderRadius: 'var(--r-sm)', background: 'transparent', border: 'none', color: 'var(--text-3)', fontSize: 12, cursor: 'pointer', marginTop: 2, textAlign: 'left' }}>
          <Icon name="plus" size={12} color="var(--text-3)"/>
          <span>{strings.projects_new_button}</span>
        </button>
      )}
    </div>
  );
}

const menuItemStyle = () => ({
  display: 'block', width: '100%', padding: '6px 10px', fontSize: 12,
  background: 'transparent', border: 'none', color: 'var(--text-2)',
  textAlign: 'left', cursor: 'pointer', borderRadius: 4,
});

// ──────────────────────────────────────────────────────────────────────────
// Jurisdiction marks
// ──────────────────────────────────────────────────────────────────────────
const JurisdictionMark = ({ kind }) => {
  const marks = {
    all: <div style={{ width: 22, height: 15, borderRadius: 2, background: 'var(--surface-2)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-2)', fontSize: 9, fontWeight: 700, letterSpacing: '-0.02em' }}>∗</div>,
    no: <svg width="22" height="16" viewBox="0 0 22 16" role="img" aria-label="Norsk flagg" style={{ borderRadius: 2, border: '0.5px solid rgba(0,0,0,0.1)', display: 'block', flexShrink: 0 }}>
      <rect width="22" height="16" fill="#EF2B2D"/>
      <rect x="6" y="0" width="4" height="16" fill="#FFFFFF"/>
      <rect x="0" y="6" width="22" height="4" fill="#FFFFFF"/>
      <rect x="7" y="0" width="2" height="16" fill="#002868"/>
      <rect x="0" y="7" width="22" height="2" fill="#002868"/>
    </svg>,
    dk: <svg width="22" height="15" viewBox="0 0 22 15" style={{ borderRadius: 2, border: '0.5px solid rgba(0,0,0,0.1)', flexShrink: 0 }}>
      <rect width="22" height="15" fill="#C8102E"/>
      <rect x="0" y="6" width="22" height="3" fill="#fff"/>
      <rect x="6" y="0" width="3" height="15" fill="#fff"/>
    </svg>,
    de: <svg width="22" height="15" viewBox="0 0 22 15" role="img" aria-label="Deutsche Flagge" style={{ borderRadius: 2, border: '0.5px solid rgba(0,0,0,0.1)', display: 'block', flexShrink: 0 }}>
      <rect width="22" height="5" y="0" fill="#000000"/>
      <rect width="22" height="5" y="5" fill="#DD0000"/>
      <rect width="22" height="5" y="10" fill="#FFCE00"/>
    </svg>,
    eu: <svg width="22" height="15" viewBox="0 0 810 540" style={{ borderRadius: 2, border: '0.5px solid rgba(0,0,0,0.1)' }}>
      <rect width="810" height="540" fill="#003399"/>
      {[...Array(12)].map((_, i) => {
        const a = (i * 30 - 90) * Math.PI / 180;
        return <text key={i} x={405 + 120 * Math.cos(a)} y={270 + 120 * Math.sin(a)} textAnchor="middle" dominantBaseline="central" fill="#FFCC00" fontSize="80">★</text>;
      })}
    </svg>,
    klfu: <img src={asset("/static/images/dk-crown.svg")} alt="Kongekronen" width="22" height="18" style={{ display: 'block', flexShrink: 0 }}/>,
  };
  return marks[kind] || marks.all;
};

// ──────────────────────────────────────────────────────────────────────────
// Source tabs
// ──────────────────────────────────────────────────────────────────────────
function SourceTabs({ corpora, active, onSelect, totals }) {
  // Horizontally scrollable strip of source-cards. The scroller keeps each
  // card uniform width (140px) so 7 home corpora fit on a 1100-wide layout
  // and additional corpora overflow into a horizontal scroll instead of
  // wrapping. Edge fades hint at off-screen content.
  const scrollRef = useRef(null);
  const [edge, setEdge] = useState({ left: false, right: false });
  const recalcEdges = () => {
    const el = scrollRef.current;
    if (!el) return;
    const max = el.scrollWidth - el.clientWidth;
    setEdge({ left: el.scrollLeft > 4, right: el.scrollLeft < max - 4 });
  };
  useEffect(() => {
    recalcEdges();
    const el = scrollRef.current;
    if (!el) return;
    el.addEventListener('scroll', recalcEdges, { passive: true });
    window.addEventListener('resize', recalcEdges);
    return () => {
      el.removeEventListener('scroll', recalcEdges);
      window.removeEventListener('resize', recalcEdges);
    };
  }, [corpora.length]);
  const scrollBy = (delta) => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ left: el.scrollLeft + delta, behavior: 'smooth' });
  };
  // When the active tab changes, ensure it's visible in the scroller.
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const node = el.querySelector(`[data-corpus-id="${active}"]`);
    if (!node) return;
    const elBox = el.getBoundingClientRect();
    const nodeBox = node.getBoundingClientRect();
    if (nodeBox.left < elBox.left + 24 || nodeBox.right > elBox.right - 24) {
      node.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' });
    }
  }, [active]);
  return (
    <div data-tour="source-tabs" style={{ marginBottom: 12, marginTop: 8, position: 'relative' }}>
      {edge.left && (
        <button onClick={() => scrollBy(-240)} aria-label="Bla venstre" className="source-tabs-arrow source-tabs-arrow-left">
          <Icon name="chevronRight" size={14}/>
        </button>
      )}
      {edge.right && (
        <button onClick={() => scrollBy(240)} aria-label="Bla høyre" className="source-tabs-arrow source-tabs-arrow-right">
          <Icon name="chevronRight" size={14}/>
        </button>
      )}
      <div ref={scrollRef} className="source-tabs-scroll"
        data-edge-left={edge.left ? 'true' : 'false'}
        data-edge-right={edge.right ? 'true' : 'false'}>
        {corpora.map(c => {
          const isActive = active === c.id;
          const total = totals[c.id];
          return (
            <button key={c.id} data-corpus-id={c.id}
              className="source-card" data-active={isActive ? 'true' : 'false'}
              onClick={() => onSelect(c.id)}>
              <div className="source-card-row">
                <JurisdictionMark kind={c.mark}/>
                <span className="source-card-label">{c.label}</span>
                {total != null && (
                  <span className="source-card-count" data-active={isActive ? 'true' : 'false'}>{total}</span>
                )}
              </div>
              <span className="source-card-sub">{c.sub}</span>
              {c.data_version && (
                <span className="source-card-version">{c.data_version}</span>
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Result crossfade wrapper — fades content on view mode change
// ──────────────────────────────────────────────────────────────────────────
function ResultFade({ viewMode, render }) {
  const [displayedMode, setDisplayedMode] = useState(viewMode);
  const [phase, setPhase] = useState('visible');
  useEffect(() => {
    if (viewMode === displayedMode) { setPhase('visible'); return; }
    setPhase('fading-out');
    const t = setTimeout(() => {
      setDisplayedMode(viewMode);
      // Use rAF to ensure the DOM updates with new content at opacity 0
      // before we start the fade-in transition
      requestAnimationFrame(() => {
        requestAnimationFrame(() => setPhase('visible'));
      });
    }, 180);
    return () => clearTimeout(t);
  }, [viewMode, displayedMode]);
  return (
    <div style={{
      opacity: phase === 'visible' ? 1 : 0,
      transform: phase === 'visible' ? 'translateY(0)' : 'translateY(6px)',
      transition: 'opacity 0.2s ease, transform 0.2s ease',
    }}>
      {render(displayedMode)}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// View mode toggle — slides between paragraph and table views
// ──────────────────────────────────────────────────────────────────────────
function ViewModeToggle({ mode, onChange, strings, corpus }) {
  // PR C: N-segment toggle driven by corpus.view_modes when present. Lovdata
  // and Retsinfo ship a 3-segment array (Ledd / Paragraf / Dokument); KOFA /
  // KLFU and other corpora fall through to the existing 2-way Avsnitt / Sak.
  //
  // Icon mapping:
  //   3-way (Lovdata-kind):  Ledd → list  · Paragraf → § symbol · Dokument → boxed-bars
  //   2-way (KOFA-kind):     Avsnitt → list · Sak → table grid
  const cornerIcon = (i, n) => {
    if (n === 3) {
      if (i === 0) return 'list';
      if (i === 1) return 'paragraph';
      return 'document';
    }
    return i === 0 ? 'list' : 'table';
  };
  let tabs;
  if (Array.isArray(corpus?.view_modes) && corpus.view_modes.length > 0) {
    tabs = corpus.view_modes.map((vm, i, arr) => ({
      id: vm.value,
      label: vm.label,
      icon: cornerIcon(i, arr.length),
    }));
  } else {
    const paragraphLabel = strings.paragraph_tab || 'Avsnitt';
    const caseLabel      = strings.case_tab      || 'Sak';
    tabs = [
      { id: 'paragraph', label: paragraphLabel, icon: 'list'  },
      { id: 'case',      label: caseLabel,      icon: 'table' },
    ];
  }
  let activeIdx = tabs.findIndex(t => t.id === mode);
  if (activeIdx < 0) activeIdx = 0;
  // Fixed per-segment width so all toggle buttons are equal regardless of
  // label length. 120 px comfortably fits the widest label we expose today
  // ("Dokument" / "Paragraf" + icon + gap). Pill positioning uses the same
  // px value so it aligns exactly with the active button — the previous
  // % math drifted because the container's 3 px padding wasn't subtracted
  // from the percentage base.
  const SEGMENT_PX = 120;
  const PAD_PX = 3;
  return (
    <div data-tour="view-mode" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, marginTop: 8, marginBottom: 4 }}>
      <div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', background: 'var(--surface-2)', borderRadius: 8, padding: PAD_PX, border: '1px solid var(--border-light)' }}>
        {/* Sliding background pill. Animate `transform` instead of `left`:
            `transform` composites on the GPU and avoids the layout/paint
            cycle that `left` triggers on every frame — the earlier `left`
            version stuttered visibly on the Ledd→§ transition because the
            same frame swapped the (much heavier) result-list DOM. */}
        <div style={{
          position: 'absolute', top: PAD_PX, bottom: PAD_PX,
          left: `${PAD_PX}px`,
          width: `${SEGMENT_PX}px`,
          transform: `translateX(${activeIdx * SEGMENT_PX}px)`,
          willChange: 'transform',
          background: 'var(--surface)', borderRadius: 6,
          boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
          transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
          zIndex: 0,
        }}/>
        {tabs.map(t => {
          const a = mode === t.id;
          return (
            <button key={t.id} onClick={() => onChange(t.id)} style={{
              position: 'relative', zIndex: 1,
              display: 'flex', alignItems: 'center', gap: 6, padding: '6px 0', borderRadius: 6, fontSize: 12.5,
              fontWeight: a ? 600 : 400,
              color: a ? 'var(--accent)' : 'var(--text-3)',
              background: 'transparent',
              transition: 'color 0.2s', border: 'none', cursor: 'pointer',
              width: SEGMENT_PX, justifyContent: 'center',
            }}>
              <Icon name={t.icon} size={14}/> {t.label}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Search bar with rotating placeholder
// ──────────────────────────────────────────────────────────────────────────
function SearchBar({ query, onQuery, placeholderSamples, searchMode, onSetSearchMode, lockMode, rerank, onSetRerank, rerankEnabled, dark, locale }) {
  const [focused, setFocused] = useState(false);
  const [idx, setIdx] = useState(0);
  const [popOpen, setPopOpen] = useState(false);
  const anchorRef = useRef(null);
  // Auto-grow the query box: a long (paragraph-length) query wraps onto new
  // lines and the box grows in height up to a cap, then scrolls — rather than
  // staying one line and scrolling sideways.
  const taRef = useRef(null);
  const TA_MAX = 168;
  const autosize = (el) => {
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, TA_MAX) + 'px';
  };
  useEffect(() => { autosize(taRef.current); }, [query]);
  useEffect(() => {
    if (query || focused) return;
    const id = setInterval(() => setIdx(i => (i + 1) % placeholderSamples.length), 5250);
    return () => clearInterval(id);
  }, [query, focused, placeholderSamples.length]);
  const placeholder = placeholderSamples[idx];
  const _modeOpts = searchModeOptions(locale);
  const activeOpt = _modeOpts.find(o => o.value === searchMode) || _modeOpts[0];
  return (
    <div data-tour="search-bar" style={{ position: 'relative', marginBottom: 10 }}>
      <div style={{
        position: 'relative', background: 'var(--surface)',
        border: focused ? '1.5px solid var(--accent)' : '1.5px solid var(--border)',
        borderRadius: 14,
        boxShadow: focused
          ? '0 0 0 4px rgba(90,111,240,0.10), 0 6px 20px rgba(0,0,0,0.06)'
          : '0 2px 10px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03)',
        transition: 'all 0.15s ease', display: 'flex', alignItems: 'stretch',
      }}>
        <div data-tour="search-mode" ref={anchorRef} style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingLeft: 16, paddingRight: 10 }}>
          <button onClick={() => { if (!lockMode) setPopOpen(p => !p); }}
            title={lockMode
              ? `${locale === 'de' ? 'Suchmodus' : 'Søkemodus'}: ${activeOpt.label}`
              : `${locale === 'de' ? 'Suchmodus' : 'Søkemodus'}: ${activeOpt.label}`}
            style={{
              display: 'flex', alignItems: 'center', gap: 6,
              background: 'transparent', border: 'none', padding: 0,
              cursor: lockMode ? 'default' : 'pointer',
            }}>
            <img src={asset(dark ? '/static/images/favicon-dark.png' : '/static/images/favicon.png')} alt="Reliable" style={{ width: 22, height: 22 }}/>
            <span style={{
              fontSize: 9.5, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase',
              color: 'var(--text-3)',
              fontFamily: "'IBM Plex Sans', system-ui, sans-serif",
            }}>{activeOpt.label}</span>
            {rerankEnabled && rerank && (
              <span title={locale === 'de' ? 'Gründlichkeit an' : 'Grundighet på'} style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--accent)' }}/>
            )}
            {!lockMode && <Icon name="chevronDown" size={11} color="var(--text-3)"/>}
          </button>
          {popOpen && onSetSearchMode && (
            <SearchModePopover
              value={searchMode}
              anchorRef={anchorRef}
              rerank={rerank}
              rerankEnabled={rerankEnabled}
              onSetRerank={onSetRerank}
              locale={locale}
              onChange={(m) => {
                if (m && m !== searchMode) onSetSearchMode(m);
                setPopOpen(false);
              }}
            />
          )}
        </div>
        <div style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center' }}>
          {!query && (
            <div key={idx} style={{
              position: 'absolute', left: 0, right: 8, pointerEvents: 'none',
              fontSize: 15, fontFamily: 'Georgia, "Source Serif Pro", serif',
              color: 'var(--text-3)', lineHeight: 1.4,
              overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
              animation: 'placeholderFade 0.6s ease',
              display: 'flex', alignItems: 'center', gap: 10,
            }}>
              <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{placeholder}</span>
              {focused && (
                <span style={{ flexShrink: 0, fontFamily: "'IBM Plex Sans', system-ui, sans-serif", fontSize: 10.5, color: 'var(--text-3)', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: 4, padding: '1px 6px', fontWeight: 500, letterSpacing: '0.02em' }}>Tab ↹</span>
              )}
            </div>
          )}
          <textarea
            ref={taRef}
            value={query}
            rows={1}
            onChange={e => { onQuery(e.target.value); autosize(e.target); }}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            onKeyDown={e => {
              if (e.key === 'Tab' && !query) {
                e.preventDefault();
                onQuery(placeholderSamples[idx]);
              } else if (e.key === 'Enter' && !e.shiftKey) {
                // Enter = no line break (search runs live as-you-type; the box
                // grows by wrapping). Shift+Enter still inserts a newline.
                e.preventDefault();
              }
            }}
            style={{
              width: '100%', padding: '15px 8px 15px 0',
              border: 'none', outline: 'none', background: 'transparent',
              fontSize: 15, color: 'var(--text)',
              fontFamily: 'Georgia, "Source Serif Pro", serif',
              lineHeight: 1.4, position: 'relative', zIndex: 1,
              resize: 'none', display: 'block', boxSizing: 'border-box',
              maxHeight: TA_MAX, overflowY: 'auto', overflowWrap: 'break-word',
            }}
          />
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 2, paddingRight: 10 }}>
          {query && (
            <button onClick={() => onQuery('')} style={{ ...iconBtn, color: 'var(--text-3)' }} title="Tøm søk">
              <Icon name="x" size={14}/>
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Date-range quick chips — rendered inline below the date inputs in the
// Saksdata filter section. Only "date_range" kind presets from
// country_config.quick_chip_presets are surfaced here.
// ──────────────────────────────────────────────────────────────────────────
function DateRangePresets({ primary, value, onChange }) {
  const presets = ((primary && primary.quick_chip_presets) || []).filter(p => p.kind === 'date_range');
  if (presets.length === 0) return null;

  const todayIso = () => new Date().toISOString().slice(0, 10);
  const isoDaysAgo = (days) => {
    const d = new Date();
    d.setDate(d.getDate() - days);
    return d.toISOString().slice(0, 10);
  };

  const isActive = (p) => value.start_date === isoDaysAgo(p.days_back) && value.end_date === todayIso();
  const toggle = (p) => {
    if (isActive(p)) onChange({ ...value, start_date: '', end_date: '' });
    else onChange({ ...value, start_date: isoDaysAgo(p.days_back), end_date: todayIso() });
  };

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
      {presets.map(p => {
        const a = isActive(p);
        return (
          <button key={p.key} onClick={() => toggle(p)} title={p.label}
            style={{
              padding: '2px 9px', borderRadius: 12, fontSize: 10.5, fontWeight: a ? 600 : 500,
              background: a ? 'var(--accent-bg)' : 'var(--surface-2)',
              color: a ? 'var(--accent)' : 'var(--text-2)',
              border: `1px solid ${a ? 'var(--accent)' : 'transparent'}`,
              cursor: 'pointer', transition: 'all 0.12s',
            }}>{p.label}</button>
        );
      })}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Filter accordion (corpus-aware)
// ──────────────────────────────────────────────────────────────────────────
function FilterAccordion({ corpus, primary, strings, value, onChange, options, alwaysOpen }) {
  // Default everything collapsed — users open what they need. The outer wrapper
  // and every sub-section both start closed and only expand when clicked.
  const [open, setOpen] = useState(false);
  // In the persistent sidebar (alwaysOpen), start the main sections expanded and
  // keep only "Avanserte filtre" collapsed (parties = KOFA advanced, eu_advanced
  // = EU-court advanced). The accordion mode starts everything collapsed.
  const [openSections, setOpenSections] = useState(alwaysOpen
    ? { visning: false, seksjon: true, saksdata: true, parties: false, sorter: true, doc: false, unit: false, lang: false, instance: false, section_code: false, catchwords_terms: false, eu_tekstdel: true, eu_saksdata: true, eu_advanced: false, sec_saksdata: true }
    : { visning: false, seksjon: false, saksdata: false, parties: false, sorter: false, doc: false, unit: false, lang: false, instance: false, section_code: false, catchwords_terms: false, eu_tekstdel: false, eu_saksdata: false, eu_advanced: false, sec_saksdata: false });
  const toggle = k => setOpenSections(p => ({ ...p, [k]: !p[k] }));

  // Cross-country foreign tabs ship their own filter config on the corpus
  // descriptor (sections, multi-selects, parties labels, etc.) so the KLFU
  // tab in the NO app renders DK filters and vice versa. Home primary keeps
  // the existing cfg["primary_corpus"] path unchanged.
  const filterCfg = (corpus?.is_cross_country && corpus?.kind === 'kofa') ? corpus : primary;

  const activeCount = useMemo(() => {
    let n = 0;
    if (value.case_id) n++;
    if (value.respondent) n++;
    if (value.complainant) n++;
    if (value.saken_gjelder) n++;
    if (value.start_date || value.end_date) n++;
    Object.entries(value.sections || {}).forEach(([, on]) => { if (on) n++; });
    if (value.doc_slug && value.doc_slug.length) n++;
    if (value.unit_kind && value.unit_kind.length) n++;
    if (value.doc_kind && value.doc_kind.length) n++;
    if (value.instance && value.instance.length) n++;
    if (value.section_code && value.section_code.length) n++;
    if (value.language_code && value.language_code.length) n++;
    if (value.catchwords_terms && value.catchwords_terms.length) n++;
    (filterCfg?.multi_select_filters || []).forEach(ms => {
      if (value[ms.param] && value[ms.param].length) n++;
    });
    (filterCfg?.boolean_filters || []).forEach(bf => {
      if (value[bf.param]) n++;
    });
    return n;
  }, [value, filterCfg]);

  const setField = (k, v) => onChange({ ...value, [k]: v });
  const setSection = (code, on) => onChange({ ...value, sections_touched: true, sections: { ...(value.sections || {}), [code]: on } });

  const isPrimary = corpus?.kind === 'kofa';
  // Lovdata + Retsinformation share the lovdata kind; both expose the same
  // §-level filters (Dokument multi-select + Dokumenttype chips).
  const isLov = corpus?.kind === 'lovdata';
  const isRetsinfo = corpus?.id === 'retsinfo';
  const isEuLaw = corpus?.kind === 'eu_directive';   // eu_directives + eu_regulations
  const isEuCourt = corpus?.id === 'eu_court_decisions';
  const isDfo = corpus?.id === 'dfo_veileder';
  const isVergabekammer = corpus?.kind === 'vergabekammer';

  return (
    <div style={{ marginBottom: 10, border: '1px solid var(--border)', borderRadius: 'var(--r)', background: 'var(--surface)', overflow: 'hidden' }}>
      {alwaysOpen ? (
        // Persistent left-sidebar mode: static header, no collapse toggle.
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 14px', borderBottom: '1px solid var(--border-light)' }}>
          <Icon name="sliders" size={14} color="var(--text-2)"/>
          <span style={{ fontWeight: 500, fontSize: 12.5 }}>{strings.filters_title || 'Filtre'}</span>
          {activeCount > 0 && (
            <span style={{ background: 'var(--accent)', color: '#fff', borderRadius: 10, fontSize: 10, fontWeight: 600, padding: '1px 6px' }}>{activeCount}</span>
          )}
        </div>
      ) : (
        <button onClick={() => setOpen(p => !p)} style={{
          width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          padding: '9px 14px', background: 'transparent', color: 'var(--text)', border: 'none', cursor: 'pointer',
          transition: 'background 180ms cubic-bezier(0.4, 0, 0.2, 1)',
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <Icon name="sliders" size={14} color="var(--text-2)"/>
            <span style={{ fontWeight: 500, fontSize: 12.5 }}>{strings.filters_title || 'Filtre'}</span>
            {activeCount > 0 && (
              <span style={{ background: 'var(--accent)', color: '#fff', borderRadius: 10, fontSize: 10, fontWeight: 600, padding: '1px 6px' }}>{activeCount}</span>
            )}
          </div>
          <div style={{ transform: open ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 220ms cubic-bezier(0.4, 0, 0.2, 1)', color: 'var(--text-3)' }}>
            <Icon name="chevronDown" size={15}/>
          </div>
        </button>
      )}
      {/* `filter-collapse--root` caps the open height to 45vh; sidebar mode
          drops the cap (no `--root`) and lets the column scroll. */}
      <div className={alwaysOpen ? "filter-collapse" : "filter-collapse filter-collapse--root"} data-open={(alwaysOpen || open) ? 'true' : 'false'}>
        <div className="filter-collapse-inner">
        <div style={{ padding: '4px 0' }}>
          {/* View mode toggle moved above search bar */}

          {/* KOFA — Section chips */}
          {isPrimary && filterCfg?.sections?.length > 0 && (
            <FilterSection title={strings.section_heading || 'Seksjon'} open={openSections.seksjon} onToggle={() => toggle('seksjon')}>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                {filterCfg.sections.map(sec => {
                  // Backend applies `default: true` sections silently when sections_touched=false.
                  // Reflect that in the chip so UI and backend agree on first load.
                  const sectionsTouched = value.sections_touched === true;
                  const a = sectionsTouched
                    ? (value.sections || {})[sec.code] === true
                    : sec.default === true;
                  return (
                    <button key={sec.code} onClick={() => setSection(sec.code, !a)} style={{
                      padding: '4px 10px', borderRadius: 20, fontSize: 11.5, fontWeight: a ? 500 : 400,
                      background: a ? 'var(--accent-bg)' : 'var(--surface-2)',
                      color: a ? 'var(--accent)' : 'var(--text-2)',
                      border: `1px solid ${a ? 'var(--accent)' : 'transparent'}`,
                      transition: 'all 0.12s', cursor: 'pointer',
                    }}>{sec.label}</button>
                  );
                })}
              </div>
            </FilterSection>
          )}

          {/* A+ grouped layout (KLFU): filter_groups declares named sections;
              each pulls its built-in extras (case-id / dates / parties) + the
              multi-selects & booleans tagged with its group. Corpora without
              filter_groups (KOFA) fall through to the Saksdata + Avancerede
              blocks below. */}
          {isPrimary && (filterCfg?.filter_groups || []).map(g => {
            const ex = g.extras || [];
            const groupMs = (filterCfg.multi_select_filters || []).filter(ms => ms.group === g.key);
            const groupBf = (filterCfg.boolean_filters || []).filter(bf => bf.group === g.key);
            const isOpen = alwaysOpen ? (openSections[g.key] !== false) : !!openSections[g.key];
            return (
              <FilterSection key={g.key} title={g.title} open={isOpen} onToggle={() => toggle(g.key)}>
                {ex.includes('case_id') && (
                  <div style={{ marginBottom: 10 }}>
                    <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{filterCfg?.case_id_heading || strings.case_id_heading || filterCfg?.col_case_id || 'Saksnummer'}</div>
                    <input type="text" value={value.case_id || ''} onChange={e => setField('case_id', e.target.value)}
                      placeholder={filterCfg?.col_case_id === 'jnr' ? '25/09277, 24/04419' : '2024/129, 2013/122'} style={textInput}/>
                  </div>
                )}
                {ex.includes('dates') && (
                  <div style={{ marginBottom: 10 }}>
                    <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{strings.date_label}</div>
                    <div style={{ display: 'flex', gap: 8 }}>
                      <input type="date" value={value.start_date || ''} onChange={e => setField('start_date', e.target.value)} style={dateInput}/>
                      <input type="date" value={value.end_date || ''} onChange={e => setField('end_date', e.target.value)} style={dateInput}/>
                    </div>
                    <DateRangePresets primary={filterCfg} value={value} onChange={onChange}/>
                  </div>
                )}
                {ex.includes('parties') && (
                  <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, marginBottom: 8 }}>
                    <SuggestInput label={filterCfg?.col_respondent} placeholder={filterCfg?.col_respondent}
                      endpoint={(filterCfg?.suggest_endpoints || []).find(s => s.col === filterCfg?.col_respondent)?.endpoint}
                      value={value.respondent || ''} onChange={v => setField('respondent', v)}/>
                    <SuggestInput label={filterCfg?.col_complainant} placeholder={filterCfg?.col_complainant}
                      endpoint={(filterCfg?.suggest_endpoints || []).find(s => s.col === filterCfg?.col_complainant)?.endpoint}
                      value={value.complainant || ''} onChange={v => setField('complainant', v)}/>
                  </div>
                )}
                {groupMs.map(ms => (
                  <MultiSelectField key={ms.param} label={ms.label}
                    strings={strings} labelPrefix={ms.label_prefix} valueLabels={ms.value_labels}
                    options={(options.multi_select_options || {})[ms.param] || []}
                    counts={(options.multi_select_counts || {})[ms.param]}
                    value={value[ms.param] || []} onChange={vals => setField(ms.param, vals)}/>
                ))}
                {groupBf.length > 0 && (
                  <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap' }}>
                    {groupBf.map(bf => (
                      <BooleanFilterField key={bf.param} label={bf.label}
                        value={value[bf.param]} onChange={v => setField(bf.param, v)}/>
                    ))}
                  </div>
                )}
              </FilterSection>
            );
          })}

          {/* KOFA — Saksdata (saksnummer + date range, side by side) */}
          {isPrimary && !filterCfg?.filter_groups?.length && (
            <FilterSection title={strings.metadata_heading || 'Saksdata'} open={openSections.saksdata} onToggle={() => toggle('saksdata')}>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16 }}>
                <div>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{filterCfg?.case_id_heading || strings.case_id_heading || filterCfg?.col_case_id || 'Saksnummer'}</div>
                  <input type="text" value={value.case_id || ''} onChange={e => setField('case_id', e.target.value)}
                         placeholder={filterCfg?.col_case_id === 'jnr' ? '25/09277, 24/04419' : '2024/129, 2013/122'}
                         style={textInput}/>
                  <div style={{ fontSize: 10, color: 'var(--text-3)', marginTop: 4 }}>{strings.case_id_description}</div>
                </div>
                <div>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{strings.date_label}</div>
                  <div style={{ display: 'flex', gap: 8 }}>
                    <input type="date" value={value.start_date || ''} onChange={e => setField('start_date', e.target.value)} style={dateInput}/>
                    <input type="date" value={value.end_date || ''} onChange={e => setField('end_date', e.target.value)} style={dateInput}/>
                  </div>
                  <DateRangePresets primary={filterCfg} value={value} onChange={onChange}/>
                </div>
              </div>
            </FilterSection>
          )}

          {/* KOFA — Parties + saken gjelder + multi-select (fallback when the
              corpus declares no filter_groups, e.g. KOFA home). */}
          {isPrimary && !filterCfg?.filter_groups?.length && (
            <FilterSection title={strings.advanced_filters || 'Avanserte filtre'} open={openSections.parties} onToggle={() => toggle('parties')}>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 8, marginBottom: 8 }}>
                <SuggestInput label={filterCfg?.col_respondent} placeholder={filterCfg?.col_respondent}
                  endpoint={(filterCfg?.suggest_endpoints || []).find(s => s.col === filterCfg?.col_respondent)?.endpoint}
                  value={value.respondent || ''} onChange={v => setField('respondent', v)}/>
                <SuggestInput label={filterCfg?.col_complainant} placeholder={filterCfg?.col_complainant}
                  endpoint={(filterCfg?.suggest_endpoints || []).find(s => s.col === filterCfg?.col_complainant)?.endpoint}
                  value={value.complainant || ''} onChange={v => setField('complainant', v)}/>
              </div>
              {filterCfg?.has_saken_gjelder && (
                <div style={{ marginBottom: 8 }}>
                  <SuggestInput label={strings.saken_gjelder_label || 'Saken gjelder'} placeholder="Tema, kategorier (komma)"
                    endpoint={(filterCfg.suggest_endpoints || []).find(s => s.col === 'Saken gjelder')?.endpoint}
                    value={value.saken_gjelder || ''} onChange={v => setField('saken_gjelder', v)}
                    splitComma/>
                </div>
              )}
              {/* Multi-selects: non-advanced render in-line; advanced ones
                  go inside a progressive-disclosure block below to keep the
                  default filter UI legible. */}
              {(filterCfg?.multi_select_filters || []).filter(ms => !ms.advanced).map(ms => (
                <MultiSelectField key={ms.param}
                  label={ms.label}
                  strings={strings} labelPrefix={ms.label_prefix} valueLabels={ms.value_labels}
                  options={(options.multi_select_options || {})[ms.param] || []}
                  counts={(options.multi_select_counts || {})[ms.param]}
                  value={value[ms.param] || []} onChange={vals => setField(ms.param, vals)}/>
              ))}

              {/* Advanced filters disclosure — multi-selects + booleans flagged
                  with ``advanced: true`` in country_config. Only renders when
                  the corpus declares any advanced filters. */}
              {(((filterCfg?.multi_select_filters || []).some(ms => ms.advanced))
                || ((filterCfg?.boolean_filters || []).some(bf => bf.advanced))) && (
                <details style={{ marginTop: 6, marginBottom: 4 }}>
                  <summary style={{ cursor: 'pointer', fontSize: 11, color: 'var(--text-3)', padding: '4px 0', userSelect: 'none' }}>
                    {strings.advanced_filters_toggle || 'Flere filtre'}
                  </summary>
                  <div style={{ paddingTop: 8 }}>
                    {(filterCfg?.multi_select_filters || []).filter(ms => ms.advanced).map(ms => (
                      <MultiSelectField key={ms.param}
                        label={ms.label}
                        strings={strings} labelPrefix={ms.label_prefix} valueLabels={ms.value_labels}
                        options={(options.multi_select_options || {})[ms.param] || []}
                        counts={(options.multi_select_counts || {})[ms.param]}
                        value={value[ms.param] || []} onChange={vals => setField(ms.param, vals)}/>
                    ))}
                    {(filterCfg?.boolean_filters || []).filter(bf => bf.advanced).length > 0 && (
                      <div style={{ marginTop: 6 }}>
                        <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 4 }}>Yderligere flag</div>
                        <div style={{ display: 'flex', flexWrap: 'wrap' }}>
                          {(filterCfg?.boolean_filters || []).filter(bf => bf.advanced).map(bf => (
                            <BooleanFilterField key={bf.param}
                              label={bf.label}
                              value={value[bf.param]}
                              onChange={v => setField(bf.param, v)}/>
                          ))}
                        </div>
                      </div>
                    )}
                  </div>
                </details>
              )}
              {/* Non-advanced booleans render in-line below the multi-selects */}
              {(filterCfg?.boolean_filters || []).filter(bf => !bf.advanced).length > 0 && (
                <div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap' }}>
                  {(filterCfg?.boolean_filters || []).filter(bf => !bf.advanced).map(bf => (
                    <BooleanFilterField key={bf.param}
                      label={bf.label}
                      value={value[bf.param]}
                      onChange={v => setField(bf.param, v)}/>
                  ))}
                </div>
              )}
              {(value.view_mode || 'case') === 'case' && (
                <div style={{ marginTop: 8 }}>
                  <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>{strings.sort_label || 'Sorter etter'}</div>
                  <div style={{ display: 'flex', gap: 8 }}>
                    <select value={value.sort_by || ''} onChange={e => setField('sort_by', e.target.value)} style={selectInput}>
                      {(corpus?.sort_options || []).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
                    </select>
                    <select value={value.sort_order || 'desc'} onChange={e => setField('sort_order', e.target.value)} style={{ ...selectInput, width: 120 }}>
                      <option value="desc">{strings.descending}</option>
                      <option value="asc">{strings.ascending}</option>
                    </select>
                  </div>
                </div>
              )}
            </FilterSection>
          )}

          {/* Law / EU-laws / guide corpora — KOFA-style Saksdata: Dokumenttype
              (if applicable) then a document typeahead. The standalone "Sorter
              etter" section below follows it. EU court has its own Saksdata group. */}
          {(isLov || isEuLaw || isDfo) && (
            <FilterSection title={strings.metadata_heading || 'Saksdata'} open={openSections.sec_saksdata} onToggle={() => toggle('sec_saksdata')}>
              {isLov && (
                <div style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>Dokumenttype</div>
                  <ChipMulti
                    options={isRetsinfo
                      ? [{ value: 'lbk', label: 'Lovbekendtgørelse' }, { value: 'bek', label: 'Bekendtgørelse' }]
                      : [{ value: 'lov', label: 'Lov' }, { value: 'forskrift', label: 'Forskrift' }]}
                    counts={(options.multi_select_counts || {}).doc_kind}
                    value={value.doc_kind || []} onChange={vals => setField('doc_kind', vals)}/>
                </div>
              )}
              {(isEuLaw || corpus.id === 'retsinfo') && (
                <div style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{corpus.id === 'retsinfo' ? 'Tekstdel' : (strings.filter_unit_type || 'Type avsnitt')}</div>
                  <ChipMulti
                    options={corpus.id === 'retsinfo'
                      ? [{ value: 'Paragraffer', label: 'Paragraffer' }, { value: 'Bilag', label: 'Bilag' }]
                      : [{ value: 'recital', label: 'Fortale' }, { value: 'article', label: 'Artikkel' }]}
                    counts={corpus.id === 'retsinfo' ? undefined : (options.multi_select_counts || {}).unit_kind}
                    value={value.unit_kind || []} onChange={vals => setField('unit_kind', vals)}/>
                </div>
              )}
              <DocSuggestFilter corpus={corpus.id} label={isDfo ? (strings.filter_guide || 'Veileder') : 'Dokument'} placeholder={isDfo ? (strings.filter_chapter_search || 'Søk i kapitler…') : (strings.filter_doc_search || 'Søk i dokumenter…')}
                value={value.doc_slug || []} onChange={vals => setField('doc_slug', vals)}/>
            </FilterSection>
          )}
          {/* EU court — KOFA-style grouped filters: Tekstdel / Saksdata / Avanserte filtre */}
          {isEuCourt && (corpus.section_codes || []).length > 0 && (
            <FilterSection title="Tekstdel" open={openSections.eu_tekstdel} onToggle={() => toggle('eu_tekstdel')}>
              <ChipMulti
                options={(corpus.section_codes || []).map(code => ({
                  value: code,
                  label: (primary?.id === 'klfu' ? SECTION_CODE_LABELS_DA : SECTION_CODE_LABELS_NB)[code] || code,
                }))}
                counts={(options.multi_select_counts || {}).section_code}
                value={value.section_code || []}
                onChange={vals => setField('section_code', vals)}/>
            </FilterSection>
          )}
          {isEuCourt && (
            <FilterSection title={strings.metadata_heading || 'Saksdata'} open={openSections.eu_saksdata} onToggle={() => toggle('eu_saksdata')}>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16 }}>
                <div>
                  <DocSuggestFilter corpus={corpus.id} label={strings.filter_case_number || 'Saksnummer'} placeholder="C-413/17"
                    value={value.doc_slug || []} onChange={vals => setField('doc_slug', vals)}/>
                  {(corpus.instances || []).length > 0 && (
                    <div style={{ marginTop: 12 }}>
                      <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>Instans</div>
                      <ChipMulti
                        options={(corpus.instances || []).map(code => ({ value: code, label: COURT_LABELS_NB[code] || code }))}
                        value={value.instance || []}
                        onChange={vals => setField('instance', vals)}/>
                    </div>
                  )}
                </div>
                <div>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>Domsdato</div>
                  <div style={{ display: 'flex', gap: 8 }}>
                    <input type="date" value={value.start_date || ''} onChange={e => setField('start_date', e.target.value)} style={dateInput}/>
                    <input type="date" value={value.end_date || ''} onChange={e => setField('end_date', e.target.value)} style={dateInput}/>
                  </div>
                  <DateRangePresets primary={primary} value={value} onChange={onChange}/>
                </div>
              </div>
            </FilterSection>
          )}
          {isEuCourt && (
            <FilterSection title={strings.advanced_filters || 'Avanserte filtre'} open={openSections.eu_advanced} onToggle={() => toggle('eu_advanced')}>
              {(corpus.original_languages || []).length > 0 && (
                <div style={{ marginBottom: 12 }}>
                  <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{strings.filter_original_language || 'Originalspråk'}</div>
                  <ChipMulti
                    options={(corpus.original_languages || []).map(code => ({ value: code, label: `${(LANGUAGE_LABELS_NB[code] || code.toLowerCase())} (${code})` }))}
                    counts={(options.multi_select_counts || {}).language_code}
                    value={value.language_code || []}
                    onChange={vals => setField('language_code', vals)}/>
                </div>
              )}
              {/* Saksområde (catchwords). Self-hides when the current result set has
                  no catchwords (only ~49/182 judgments carry them). RAW-STRING
                  contract preserved — MultiSelectField wraps each string itself;
                  passing {value,label} objects would trigger React #31. */}
              {(corpus.multi_select_filters || []).some(ms => ms.param === 'catchwords_terms') && (() => {
                const cwCounts = (options.multi_select_counts || {}).catchwords_terms || {};
                const cwSelected = value.catchwords_terms || [];
                const cwOptions = Object.entries(cwCounts)
                  .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
                  .slice(0, 50)
                  .map(([term]) => term);
                const known = new Set(cwOptions);
                cwSelected.forEach(v => { if (!known.has(v)) cwOptions.push(v); });
                if (cwOptions.length === 0) return null;
                return (
                  <div style={{ marginBottom: 12 }}>
                    <MultiSelectField
                      label={strings.filter_subject_area || 'Saksområde'}
                      options={cwOptions}
                      counts={cwCounts}
                      value={cwSelected}
                      onChange={vals => setField('catchwords_terms', vals)}
                      placeholder="Velg saksområde…"/>
                  </div>
                );
              })()}
              <div style={{ marginTop: 4 }}>
                <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{strings.sort_label || 'Sorter etter'}</div>
                <div style={{ display: 'flex', gap: 8 }}>
                  <select value={value.sort_by || ''} onChange={e => setField('sort_by', e.target.value)} style={selectInput}>
                    {(corpus?.sort_options || []).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
                  </select>
                  <select value={value.sort_order || 'desc'} onChange={e => setField('sort_order', e.target.value)} style={{ ...selectInput, width: 120 }}>
                    <option value="desc">{strings.descending}</option>
                    <option value="asc">{strings.ascending}</option>
                  </select>
                </div>
              </div>
            </FilterSection>
          )}

          {/* Vergabekammer (German) — date-range "time" filter + config-declared
              multi-selects (Spruchkörper / Entscheidungsart / Abschnitt). The
              corpus kind isn't primary/lov/eu, so it needs its own filter block. */}
          {isVergabekammer && (
            <FilterSection title={strings.filters_title || 'Filter'} open={openSections.vk_filters !== false} onToggle={() => toggle('vk_filters')}>
              <div style={{ marginBottom: 14 }}>
                <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{strings.date_label || 'Entscheidungsdatum (von/bis)'}</div>
                <div style={{ display: 'flex', gap: 8 }}>
                  <input type="date" value={value.start_date || ''} onChange={e => setField('start_date', e.target.value)} style={dateInput}/>
                  <input type="date" value={value.end_date || ''} onChange={e => setField('end_date', e.target.value)} style={dateInput}/>
                </div>
              </div>
              {(corpus.multi_select_filters || []).map(ms => {
                const opts = (options.multi_select_options || {})[ms.param] || [];
                if (!opts.length) return null;
                return (
                  <div key={ms.param} style={{ marginBottom: 12 }}>
                    <MultiSelectField
                      label={ms.label}
                      options={opts}
                      counts={(options.multi_select_counts || {})[ms.param]}
                      valueLabels={ms.value_labels}
                      value={value[ms.param] || []}
                      onChange={vals => setField(ms.param, vals)}
                      placeholder={ms.placeholder}/>
                  </div>
                );
              })}
            </FilterSection>
          )}

          {/* Sort — shown standalone for non-KOFA corpora, except EU court (which
              renders sort inside its Avanserte filtre group above). */}
          {!isPrimary && !isEuCourt && (
            <FilterSection title={strings.sort_label || 'Sorter etter'} open={openSections.sorter} onToggle={() => toggle('sorter')}>
              <div style={{ display: 'flex', gap: 8 }}>
                <select value={value.sort_by || ''} onChange={e => setField('sort_by', e.target.value)} style={selectInput}>
                  {(corpus?.sort_options || []).map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
                </select>
                <select value={value.sort_order || 'desc'} onChange={e => setField('sort_order', e.target.value)} style={{ ...selectInput, width: 120 }}>
                  <option value="desc">{strings.descending}</option>
                  <option value="asc">{strings.ascending}</option>
                </select>
              </div>
            </FilterSection>
          )}
        </div>
        </div>
      </div>
    </div>
  );
}

const FilterSection = ({ title, open, onToggle, children }) => (
  <div style={{ padding: '0 14px', borderTop: '1px solid var(--border-light)' }}>
    <button onClick={onToggle} style={{ width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', color: 'var(--text-2)', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', background: 'transparent', border: 'none', cursor: 'pointer' }}>
      {title}
      <span style={{ display: 'inline-flex', transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 180ms cubic-bezier(0.4, 0, 0.2, 1)' }}>
        <Icon name="chevronRight" size={13}/>
      </span>
    </button>
    <div className="filter-collapse" data-open={open ? 'true' : 'false'}>
      <div style={{ overflow: 'hidden' }}>
        <div style={{ paddingBottom: 12 }}>{children}</div>
      </div>
    </div>
  </div>
);

const dateInput = { flex: 1, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '5px 8px', fontSize: 11, color: 'var(--text-2)', background: 'var(--surface-2)' };
const textInput = { width: '100%', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '6px 10px', fontSize: 12, color: 'var(--text)', background: 'var(--surface)', outline: 'none' };
const selectInput = { flex: 1, padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', fontSize: 12, color: 'var(--text)', background: 'var(--surface)' };

function ChipMulti({ options, value, onChange, counts }) {
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
      {options.map(opt => {
        const a = value.includes(opt.value);
        const c = counts ? counts[opt.value] : undefined;
        const label = (typeof c === 'number') ? `${opt.label} (${c})` : opt.label;
        return (
          <button key={opt.value} onClick={() => onChange(a ? value.filter(v => v !== opt.value) : [...value, opt.value])}
            style={{
              padding: '4px 10px', borderRadius: 20, fontSize: 11.5, fontWeight: a ? 500 : 400,
              background: a ? 'var(--accent-bg)' : 'var(--surface-2)',
              color: a ? 'var(--accent)' : 'var(--text-2)',
              border: `1px solid ${a ? 'var(--accent)' : 'transparent'}`, cursor: 'pointer',
            }}>{label}</button>
        );
      })}
    </div>
  );
}

function MultiSelectField({ label, options, value, onChange, counts, strings, labelPrefix, valueLabels }) {
  // Value-label resolution order: (1) the filter declaration's own
  // ``value_labels`` map (single source of truth, travels with the filter
  // across cross-country), (2) ``strings[`${labelPrefix}_${V}`]`` legacy
  // path (kept for any caller that hasn't migrated), (3) raw value.
  const displayFor = (v) => {
    if (valueLabels && typeof valueLabels[v] === 'string') return valueLabels[v];
    if (labelPrefix && strings) {
      const key = `${labelPrefix}_${v}`;
      if (typeof strings[key] === 'string') return strings[key];
    }
    return v;
  };
  const labelFor = (o) => {
    const c = counts ? counts[o] : undefined;
    const disp = displayFor(o);
    return (typeof c === 'number') ? `${disp} (${c})` : disp;
  };
  return (
    <div style={{ marginBottom: 8 }}>
      <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 4 }}>{label}</div>
      <ChipMulti options={options.map(o => ({ value: o, label: labelFor(o) }))} value={value} onChange={onChange}/>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// BooleanFilterField — single-pill checkbox for boolean parquet columns.
// Used by the country_config.boolean_filters declarations (KLFU's is_annulled,
// has_sanction, etc.). Active state toggles ``value[param] = true|false``;
// the backend reads it from kwargs and applies ``df[col] == True``.
// ──────────────────────────────────────────────────────────────────────────
function BooleanFilterField({ label, value, onChange }) {
  const active = !!value;
  return (
    <button onClick={() => onChange(!active)}
      style={{
        padding: '4px 10px', borderRadius: 20, fontSize: 11.5, fontWeight: active ? 500 : 400,
        background: active ? 'var(--accent-bg)' : 'var(--surface-2)',
        color: active ? 'var(--accent)' : 'var(--text-2)',
        border: `1px solid ${active ? 'var(--accent)' : 'transparent'}`,
        cursor: 'pointer', marginRight: 6, marginBottom: 6,
      }}>{label}</button>
  );
}

function SuggestInput({ label, placeholder, endpoint, value, onChange, splitComma }) {
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState([]);
  const [hi, setHi] = useState(-1);
  const wrapRef = useRef(null);

  useEffect(() => {
    if (!endpoint) return;
    if (!value || value.length < 2) { setItems([]); setOpen(false); return; }
    const lastFragment = splitComma ? value.split(',').pop().trim() : value;
    if (!lastFragment) { setItems([]); setOpen(false); return; }
    let cancelled = false;
    Api.suggest(endpoint, lastFragment).then(r => {
      if (cancelled) return;
      setItems(Array.isArray(r) ? r.slice(0, 8) : []);
      setOpen(true);
    });
    return () => { cancelled = true; };
  }, [value, endpoint, splitComma]);

  useEffect(() => {
    const onDoc = e => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, []);

  const accept = (s) => {
    if (splitComma) {
      const parts = value.split(',');
      parts[parts.length - 1] = ' ' + s;
      onChange(parts.join(','));
    } else {
      onChange(s);
    }
    setOpen(false);
    setHi(-1);
  };

  return (
    <div ref={wrapRef} style={{ position: 'relative' }}>
      <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 3 }}>{label}</div>
      <input value={value} onChange={e => onChange(e.target.value)} placeholder={placeholder}
        onFocus={() => { if (items.length) setOpen(true); }}
        onKeyDown={e => {
          if (!open || !items.length) return;
          if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(items.length - 1, h + 1)); }
          else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(0, h - 1)); }
          else if (e.key === 'Enter' && hi >= 0) { e.preventDefault(); accept(items[hi]); }
          else if (e.key === 'Escape') { setOpen(false); }
        }}
        style={textInput}/>
      {open && items.length > 0 && (
        <div style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: '0 6px 20px rgba(0,0,0,0.08)', zIndex: 20, maxHeight: 220, overflowY: 'auto' }}>
          {items.map((it, i) => (
            <div key={it + i}
              onMouseDown={e => { e.preventDefault(); accept(it); }}
              onMouseEnter={() => setHi(i)}
              style={{ padding: '6px 10px', fontSize: 12, color: 'var(--text)', cursor: 'pointer', background: hi === i ? 'var(--accent-bg)' : 'transparent' }}>
              {it}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

function DocSlugFilter({ corpus, value, onChange }) {
  // Slugs are pulled from the parquet on demand. Cached per-corpus on the
  // backend, so this fetch is cheap. While loading, render a hint.
  const [opts, setOpts] = useState(null);
  const [filter, setFilter] = useState('');
  useEffect(() => {
    let alive = true;
    setOpts(null);
    Api.getDocSlugs(corpus).then(r => {
      if (!alive) return;
      setOpts(r.options || []);
    }).catch(() => { if (alive) setOpts([]); });
    return () => { alive = false; };
  }, [corpus]);

  if (opts === null) {
    return <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '4px 0' }}>Laster dokumenter…</div>;
  }
  if (!opts.length) {
    return <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '4px 0' }}>Ingen dokumenter</div>;
  }

  const f = filter.trim().toLowerCase();
  const visible = f
    ? opts.filter(o => o.label.toLowerCase().includes(f) || o.value.toLowerCase().includes(f))
    : opts;
  // Always include selected items even if they don't match the filter, so users can
  // see and remove an active selection.
  const selectedNotVisible = opts.filter(o => value.includes(o.value) && !visible.includes(o));
  const list = [...selectedNotVisible, ...visible];

  return (
    <div>
      {opts.length > 8 && (
        <input
          type="text"
          placeholder={`Søk i ${opts.length} dokumenter…`}
          value={filter}
          onChange={e => setFilter(e.target.value)}
          style={{ ...textInput, marginBottom: 8 }}
        />
      )}
      <div style={{ maxHeight: opts.length > 30 ? 240 : undefined, overflowY: opts.length > 30 ? 'auto' : undefined }}>
        <ChipMulti options={list} value={value} onChange={onChange}/>
      </div>
      {f && visible.length === 0 && selectedNotVisible.length === 0 && (
        <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Ingen treff på «{filter}».</div>
      )}
    </div>
  );
}

// Saksnummer-style typeahead over a corpus's case list — modeled on SuggestInput's
// look, but sourced CLIENT-SIDE from /api/doc-slugs (already returns
// {value: doc_slug, label: "case_no — parties_short"}), so there's no per-keystroke
// backend call and no new route. Selecting a suggestion stores the case's doc_slug in
// the existing ``doc_slug`` filter (matched server-side via isin); the input/chips show
// the case number. Multi-select — selected cases render as removable ChipMulti chips.
function DocSuggestFilter({ corpus, value, onChange, label, placeholder }) {
  const [opts, setOpts] = useState(null);      // [{value: slug, label: "case_no — parties"}]
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const [hi, setHi] = useState(-1);
  const wrapRef = useRef(null);
  const inputRef = useRef(null);
  const [menu, setMenu] = useState(null);  // {top,left,width} for the fixed-positioned dropdown
  const sel = value || [];

  useEffect(() => {
    let alive = true;
    setOpts(null);
    Api.getDocSlugs(corpus).then(r => { if (alive) setOpts(r.options || []); })
      .catch(() => { if (alive) setOpts([]); });
    return () => { alive = false; };
  }, [corpus]);

  useEffect(() => {
    const onDoc = e => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, []);

  const q = query.trim().toLowerCase();
  // Short corpora (<= SHOW_ALL_MAX docs) list every option on focus so they're
  // browsable without guessing; large lists (DFØ 105, EU court 182) require typing.
  const SHOW_ALL_MAX = 20;
  const avail = (opts || []).filter(o => !sel.includes(o.value));
  const matches = q
    ? avail.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)).slice(0, 8)
    : (avail.length <= SHOW_ALL_MAX ? avail : []);
  const accept = (o) => {
    if (!sel.includes(o.value)) onChange([...sel, o.value]);
    setQuery(''); setOpen(false); setHi(-1);
  };
  // Chips show just the case number (the label is "case_no — parties_short").
  const selOpts = sel.map(slug => {
    const found = (opts || []).find(o => o.value === slug);
    return { value: slug, label: found ? found.label.split(' — ')[0] : slug };
  });

  // Position the dropdown with position:fixed so it escapes the filter panel's
  // 45vh overflow:auto clip (which otherwise cuts the menu off / mis-places it —
  // "kommer for høyt opp / for tett på"). Re-anchor to the input on scroll/resize
  // so it tracks while the panel scrolls.
  useEffect(() => {
    if (!open || !matches.length) { setMenu(null); return; }
    const place = () => {
      const r = inputRef.current && inputRef.current.getBoundingClientRect();
      if (r) setMenu({ top: r.bottom + 2, left: r.left, width: r.width });
    };
    place();
    let raf = 0;
    const onMove = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(place); };
    window.addEventListener('scroll', onMove, true);
    window.addEventListener('resize', onMove);
    return () => { cancelAnimationFrame(raf); window.removeEventListener('scroll', onMove, true); window.removeEventListener('resize', onMove); };
  }, [open, matches.length]);

  if (opts === null) {
    return <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '4px 0' }}>Laster…</div>;
  }

  return (
    <div ref={wrapRef}>
      {label && <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{label}</div>}
      <div style={{ position: 'relative' }}>
        <input ref={inputRef} type="text" value={query} placeholder={placeholder}
          onChange={e => { setQuery(e.target.value); setOpen(true); setHi(-1); }}
          onFocus={() => { if (matches.length) setOpen(true); }}
          onKeyDown={e => {
            if (!open || !matches.length) return;
            if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(matches.length - 1, h + 1)); }
            else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(0, h - 1)); }
            else if (e.key === 'Enter' && hi >= 0) { e.preventDefault(); accept(matches[hi]); }
            else if (e.key === 'Escape') { setOpen(false); }
          }}
          style={textInput}/>
        {open && matches.length > 0 && menu && (
          <div style={{ position: 'fixed', top: menu.top, left: menu.left, width: menu.width, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: '0 6px 20px rgba(0,0,0,0.12)', zIndex: 1000, maxHeight: 220, overflowY: 'auto' }}>
            {matches.map((it, i) => (
              <div key={it.value}
                onMouseDown={e => { e.preventDefault(); accept(it); }}
                onMouseEnter={() => setHi(i)}
                style={{ padding: '6px 10px', fontSize: 12, color: 'var(--text)', cursor: 'pointer', background: hi === i ? 'var(--accent-bg)' : 'transparent' }}>
                {it.label}
              </div>
            ))}
          </div>
        )}
      </div>
      {selOpts.length > 0 && (
        <div style={{ marginTop: 8 }}>
          <ChipMulti options={selOpts} value={sel} onChange={onChange}/>
        </div>
      )}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Active filter chips
// ──────────────────────────────────────────────────────────────────────────
function ActiveFilterChips({ chips, onRemove, onClearAll, label = 'Aktive filtre:' }) {
  if (!chips.length) return null;
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, flexWrap: 'wrap' }}>
      <span style={{ fontSize: 10.5, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>{label}</span>
      {chips.map(c => (
        <span key={c.key} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--accent-bg)', color: 'var(--accent)', padding: '3px 8px', borderRadius: 20, fontSize: 11.5, fontWeight: 500 }}>
          {c.label}
          <button onClick={() => onRemove(c.key)} style={{ ...iconBtn, color: 'var(--accent)', opacity: 0.7, padding: 0 }}><Icon name="x" size={11}/></button>
        </span>
      ))}
      <button onClick={onClearAll} style={{ fontSize: 11, color: 'var(--text-3)', textDecoration: 'underline', background: 'transparent', border: 'none', cursor: 'pointer' }}>Rydd alle</button>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Result cards — one per corpus kind
// ──────────────────────────────────────────────────────────────────────────
const cardBase = (selected) => ({
  background: 'var(--surface)', border: `1.5px solid ${selected ? 'var(--accent)' : 'var(--border)'}`,
  borderRadius: 'var(--r)', padding: '14px 16px', marginBottom: 6, cursor: 'pointer',
  transition: 'border-color 0.15s, box-shadow 0.15s',
  boxShadow: selected ? '0 0 0 3px rgba(90,111,240,0.10)' : '0 1px 3px rgba(0,0,0,0.04)',
});

const cardHover = {
  enter: e => { e.currentTarget.style.borderColor = '#C5D0F8'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.06)'; },
  leave: (selected) => e => {
    e.currentTarget.style.borderColor = selected ? 'var(--accent)' : 'var(--border)';
    e.currentTarget.style.boxShadow = selected ? '0 0 0 3px rgba(90,111,240,0.10)' : '0 1px 3px rgba(0,0,0,0.04)';
  },
};

// Save a single KOFA paragraph hit (from the result list) into a project.
// A compact icon-only button with an anchored project picker — mirrors the
// SaveToProjectButton in the detail panel but works directly from a hit row
// so the user doesn't have to open the detail view first.
function HitQuickSaveButton({ hit, corpus, query, projects, onSave, onCreateProject, strings }) {
  const [open, setOpen] = useState(false);
  const [creating, setCreating] = useState(false);
  const [draftName, setDraftName] = useState('');
  const [saved, setSaved] = useState(false);
  const anchorRef = useRef(null);
  const popRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onDocClick = (e) => {
      if (popRef.current && popRef.current.contains(e.target)) return;
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onKey);
    return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); };
  }, [open]);

  // Derive paragraph save-payload from the hit (search-result row shape) —
  // similar to extractSaveData's paragraph branch but for hits, not details.
  const buildPayload = () => {
    const text = (hit.paragraph || hit.text || '') + '';
    const snippet = text.slice(0, 80).replace(/\s+/g, ' ').trim();
    const more = text.length > 80;
    const caseId = hit.case_id || '';
    const itemId = hit.pid || hit.pid || `${caseId}#${hit.para_idx ?? hit.section ?? 'p'}`;
    const title = snippet ? `${caseId} — "${snippet}${more ? '…' : ''}"` : caseId;
    return {
      corpusId: corpus.id,
      caseId: String(itemId),
      caseTitle: title,
      itemKind: 'paragraph',
      parentCaseId: caseId || null,
      paragraphText: text.slice(0, _PARAGRAPH_TEXT_MAX),
      queryText: (query || '').trim() || null,
      metadata: hit.metadata || {},
    };
  };

  const doSave = (project) => {
    const payload = buildPayload();
    if (!payload.caseId) return;
    Promise.resolve(onSave(project.id, payload))
      .then(() => {
        setOpen(false);
        setSaved(true);
        setTimeout(() => setSaved(false), 1400);
      })
      .catch(() => { setOpen(false); });
  };

  const submitCreate = () => {
    const n = draftName.trim();
    if (!n) { setCreating(false); setDraftName(''); return; }
    Promise.resolve(onCreateProject(n)).then(project => {
      setCreating(false);
      setDraftName('');
      if (project && project.id) doSave(project);
    }).catch(() => { setCreating(false); setDraftName(''); });
  };

  return (
    <div style={{ position: 'relative' }} onClick={(e) => e.stopPropagation()}>
      <button ref={anchorRef} onClick={() => setOpen(o => !o)}
        title={strings?.projects_save_to_button || 'Lagre i prosjekt'}
        style={{ ...iconBtn, color: saved ? 'var(--green)' : 'var(--text-3)' }}>
        <Icon name={saved ? 'check' : 'bookmark'} size={13} color={saved ? 'var(--green)' : 'var(--text-3)'}/>
      </button>
      {open && (
        <div ref={popRef}
          style={{ position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 60,
            minWidth: 220, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8,
            boxShadow: '0 12px 40px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06)',
            padding: 6, maxHeight: 280, overflowY: 'auto' }}>
          <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', padding: '6px 8px 4px' }}>
            {strings?.projects_save_picker_title || 'Velg prosjekt'}
          </div>
          {projects.length === 0 && !creating && (
            <div style={{ fontSize: 11.5, color: 'var(--text-3)', padding: '6px 10px 8px', fontStyle: 'italic' }}>
              {strings?.projects_empty || 'Ingen prosjekter ennå'}
            </div>
          )}
          {projects.map(p => (
            <button key={p.id} onClick={() => doSave(p)}
              style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left' }}>
              <Icon name="folder" size={13} color="var(--text-3)"/>
              <span style={{ fontSize: 12.5, color: 'var(--text)', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
              <span style={{ fontSize: 10.5, color: 'var(--text-3)' }}>{p.item_count || 0}</span>
            </button>
          ))}
          {creating ? (
            <input autoFocus type="text" value={draftName}
              placeholder={strings?.projects_new_placeholder || 'Nytt prosjektnavn'}
              onChange={e => setDraftName(e.target.value.slice(0, 120))}
              onBlur={submitCreate}
              onKeyDown={e => {
                if (e.key === 'Enter') { e.preventDefault(); submitCreate(); }
                else if (e.key === 'Escape') { e.preventDefault(); setCreating(false); setDraftName(''); }
              }}
              style={{ display: 'block', width: 'calc(100% - 16px)', margin: '4px 8px 4px', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', boxSizing: 'border-box' }}/>
          ) : (
            <button onClick={() => setCreating(true)}
              style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '7px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', textAlign: 'left', borderTop: '1px solid var(--border-light)', marginTop: 2 }}>
              <Icon name="plus" size={12} color="var(--accent)"/>
              <span>{strings?.projects_new_button || '+ Nytt prosjekt'}</span>
            </button>
          )}
        </div>
      )}
    </div>
  );
}

// Merged avsnitt — especially KLFU narrative sections (sagsfremstilling /
// anbringender) with no native paragraph numbering to split on — can run to
// several thousand words. Show a generous amount inline (we have room), then
// clamp the rest behind a "Vis mer" toggle so the result list stays scannable
// without losing access to the full text. Only clamps when the body actually
// overflows the threshold; the toggle stops propagation so it doesn't trigger
// the card's open-detail onClick. (This is option B of the chunking-size work;
// the data-side max-size cap — option A — lands separately.)
function CollapsibleText({ text, textStyle, clampPx = 460, moreLabel = 'Vis mer', lessLabel = 'Vis mindre' }) {
  const ref = useRef(null);
  const [overflowing, setOverflowing] = useState(false);
  const [expanded, setExpanded] = useState(false);
  React.useLayoutEffect(() => {
    const el = ref.current;
    // scrollHeight reports the full content height even while clamped, so this
    // stays correct across re-measures. +24px buffer avoids a toggle that
    // reveals only a single extra line.
    if (el) setOverflowing(el.scrollHeight > clampPx + 24);
  }, [text, clampPx]);
  const clamped = overflowing && !expanded;
  return (
    <div>
      <div style={{ position: 'relative' }}>
        <div ref={ref} style={{
          ...textStyle,
          maxHeight: clamped ? clampPx : 'none',
          overflow: 'hidden',
          transition: 'max-height 0.22s ease',
        }}>{text}</div>
        {clamped && (
          <div className="avsnitt-fade" style={{
            position: 'absolute', left: 0, right: 0, bottom: 0, height: 60,
            pointerEvents: 'none', borderRadius: '0 0 var(--r-sm) var(--r-sm)',
          }}/>
        )}
      </div>
      {overflowing && (
        <button
          onClick={(e) => { e.stopPropagation(); setExpanded(v => !v); }}
          onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-3)'; }}
          onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--surface-2)'; }}
          style={{
            marginTop: 8, display: 'inline-flex', alignItems: 'center', gap: 5,
            background: 'var(--surface-2)', color: 'var(--accent)',
            border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
            padding: '3px 11px', fontSize: 11, fontWeight: 600, letterSpacing: '0.02em',
            cursor: 'pointer', transition: 'background 0.12s',
          }}>
          {expanded ? lessLabel : moreLabel}
          <span style={{ fontSize: 8, lineHeight: 1, transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>▼</span>
        </button>
      )}
    </div>
  );
}

function KofaParagraphCard({ hit, onClick, selected, openPdf, sectionLabels, isCrossCountry, corpus, projects, query, onSaveHit, onCreateProject, strings }) {
  const md = hit.metadata || {};
  const sectionLabel = sectionLabels[hit.section] || hit.section || '';
  // Cross-country corpora can render the home-style shaded PDF when their
  // data is dual-mirrored to the home blob (KLFU on the NO container, KOFA
  // on the DK container — both ship full case parquet + faiss + polygon
  // meta + PDFs alongside the home primary). `corpus.supports_pdf` signals
  // whether the bundle has the PDFs available. Cross-country corpora
  // without mirrored PDFs (Supreme Court today) still fall back to the
  // upstream link.
  const useInAppPdf = !!corpus?.supports_pdf;
  const handlePdf = (e) => {
    e.stopPropagation();
    if (isCrossCountry && !useInAppPdf && hit.pdf_url) {
      window.open(hit.pdf_url, '_blank', 'noopener,noreferrer');
      return;
    }
    // Pass the clicked avsnitt's section so the shaded PDF always includes it
    // (unioned with the user's section filter — see the PdfModal mount).
    if (openPdf) openPdf(hit.case_id, hit.page_nr, isCrossCountry ? corpus?.id : undefined, hit.section);
  };
  const pdfTitle = (isCrossCountry && !useInAppPdf) ? 'Åpne kilde-PDF (ny fane)' : 'Åpne PDF';
  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <span style={{ color: 'var(--accent)', fontWeight: 600, fontSize: 13, fontFamily: 'Georgia, serif' }}>{hit.case_id}</span>
          {sectionLabel && <span style={{ fontSize: 11, color: 'var(--text-3)', background: 'var(--surface-2)', padding: '2px 7px', borderRadius: 3 }}>{sectionLabel}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
          {projects && onSaveHit && (
            <HitQuickSaveButton hit={hit} corpus={corpus} query={query}
              projects={projects} onSave={onSaveHit} onCreateProject={onCreateProject} strings={strings}/>
          )}
          {(hit.pdf_id || hit.pdf_url) && (
            <button onClick={handlePdf}
              style={{ ...iconBtn, color: 'var(--text-2)', gap: 4, padding: '3px 8px', fontSize: 10.5, fontWeight: 600, letterSpacing: '0.03em', border: '1px solid var(--border)', borderRadius: 5 }}
              title={pdfTitle}>
              <Icon name="externalLink" size={12}/>
              PDF
            </button>
          )}
        </div>
      </div>
      <CollapsibleText text={hit.paragraph}
        textStyle={{ fontSize: 12.5, color: 'var(--text)', lineHeight: 1.65, margin: 0 }}
        moreLabel={(strings && strings.show_more) || 'Vis mer'}
        lessLabel={(strings && strings.show_less) || 'Vis mindre'} />
      <div style={{ marginTop: 8, fontSize: 11, color: 'var(--text-3)' }}>
        {md.klager && <span>{md.klager} mot {md.innklaget || ''}</span>}
      </div>
    </div>
  );
}

function KofaCaseTable({ rows, onSelect, onOpenPdf, selectedId, columns, isCrossCountry, corpus }) {
  // See KofaParagraphCard.handlePdf for the cross-country in-app-vs-upstream
  // rationale — same gate applies to the row-level "Åpne PDF" button.
  const useInAppPdf = !!corpus?.supports_pdf;
  return (
    <div style={{ overflowX: 'auto' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
        <thead>
          <tr>
            {columns.map(c => (
              <th key={c.key} style={{ padding: '8px 8px', textAlign: 'left', fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', whiteSpace: 'nowrap' }}>{c.label}</th>
            ))}
            <th style={{ padding: '8px 8px', textAlign: 'left', fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', whiteSpace: 'nowrap', width: 70, position: 'sticky', right: 0, zIndex: 2 }}>PDF</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => {
            const id = r.case_id || r[columns[0].key];
            const sel = id === selectedId;
            const rowBg = sel ? 'var(--accent-bg)' : (i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)');
            return (
              <tr key={id + i} onClick={() => onSelect(r)} style={{
                cursor: 'pointer',
                background: rowBg,
                transition: 'background 0.1s',
              }}
                onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--surface-2)'; }}
                onMouseLeave={e => { if (!sel) e.currentTarget.style.background = i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'; }}>
                {columns.map(c => {
                  const v = r[c.key];
                  if (c.key === columns[0].key) {
                    // First column carries the case id (Saksnummer / J.nr.).
                    // When empty and a column-level placeholder is declared
                    // (e.g. em-dash for KLFU rows where the API never exposed
                    // a real jnr), render it muted so users can see it's an
                    // intentional gap, not a render bug.
                    const isEmpty = v == null || v === '';
                    if (isEmpty && c.empty_placeholder) {
                      return <td key={c.key} style={{ ...tdCell, whiteSpace: 'nowrap' }}><span style={{ color: 'var(--text-3)' }}>{c.empty_placeholder}</span></td>;
                    }
                    return <td key={c.key} style={{ ...tdCell, whiteSpace: 'nowrap' }}><span style={{ color: 'var(--accent)', fontWeight: 500, fontFamily: 'Georgia, serif' }}>{v}</span></td>;
                  }
                  if (c.label === 'Avgjørelse' || c.key === 'Avgjørelse') {
                    return <td key={c.key} style={{ ...tdCell, maxWidth: 110 }}><AvgjørelseBadge label={v} wrap/></td>;
                  }
                  if (c.label === 'Type' || c.key === 'Type') {
                    return <td key={c.key} style={{ ...tdCell, maxWidth: 110 }}><TypeBadge label={v} wrap/></td>;
                  }
                  if (c.is_date) {
                    return <td key={c.key} style={{ ...tdCell, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{formatCellValue(v, c)}</td>;
                  }
                  return (
                    <td key={c.key} style={{ ...tdCell, color: 'var(--text-2)', fontSize: 11.5, maxWidth: 150, lineHeight: 1.35, verticalAlign: 'top' }}>
                      <div style={{ wordBreak: 'break-word' }} title={String(v || '')}>{formatCellValue(v, c)}</div>
                    </td>
                  );
                })}
                <td style={{ ...tdCell, whiteSpace: 'nowrap', position: 'sticky', right: 0, background: rowBg }}>
                  {/* KOFA Innkommet / Trukket / withdrawn / dismissed cases (and
                      most KLFU rulings) have no scraped document — pdf_id is empty.
                      The cross-country path relies on the foreign pdf_url instead.
                      Show a muted "–" when neither is available (no button → can't
                      open a 404), matching the table's empty-cell placeholder. */}
                  {((isCrossCountry && r.pdf_url) || (!isCrossCountry && r.pdf_id)) ? (
                    <button
                      onClick={e => {
                        e.stopPropagation();
                        if (isCrossCountry && !useInAppPdf && r.pdf_url) {
                          window.open(r.pdf_url, '_blank', 'noopener,noreferrer');
                        } else if (onOpenPdf) {
                          onOpenPdf(id, undefined, isCrossCountry ? corpus?.id : undefined);
                        }
                      }}
                      title={(isCrossCountry && !useInAppPdf) ? 'Åpne kilde-PDF (ny fane)' : 'Åpne PDF'}
                      style={{ ...iconBtn, color: 'var(--text-3)' }}>
                      <Icon name="externalLink" size={13}/>
                    </button>
                  ) : (
                    <span style={{ color: 'var(--text-3)' }}>–</span>
                  )}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}
const tdCell = { padding: '10px 8px', borderBottom: '1px solid var(--border-light)' };

function GenericTable({ rows, columns, corpus, onSelect, selectedId, urlField, onOpenViewer, locale }) {
  // Lovdata sak-mode gets a special Åpne column that opens the LovdataViewer
  // modal at the top hit. Same right-aligned slot as KOFA's PDF column.
  const showViewerCol = !!onOpenViewer;  // gated upstream by corpus.supports_document_viewer
  const isLovdata = corpus?.kind === 'lovdata';
  // Accent (blue) on the first column is meaningful only when it's a real
  // case identifier (Saksnummer / case number) — those are the rows the
  // user wants to click. For Lovdata chapters, DFØ-veileder titles, EU
  // directive titles, commentary book titles, etc. the first column is
  // descriptive text, and the blue tint reads as a false-affordance link.
  const CASE_ID_KEYS = new Set(['case_no', 'case_id', 'Saksnummer', 'jnr', 'sagsnr']);
  const firstColIsCaseId = columns.length > 0 && CASE_ID_KEYS.has(columns[0].key);
  return (
    <div style={{ overflowX: 'auto' }}>
      <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
        <thead>
          <tr>
            {columns.map(c => (
              <th key={c.key} style={{ padding: '8px 12px', textAlign: 'left', fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', whiteSpace: 'nowrap' }}>{c.label}</th>
            ))}
            {showViewerCol && <th style={{ padding: '8px 12px', textAlign: 'left', fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', whiteSpace: 'nowrap', width: 70 }}>Åpne</th>}
            {!showViewerCol && urlField && <th style={{ padding: '8px 12px', textAlign: 'left', fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', whiteSpace: 'nowrap', width: 50 }}>Kilde</th>}
          </tr>
        </thead>
        <tbody>
          {rows.map((r, i) => {
            const id = r.pid || r[columns[0].key];
            const sel = id === selectedId;
            return (
              <tr key={`${id}-${i}`} onClick={() => onSelect(r)} style={{
                cursor: 'pointer',
                background: sel ? 'var(--accent-bg)' : (i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'),
                transition: 'background 0.1s',
              }}
                onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--surface-2)'; }}
                onMouseLeave={e => { if (!sel) e.currentTarget.style.background = i % 2 === 0 ? 'var(--surface)' : 'var(--surface-2)'; }}>
                {columns.map(c => {
                  const v = r[c.key];
                  if (c.key === columns[0].key) {
                    // Lovdata sak-mode: column-0 is the synthesized _group_label
                    // (chapter title or "§ N"). Subtitle line shows hit count +
                    // _group_sublabel (part_title for chapters, paragraph_title
                    // for §s in anskaffelsesloven).
                    if (isLovdata && c.key === '_group_label') {
                      const hits = r._hit_count;
                      const sub = r._group_sublabel;
                      const subBits = [];
                      if (hits != null) subBits.push(`${hits} ${locale === 'de' ? 'Treffer' : 'treff'}`);
                      if (sub) subBits.push(String(sub));
                      return (
                        <td key={c.key} style={{ ...tdCell, lineHeight: 1.35, verticalAlign: 'top' }}>
                          <div style={{ color: 'var(--text)', fontWeight: 500, fontFamily: 'Georgia, serif', fontSize: 13 }}>{v}</div>
                          {subBits.length > 0 && (
                            <div style={{ color: 'var(--text-3)', fontSize: 11, marginTop: 2 }}>{subBits.join(' · ')}</div>
                          )}
                        </td>
                      );
                    }
                    return <td key={c.key} style={{ ...tdCell, whiteSpace: 'nowrap' }}><span style={{ color: firstColIsCaseId ? 'var(--accent)' : 'var(--text)', fontWeight: 500, fontFamily: 'Georgia, serif' }}>{v}</span></td>;
                  }
                  if (c.is_date) {
                    return <td key={c.key} style={{ ...tdCell, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{formatCellValue(v, c)}</td>;
                  }
                  return (
                    <td key={c.key} style={{ ...tdCell, color: 'var(--text-2)', fontSize: 11.5, maxWidth: 220, lineHeight: 1.35, verticalAlign: 'top' }}>
                      <div style={{ wordBreak: 'break-word' }} title={String(v || '')}>{formatCellValue(v, c)}</div>
                    </td>
                  );
                })}
                {showViewerCol && (
                  <td style={{ ...tdCell, whiteSpace: 'nowrap' }}>
                    <button
                      onClick={e => { e.stopPropagation(); onOpenViewer(r.doc_slug, r[urlField] || r.lovdata_url || '', r.chapter_id || null); }}
                      title="Åpne dokument"
                      style={{ ...iconBtn, color: 'var(--text-3)' }}>
                      <Icon name="externalLink" size={13}/>
                    </button>
                  </td>
                )}
                {!showViewerCol && urlField && (
                  <td style={{ ...tdCell, whiteSpace: 'nowrap' }}>
                    {r[urlField] && (
                      <a href={r[urlField]} target="_blank" rel="noopener noreferrer"
                        onClick={e => e.stopPropagation()}
                        title="Åpne kilde"
                        style={{ ...iconBtn, color: 'var(--text-3)' }}>
                        <Icon name="externalLink" size={13}/>
                      </a>
                    )}
                  </td>
                )}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function formatCellValue(v, c) {
  if (v == null || v === '') return c.empty_placeholder || '';
  if (c.is_date) {
    try { const d = new Date(v); if (!isNaN(d)) return d.toLocaleDateString('nb-NO'); } catch (_) {}
  }
  return String(v);
}

function LovCard({ hit, onClick, selected, corpus, onOpenViewer }) {
  // Url field varies between Lovdata (lovdata_url) and Retsinformation
  // (retsinfo_url). Title for the source link reflects the source name so
  // the affordance reads correctly cross-corpus.
  const urlField = (corpus && corpus.url_field) || 'lovdata_url';
  const url = hit[urlField] || hit.lovdata_url || hit.retsinfo_url;
  const sourceLabel = corpus?.id === 'retsinfo' ? 'Åbn på Retsinformation' : 'Åpne på Lovdata';
  // Retsinformation parquet uses doc_title_da; Lovdata uses doc_title.
  const docTitle = hit.doc_title || hit.doc_title_da || hit.doc_slug;
  // Heading is retsinfo's "Overskrift"; lovdata uses paragraph_title. Both
  // sit visually in the same slot beside the §.
  const subtitle = hit.paragraph_title || hit.heading;
  // Chapter breadcrumb: "kap. 8 › § 8-1". Lovdata supplies chapter_title; retsinfo
  // exposes part_no/part_title for top-level Del. Only render when non-empty so
  // anskaffelsesloven (which is unchaptered) doesn't show a stray "›".
  const chapterRaw = (hit.chapter_title || '').toString().trim();
  const partRaw = (hit.part_title || '').toString().trim();
  const breadcrumb = chapterRaw || partRaw;
  // Retsinfo: put the full locator ("§ 18, nr. 3") in the bold slot, not just
  // "§ 18" — the stk./nr. is the useful part. Derive it by stripping the (often
  // very long, e.g. bekendtgørelse) doc-title prefix off the citation.
  const docTitleDa = (hit.doc_title_da || '').toString();
  const locator = (corpus?.id === 'retsinfo' && hit.citation && docTitleDa && hit.citation.startsWith(docTitleDa))
    ? (hit.citation.slice(docTitleDa.length).trim() || hit.paragraph || '§')
    : (hit.paragraph || '§');
  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      {breadcrumb && (
        <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
          {breadcrumb}
        </div>
      )}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
          <span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', fontFamily: 'Georgia, serif', whiteSpace: 'nowrap' }}>{locator}</span>
          {subtitle && <span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{subtitle}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          <span title={docTitle} style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 8px', borderRadius: 3, background: '#EEF2FD', color: '#2754E6', maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block', verticalAlign: 'middle' }}>{docTitle}</span>
          {onOpenViewer && (
            <button onClick={e => { e.stopPropagation(); onOpenViewer(); }}
              style={{ ...iconBtn, color: 'var(--text-3)' }} title="Åpne dokument">
              <Icon name="externalLink" size={13}/>
            </button>
          )}
          {/* External source link shows ONLY when there is no in-app viewer.
              When a viewer exists (lovdata + retsinfo — Phase 1b shipped), the
              viewer modal header already carries the prominent source pill and
              the DetailPanel keeps the link, so a second card-level icon is a
              confusing duplicate of the "Åpne dokument" button. */}
          {url && !onOpenViewer && (
            <a href={url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }} title={sourceLabel}>
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      <CollapsibleText text={`«${hit.text}»`}
        textStyle={{ fontSize: 12.5, color: 'var(--text)', lineHeight: 1.7, fontStyle: 'italic', margin: 0, whiteSpace: 'pre-line' }}
        moreLabel={corpus?.id === 'retsinfo' ? 'Vis mere' : 'Vis mer'}/>
      {hit.citation && <div style={{ marginTop: 8, fontSize: 10.5, color: 'var(--text-3)', fontFamily: 'monospace' }}>{hit.citation}</div>}
    </div>
  );
}

function EuDirectiveCard({ hit, onClick, selected }) {
  const isRecital = hit.unit_kind === 'recital';
  const isRegulation = hit._doc_kind === 'regulation' || hit._corpus_source === 'eu_regulations';
  // is_consolidated arrives as Python bool → JSON true/false; some parquets may
  // serialize as 1/0 or string. Normalize to boolean.
  const isConsolidated = hit.is_consolidated === true || hit.is_consolidated === 1
    || hit.is_consolidated === 'true' || hit.is_consolidated === 'True';
  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap' }}>
          <span style={{
            fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, flexShrink: 0,
            background: isRegulation ? '#FEF3E7' : '#E8F1FE',
            color: isRegulation ? '#92400E' : '#1E40AF',
          }}>{isRegulation ? 'Forordning' : 'Direktiv'}</span>
          <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#EEF4FF', color: '#1E40AF', flexShrink: 0 }}>{isRecital ? 'Fortale' : 'Artikkel'}</span>
          {isConsolidated && (
            <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#E7F7EE', color: '#166534', flexShrink: 0 }} title="Konsolidert versjon">Konsolidert</span>
          )}
          <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)', fontFamily: 'Georgia, serif', whiteSpace: 'nowrap' }}>{isRecital ? `(${hit.recital_no})` : `Art. ${hit.article_no || ''}`}</span>
          <span style={{ fontSize: 12.5, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{hit.doc_short_title}</span>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          {hit.eurlex_url && (
            <a href={hit.eurlex_url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }} title="Åpne i EUR-Lex">
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      {hit.doc_long_title && hit.doc_long_title !== hit.doc_short_title && (
        <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 8, lineHeight: 1.5, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }} title={hit.doc_long_title}>
          {hit.doc_long_title}
        </div>
      )}
      <p style={{ fontSize: 12.5, color: 'var(--text)', lineHeight: '1.7', margin: 0 }}><em>«{hit.text}»</em></p>
      <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10 }}>
        {hit.celex && <span style={{ fontSize: 10.5, color: 'var(--text-3)', fontFamily: 'monospace' }}>CELEX: {hit.celex}</span>}
      </div>
    </div>
  );
}

function DfoVeilederCard({ hit, onClick, selected }) {
  // section_path comes through the parquet as a Python list dumped to a string
  // ("['Veileder i konkurransepreget dialog' 'Innholdsfortegnelse']"). Strip
  // the noise so the card shows a clean breadcrumb.
  const breadcrumb = (() => {
    const raw = (hit.section_path || '').toString().trim();
    if (!raw) return '';
    const cleaned = raw.replace(/^\[|\]$/g, '').replace(/'/g, '"');
    const parts = cleaned.match(/"([^"]+)"/g);
    if (!parts) return raw;
    return parts.map(s => s.replace(/"/g, '')).filter(Boolean).join(' › ');
  })();
  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#E8F5EE', color: '#1A6E3D', flexShrink: 0 }}>DFØ-veileder</span>
          <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)', fontFamily: 'Georgia, serif' }}>{hit.guide_title}</span>
          {hit.heading && <span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>— {hit.heading}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          {hit.dfo_url && (
            <a href={hit.dfo_url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }} title="Åpne hos DFØ">
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      {breadcrumb && <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>{breadcrumb}</div>}
      <p style={{ fontSize: 12.5, color: 'var(--text)', lineHeight: '1.7', margin: 0 }}>{hit.text}</p>
      {hit.last_updated && <div style={{ marginTop: 8, fontSize: 10.5, color: 'var(--text-3)' }}>Sist oppdatert: {hit.last_updated}</div>}
    </div>
  );
}

// Resolve the two text variants for an EU court hit. Cards default to the
// English version when one is available — the source-language original is
// reachable via the per-card "Vis original" toggle.
function euCourtTextVariants(hit) {
  const hitIsEnglish = hit.language_code === 'EN';
  const pairedExists = hit._paired_text != null && hit._paired_language_code != null;
  const pairedIsEnglish = pairedExists && hit._paired_language_code === 'EN';

  let englishText = null;
  let englishSource = null;       // language we translated FROM (when the English text is a machine translation)
  let originalText = null;        // source-language text, if any
  let originalLang = null;

  if (hitIsEnglish) {
    englishText = hit.text;
    englishSource = hit.is_translation ? (hit.source_language || null) : null;
    if (pairedExists && !pairedIsEnglish) {
      originalText = hit._paired_text;
      originalLang = hit._paired_language_code;
    }
  } else {
    originalText = hit.text;
    originalLang = hit.language_code || null;
    if (pairedIsEnglish) {
      englishText = hit._paired_text;
      englishSource = originalLang;
    }
  }
  return { englishText, englishSource, originalText, originalLang };
}

// Section-code → display-label mapping for Høyesterett / Højesteret cards.
// Section codes are shared NO/DK by design (set in the cleaners): i / b / a / kv / k.
// The two countries differ only in the rendered Danish/Norwegian phrasing.
const HOYESTERETT_SECTION_LABELS = {
  no: {
    i:  'Innledning',
    b:  'Bakgrunn',
    a:  'Anførsler',
    kv: 'Høyesteretts vurdering',
    k:  'Konklusjon',
  },
  dk: {
    i:  'Indledning',
    b:  'Sagsfremstilling',
    a:  'Anbringender',
    kv: 'Højesterets begrundelse',
    k:  'Konklusion',
  },
};

// Card for Norwegian Høyesterett + Danish Højesteret rulings. The two share a
// `kind: "hoyesterett"` corpus dict so this component renders both, switching
// labels via `corpus.mark` ("no" | "dk"). Designed to mirror the visual weight
// of EuCourtCard so the rail of court-decision corpora reads consistently.
function HoyesterettCard({ hit, onClick, selected, corpus }) {
  const isDanish = corpus?.mark === 'dk';
  const labels = isDanish ? HOYESTERETT_SECTION_LABELS.dk : HOYESTERETT_SECTION_LABELS.no;
  const sectionLabel = labels[hit.section] || hit.section || '';
  const caseIdLabel  = isDanish ? 'Sagsnr' : 'Saksnr';
  const chamberLabel = (() => {
    const raw = (hit.chamber || '').toString().toLowerCase();
    if (!raw) return '';
    if (isDanish) {
      if (raw.startsWith('civil')) return 'Civilret';
      if (raw.startsWith('straf')) return 'Strafferet';
    } else {
      if (raw === 'sivil')  return 'Sivil';
      if (raw === 'straff') return 'Straff';
    }
    return raw.charAt(0).toUpperCase() + raw.slice(1);
  })();
  const sourceLabel = isDanish ? 'Åbn på domstol.dk' : 'Åpne på domstol.no';

  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#F0EAFA', color: '#5B21B6', flexShrink: 0 }}>
            {isDanish ? 'Højesteret' : 'Høyesterett'}
          </span>
          {chamberLabel && (
            <span style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 7px', borderRadius: 3, background: '#EEF2FD', color: '#1E40AF', flexShrink: 0 }}>
              {chamberLabel}
            </span>
          )}
          {sectionLabel && (
            <span style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 7px', borderRadius: 3, background: '#F5F0EB', color: '#92400E', flexShrink: 0 }}>
              {sectionLabel}
            </span>
          )}
          <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)', fontFamily: 'Georgia, serif', whiteSpace: 'nowrap' }}>
            {caseIdLabel} {hit.case_id}
          </span>
          {hit.judgment_date && (
            <span style={{ fontSize: 11, color: 'var(--text-3)', flexShrink: 0 }}>{hit.judgment_date}</span>
          )}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          {hit.case_url && (
            <a href={hit.case_url} target="_blank" rel="noopener noreferrer"
              onClick={e => e.stopPropagation()}
              style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }}
              title={sourceLabel}>
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      {hit.title && (
        <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 6, lineHeight: 1.5, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }} title={hit.title}>
          {hit.title}
        </div>
      )}
      <p style={{ fontSize: 12.5, color: 'var(--text)', lineHeight: '1.7', margin: 0 }}>
        <em>«{hit.paragraph_text || hit.text}»</em>
      </p>
      {hit.page_nr && (
        <div style={{ marginTop: 8, fontSize: 10.5, color: 'var(--text-3)' }}>
          {isDanish ? 'Side' : 'Side'} {hit.page_nr}
        </div>
      )}
    </div>
  );
}

function EuCourtCard({ hit, onClick, selected }) {
  const variants = euCourtTextVariants(hit);
  const canToggle = !!variants.englishText && !!variants.originalText;
  const [showOriginal, setShowOriginal] = useState(false);
  const displayingOriginal = canToggle && showOriginal;
  const displayText = displayingOriginal
    ? variants.originalText
    : (variants.englishText || variants.originalText || hit.text);

  const instanceLabel = COURT_LABELS_NB[hit.instance] || 'EU-domstolen';
  const showTranslatedBadge = !displayingOriginal && variants.englishSource;

  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#F5EEFF', color: '#6B21A8', flexShrink: 0 }}>{instanceLabel}</span>
          {showTranslatedBadge && (
            <span style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 7px', borderRadius: 3, background: '#FFF7ED', color: '#9A3412', flexShrink: 0 }}>
              Oversatt fra {langLabel(variants.englishSource)}
            </span>
          )}
          <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)', fontFamily: 'Georgia, serif', whiteSpace: 'nowrap' }}>{hit.case_no}</span>
          <span style={{ fontSize: 12.5, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{hit.short_title}</span>
          {hit.judgment_date && <span style={{ fontSize: 11, color: 'var(--text-3)', flexShrink: 0 }}>{hit.judgment_date}</span>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          {hit.eurlex_url && (
            <a href={hit.eurlex_url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }} title="Åpne i EUR-Lex">
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      {hit.parties_short && <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginBottom: 6, fontStyle: 'italic' }}>{hit.parties_short}</div>}
      {hit._matched_on_language && hit._matched_on_language !== hit.language_code && (
        <div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 6 }}>
          Treff på {langLabel(hit._matched_on_language)}
        </div>
      )}
      <p style={{ fontSize: 12.5, color: 'var(--text)', lineHeight: '1.7', margin: 0 }}>{displayText}</p>
      <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        {canToggle && (
          <button onClick={(e) => { e.stopPropagation(); setShowOriginal(s => !s); }}
            style={{ background: 'transparent', border: 'none', padding: 0, fontSize: 11, color: 'var(--accent)', cursor: 'pointer', textDecoration: 'underline' }}>
            {displayingOriginal ? 'Vis engelsk oversettelse' : `Vis original (${langLabel(variants.originalLang)})`}
          </button>
        )}
        {hit.ecli && <span style={{ fontSize: 10.5, color: 'var(--text-3)', fontFamily: 'monospace' }}>{hit.ecli}</span>}
      </div>
    </div>
  );
}

// Tone-colour the Entscheidungsart badge so lawyers can scan outcomes at a glance.
function vkDecisionTone(decisionType) {
  const s = (decisionType || '').toLowerCase();
  if (s.includes('teilweise')) return { bg: '#FEF3E2', fg: '#92400E' };          // partial
  if (s.includes('stattgegeben')) return { bg: '#E8F5E9', fg: '#1B5E20' };       // granted
  if (s.includes('zurückgewiesen') || s.includes('verworfen')
      || s.includes('unbegründet') || s.includes('abgelehnt'))
    return { bg: '#FDECEC', fg: '#9B1C1C' };                                      // rejected
  return { bg: '#EEF2FD', fg: '#1E40AF' };                                        // neutral
}

// German federal Vergabekammer (Vergabekammern des Bundes) decision hit.
// Paragraph-native, like HoyesterettCard, but with German labels + an
// Entscheidungsart (decision-type) badge. supports_pdf is False, so the
// external link points to the bundeskartellamt.de source page (case_url).
function VergabekammerCard({ hit, onClick, selected, corpus, openPdf }) {
  const sectionLabel = hit.section_label || hit.section || '';
  const tone = vkDecisionTone(hit.decision_type);
  const sourceUrl = hit.case_url || hit.pdf_url;
  return (
    <div onClick={onClick} style={cardBase(selected)}
      onMouseEnter={cardHover.enter} onMouseLeave={cardHover.leave(selected)}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 10.5, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: '#F0EAFA', color: '#5B21B6', flexShrink: 0 }}>
            {hit.chamber || 'Vergabekammer'}
          </span>
          {hit.decision_type && (
            <span style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 7px', borderRadius: 3, background: tone.bg, color: tone.fg, flexShrink: 0 }}>
              {hit.decision_type}
            </span>
          )}
          {sectionLabel && (
            <span style={{ fontSize: 10.5, fontWeight: 500, padding: '2px 7px', borderRadius: 3, background: '#F5F0EB', color: '#92400E', flexShrink: 0 }}>
              {sectionLabel}
            </span>
          )}
          <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text)', fontFamily: 'Georgia, serif', whiteSpace: 'nowrap' }}>
            Az. {hit.case_id}
          </span>
          {hit.judgment_date && (
            <span style={{ fontSize: 11, color: 'var(--text-3)', flexShrink: 0 }}>{hit.judgment_date}</span>
          )}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
          {openPdf && (
            <button onClick={(e) => { e.stopPropagation(); openPdf(hit.case_id, hit.page_nr, corpus?.id, hit.section); }}
              title="Beschluss als PDF öffnen — relevante Absätze hervorgehoben"
              style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', border: '1px solid var(--border)', borderRadius: 5, fontSize: 10.5, fontWeight: 600, letterSpacing: '0.03em', color: 'var(--accent)', background: 'var(--surface)', cursor: 'pointer' }}>
              <Icon name="externalLink" size={12}/> PDF
            </button>
          )}
          {sourceUrl && (
            <a href={sourceUrl} target="_blank" rel="noopener noreferrer"
              onClick={e => e.stopPropagation()}
              style={{ ...iconBtn, color: 'var(--text-3)', textDecoration: 'none' }}
              title="Auf bundeskartellamt.de öffnen">
              <Icon name="externalLink" size={13}/>
            </a>
          )}
        </div>
      </div>
      {hit.title && hit.title !== hit.case_id && (
        <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 6, lineHeight: 1.5, display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, overflow: 'hidden' }} title={hit.title}>
          {hit.title}
        </div>
      )}
      <p style={{ fontSize: 12.5, color: 'var(--text)', lineHeight: '1.7', margin: 0 }}>
        {hit.paragraph_text || hit.text}
      </p>
      {hit.page_nr && (
        <div style={{ marginTop: 8, fontSize: 10.5, color: 'var(--text-3)' }}>Seite {hit.page_nr}</div>
      )}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Detail panel (KOFA + Lovdata + EU)
// ──────────────────────────────────────────────────────────────────────────
// Build a one-line citation for clipboard. Per-corpus shape so lawyers can
// paste straight into a Word brief. Falls back to the detail's existing
// citation field when no special handling applies.
function formatCitation(d, c) {
  if (!c || !d) return '';
  if (c.kind === 'kofa') {
    // "KOFA 2026/0880" or "KLFU 26/00766". For paragraph rows we get a section
    // label; some KOFA detail responses also expose a paragraph number.
    const head = `${c.label} ${d.case_id || d.id || ''}`.trim();
    const paraNo = d.paragraph_no || d.unit_no || null;
    const section = d.section_label || d.section_name || null;
    let tail = '';
    if (paraNo) tail += `, avsnitt ${paraNo}`;
    if (section) tail += ` (${section})`;
    return head + tail;
  }
  // Generic: most corpora populate `citation` (Lovdata "§-references",
  // EU directives "Directive 2014/24/EU, Article 18", EU court "C-209/00").
  return d.citation || d.case_no || d.paragraph || d.id || '';
}

function CopyCitationButton({ detail, corpus, strings }) {
  const [copied, setCopied] = useState(false);
  const citation = formatCitation(detail, corpus);
  if (!citation) return null;
  const onClick = async () => {
    try {
      await navigator.clipboard.writeText(citation);
      setCopied(true);
      setTimeout(() => setCopied(false), 1400);
    } catch (_) {
      window.prompt(strings.copy_citation_label || 'Sitering', citation);
    }
  };
  // Quiet icon-only button matching the close button's iconBtn style so it
  // sits next to it without competing for attention. Title shows the actual
  // citation so the user can preview before clicking.
  return (
    <button onClick={onClick}
      title={`${strings.copy_citation_label || 'Kopier sitering'} — ${citation}`}
      aria-label={strings.copy_citation_label || 'Kopier sitering'}
      style={{ ...iconBtn, color: copied ? 'var(--green)' : 'var(--text-3)' }}>
      <Icon name={copied ? 'check' : 'copy'} size={14} color={copied ? 'var(--green)' : 'var(--text-3)'}/>
    </button>
  );
}

// ── ClaimList — the "Påstande og resultat" rehaul ──────────────────────────
// Renders d.claims (rich per-påstand list from the unit meta): one card per
// numbered claim with the actual claim TEXT, verdict tag, who raised it, the
// nævn's assessment + PDF jump, and a per-outcome focus control to hide the
// claims you don't care about. The caller falls back to the legacy
// claim_outcomes block when d.claims is absent (old / cross-country data).
const _VERDICT_ORDER = ['medhold', 'delvist', 'ikke_medhold', 'afvises', 'hjemvises', 'udsat'];
function ClaimList({ claims, corpus, strings, caseId, onOpenPdf }) {
  const [focus, setFocus] = React.useState('all');
  if (!Array.isArray(claims) || claims.length === 0) return null;

  const parse = (c) => {
    const raw = (c.text || '').trim();
    const m = raw.match(/\(subsidiær(?:\s+i\s+forhold\s+til|\s+til)?\s+påstand\s+(\d+)\)/i);
    const sub = m ? parseInt(m[1], 10) : null;
    // strip a leading "Påstand N (…)" / footnote prefix so the body reads as the claim itself
    const stripped = raw.replace(/^\s*(?:\d+\s+Senere tilføjelse:.*?(?=Påstand|\bKlagenævnet\b|\bIndklagede\b))?Påstand\s+\d+\s*(?:\([^)]*\))?\s*[:.]?\s*/i, '').trim();
    return { ...c, txt: (stripped && stripped.length > 12) ? stripped : raw, sub };
  };
  const parsed = claims.map(parse);
  const verdicts = _VERDICT_ORDER.filter(v => parsed.some(c => c.verdict === v));
  const shown = focus === 'all' ? parsed : parsed.filter(c => c.verdict === focus);
  const groups = [
    { key: 'klager', label: 'Klagers påstande', items: shown.filter(c => (c.raised_by || 'klager') === 'klager') },
    { key: 'indklagede', label: 'Indklagedes påstande', items: shown.filter(c => c.raised_by === 'indklagede') },
  ].filter(g => g.items.length);

  const tagOf = (v) => {
    const tone = _verdictTone(v);
    return <span style={{ display: 'inline-flex', padding: '1px 8px', borderRadius: 5, background: tone.bg, color: tone.color, fontSize: 10.5, fontWeight: 600, whiteSpace: 'nowrap' }}>{lookupFilterLabel(corpus, 'verdict_kind', v)}</span>;
  };

  return (
    <div style={{ marginTop: 16 }}>
      <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 8 }}>
        {strings.section_claim_outcomes_heading || 'Påstande og resultat'}
      </div>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 10 }}>
        <span style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{parsed.length} påstande</span>
        {verdicts.length > 1 && <span style={{ width: 1, height: 12, background: 'var(--border)' }} />}
        {verdicts.length > 1 && ['all', ...verdicts].map(v => {
          const on = focus === v;
          const n = v === 'all' ? parsed.length : parsed.filter(c => c.verdict === v).length;
          const lbl = (v === 'all' ? 'Alle' : lookupFilterLabel(corpus, 'verdict_kind', v)) + ` (${n})`;
          return (
            <button key={v} type="button" onClick={() => setFocus(v)}
              style={{ appearance: 'none', cursor: 'pointer', fontSize: 10.5, fontWeight: 600, padding: '2px 9px', borderRadius: 999,
                border: '1px solid ' + (on ? 'var(--accent)' : 'var(--border)'), background: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text-2)' }}>{lbl}</button>
          );
        })}
      </div>
      {groups.map(g => (
        <div key={g.key}>
          {groups.length > 1 && (
            <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.04em', margin: '10px 0 6px' }}>
              {g.label} <span style={{ color: 'var(--text-3)', fontWeight: 500 }}>({g.items.length})</span>
            </div>
          )}
          {g.items.map((c, i) => (
            <div key={`cl-${c.no}-${i}`} style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '9px 11px', marginBottom: 8,
              // green = unambiguous klager win; indklagede counter-claims stay neutral (raised_by is low-confidence)
              background: (c.raised_by || 'klager') === 'klager' && c.verdict === 'medhold' ? 'var(--green-bg)' : 'var(--surface)' }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 4 }}>
                <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text)' }}>
                  Påstand {c.no}
                  {c.sub && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 600, color: 'var(--text-3)', background: 'var(--surface-2)', borderRadius: 4, padding: '1px 6px' }}>subsidiær til {c.sub}</span>}
                </span>
                {c.verdict ? tagOf(c.verdict) : <span style={{ display: 'inline-flex', padding: '1px 8px', borderRadius: 5, background: 'var(--surface-2)', color: 'var(--text-3)', fontSize: 10.5, fontWeight: 600, whiteSpace: 'nowrap' }}>Ukendt udfald</span>}
              </div>
              <div style={{ fontSize: 12, lineHeight: 1.5, color: 'var(--text)' }}>{c.txt}</div>
              {(c.assessment || c.page != null) && (
                <div style={{ marginTop: 7, paddingTop: 6, borderTop: '1px dashed var(--border)' }}>
                  {c.assessment && (
                    <div>
                      <div style={{ fontSize: 9.5, fontWeight: 700, color: 'var(--accent)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 2 }}>Klagenævnets vurdering</div>
                      <div style={{ fontSize: 11, fontStyle: 'italic', color: 'var(--text-2)', lineHeight: 1.5 }}>
                        "{c.assessment.length > 300 ? c.assessment.slice(0, 300) + '…' : c.assessment}"
                      </div>
                    </div>
                  )}
                  {onOpenPdf && (c.assessment_page ?? c.page) != null && (
                    <button type="button" onClick={() => onOpenPdf(caseId, c.assessment_page ?? c.page, undefined, 'p')}
                      style={{ appearance: 'none', cursor: 'pointer', background: 'none', border: 'none', padding: '5px 0 0', color: 'var(--accent)', fontSize: 11, fontWeight: 600 }}>
                      › Åbn i PDF (s. {c.assessment_page ?? c.page})
                    </button>
                  )}
                </div>
              )}
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}

function DetailPanel({ open, corpus, primary, detail, detailLoading, selectedId, query, onClose, onOpenPdf, strings, projects, onSaveToProject, onCreateProject }) {
  // Cache the last-shown corpus/detail so the slide-out animation doesn't blank.
  const [cached, setCached] = useState({ corpus: null, detail: null });
  useEffect(() => {
    if (open && corpus && detail) setCached({ corpus, detail });
  }, [open, corpus, detail]);
  // Only fall back to the cached detail while CLOSING (slide-out) or while a new
  // fetch is in flight (avoids a flash when switching items). Once a fetch
  // resolves with no detail (e.g. /api/detail 404s), drop the cache so we show
  // an honest not-found state instead of the previously-opened item's detail —
  // which is what made a failed lookup silently show the wrong / a blank case.
  const showCached = !open || detailLoading;
  const c = corpus || (showCached ? cached.corpus : null);
  const d = detail || (showCached ? cached.detail : null);

  const isKofa = c?.kind === 'kofa';
  const isEuCourt = c?.id === 'eu_court_decisions';
  const remapEuValue = (col, value) => {
    if (col === 'instance') return COURT_LABELS_NB[value] || value;
    if (col === 'language_code' || col === 'source_language') {
      const nb = LANGUAGE_LABELS_NB[value];
      return nb ? `${nb} (${value})` : value;
    }
    if (col === 'section_code') {
      // section_label is dual-meaning across provenances (raw heading from
      // HTML path, normalized code from DI path) — display via section_code
      // mapped to the locale labels used by the Tekstdel filter chips.
      const map = primary?.id === 'klfu' ? SECTION_CODE_LABELS_DA : SECTION_CODE_LABELS_NB;
      return map[value] || value;
    }
    return value;
  };
  const fields = c && d
    ? (isKofa
        ? kofaDetailFields(d, c)
        : (c.detail_fields || []).map(f => ({
            label: f.label,
            value: isEuCourt ? remapEuValue(f.col, d[f.col]) : d[f.col],
            is_url: f.is_url,
          })))
    : [];

  // EU court detail panel — same translated/original variants as the result card.
  const euVariants = isEuCourt && d ? euCourtTextVariants(d) : null;
  const euCanToggle = !!(euVariants && euVariants.englishText && euVariants.originalText);
  const [euShowOriginal, setEuShowOriginal] = useState(false);
  useEffect(() => { setEuShowOriginal(false); }, [d?.pid]);

  // Lovdata detail — fetch related book commentary keyed by doc_slug.
  const isLovdataDetail = c?.id === 'lovdata' && d?.doc_slug;
  const [commentary, setCommentary] = useState({ slug: null, items: null });
  useEffect(() => {
    if (!isLovdataDetail) { setCommentary({ slug: null, items: null }); return; }
    const slug = d.doc_slug;
    if (commentary.slug === slug) return;
    let alive = true;
    setCommentary({ slug, items: null });
    Api.getRelatedCommentary(slug).then(r => {
      if (!alive) return;
      setCommentary({ slug, items: r.items || [] });
    }).catch(() => { if (alive) setCommentary({ slug, items: [] }); });
    return () => { alive = false; };
  }, [isLovdataDetail, d?.doc_slug]);
  const euDisplayingOriginal = euCanToggle && euShowOriginal;
  const euDisplayText = isEuCourt && d
    ? (euDisplayingOriginal
        ? euVariants.originalText
        : (euVariants.englishText || euVariants.originalText || d.text))
    : (d?.text || '');
  // EU court header — the parquet's `citation` is composed by the pipeline
  // parser with English suffixes ("operative part" / "paragraph N") and the
  // raw instance code ("general_court"). Rebuild from structured fields so
  // the header matches the locale (NB on NO / DA on DK) and the rest of the
  // UI (filter chips, INSTANS side panel field). Drop the instance qualifier
  // from the header — INSTANS is already shown as its own field below.
  const euHeaderTitle = (() => {
    if (!isEuCourt || !d) return null;
    const map = primary?.id === 'klfu' ? SECTION_CODE_LABELS_DA : SECTION_CODE_LABELS_NB;
    const parW = primary?.id === 'klfu' ? 'afsnit' : 'avsnitt';
    let suffix = '';
    if (d.section_code === 'k' || d.unit_kind === 'operative') {
      suffix = map.k || '';
    } else if (d.paragraph_no) {
      suffix = `${parW} ${d.paragraph_no}`;
    } else if (d.section_code && map[d.section_code]) {
      suffix = map[d.section_code];
    }
    return suffix ? `${d.case_no || ''}, ${suffix}` : (d.case_no || '');
  })();
  const headerTitle = !d ? '' : (isKofa
    ? (d.case_id || '')
    : (euHeaderTitle || d.citation || d.case_no || d.paragraph || ''));
  const headerSub = !c ? '' : (isKofa ? strings.case_detail_title : (c.label || ''));
  return (
    <div className="detail-panel" style={{
      position: 'fixed', top: 72, right: 20, bottom: 20, width: 380,
      background: 'var(--surface)', borderRadius: 12,
      boxShadow: '0 16px 48px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
      border: '1px solid var(--border)',
      display: 'flex', flexDirection: 'column', overflow: 'hidden', zIndex: 50,
      transform: open ? 'translateX(0)' : 'translateX(calc(100% + 40px))',
      opacity: open ? 1 : 0,
      transition: 'transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s linear',
      pointerEvents: open ? 'auto' : 'none',
    }}>
      {c && d && (
        <>
          <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border-light)', flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
            <div style={{ minWidth: 0 }}>
              <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 2 }}>{headerSub}</div>
              <div style={{ fontWeight: 700, fontSize: 14, fontFamily: 'Georgia, serif' }}>{headerTitle}</div>
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
              {isKofa && onOpenPdf && (d.pdf_id || (c?.is_cross_country && d.pdf_url)) && (
                <button onClick={() => {
                  if (c?.is_cross_country && !c?.supports_pdf && d.pdf_url) {
                    window.open(d.pdf_url, '_blank', 'noopener,noreferrer');
                  } else {
                    onOpenPdf(d.case_id, undefined, c?.is_cross_country ? c?.id : undefined, d.section_code);
                  }
                }}
                  title={strings.open_pdf}
                  style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', border: '1px solid var(--border)', borderRadius: 5, fontSize: 10.5, fontWeight: 600, letterSpacing: '0.03em', color: 'var(--accent)', background: 'var(--surface)', cursor: 'pointer' }}>
                  <Icon name="externalLink" size={12}/> PDF
                </button>
              )}
              <button onClick={onClose} style={{ ...iconBtn, color: 'var(--text-3)' }}><Icon name="x" size={15}/></button>
            </div>
          </div>
          <div style={{ padding: '16px', overflowY: 'auto', flex: 1 }}>
            {fields.map(f => (f.value ? (
              <div key={f.label} style={{ marginBottom: 12 }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 5 }}>{f.label}</div>
                {f.badge ? f.badge : f.is_url ? (
                  <a href={f.value} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12.5, color: 'var(--accent)', wordBreak: 'break-all' }}>{f.value}</a>
                ) : (
                  <div style={{ fontSize: 13, fontWeight: 500, lineHeight: 1.5, color: 'var(--text)' }}>{String(f.value)}</div>
                )}
              </div>
            ) : null))}
            {isKofa && d.Sammendrag && (
              <div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r)', padding: '12px', borderLeft: '3px solid var(--accent)', marginTop: 4 }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>Sammendrag</div>
                <p style={{ fontSize: 12.5, lineHeight: 1.65, color: 'var(--text)', margin: 0 }}>{d.Sammendrag}</p>
              </div>
            )}
            {/* EU court catchwords — the structured per-case headnote written
                by the Court's drafters (dash-separated taxonomy of the legal
                issues at stake). Backfilled from the first paragraph chunk
                per (doc, lang, is_translation) group; only ~27% of judgments
                carry classic catchwords (older OPOCE format), modern ones
                open with a narrative sentence instead and get no block. */}
            {isEuCourt && d.catchwords && (
              <div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r)', padding: '12px', borderLeft: '3px solid var(--accent)', marginTop: 4 }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>Sammendrag</div>
                <ul style={{ fontSize: 12.5, lineHeight: 1.55, color: 'var(--text)', margin: 0, paddingLeft: 16 }}>
                  {String(d.catchwords).split(/\s+[-–—]\s+/).map((item, i) => (
                    <li key={i} style={{ marginBottom: 2 }}>{item.trim()}</li>
                  ))}
                </ul>
              </div>
            )}

            {/* Påstande og resultat — prefer the rich per-påstand ClaimList
                (d.claims, built from the unit meta); fall back to the legacy
                flat claim_outcomes when absent (old / cross-country data). */}
            {isKofa && Array.isArray(d.claims) && d.claims.length > 0 && (
              <ClaimList claims={d.claims} corpus={c} strings={strings} caseId={d.case_id} onOpenPdf={onOpenPdf} />
            )}
            {isKofa && !(Array.isArray(d.claims) && d.claims.length > 0) && Array.isArray(d.claim_outcomes) && d.claim_outcomes.length > 0 && (
              <div style={{ marginTop: 16 }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 8 }}>
                  {strings.section_claim_outcomes_heading || 'Påstande og resultat'}
                </div>
                {d.claim_outcomes.map((co, i) => {
                  // Raw KLFU API verdicts ("tages_til_følge") map to the
                  // canonical wire-format ("medhold") that the verdict_kinds
                  // filter knows about. The filter's value_labels then maps
                  // to the display string. One single source of truth.
                  const canonicalVerdict = ({
                    'tages_til_følge': 'medhold',
                    'tages_delvist_til_følge': 'delvist',
                    'tages_ikke_til_følge': 'ikke_medhold',
                    'afvises': 'afvises',
                    'hjemvises': 'hjemvises',
                    'udsættes': 'udsat',
                  })[co.verdict] || co.verdict;
                  const tone = _verdictTone(canonicalVerdict);
                  const claimLabel = co.claim_no
                    ? `Påstand ${co.claim_no}`
                    : (co.claim_name ? `Påstand om ${co.claim_name}` : 'Påstand');
                  const verdictDisplay = lookupFilterLabel(c, 'verdict_kind', canonicalVerdict);
                  return (
                    <div key={`co-${i}`} style={{ marginBottom: 10, paddingLeft: 10, borderLeft: '3px solid var(--border)' }}>
                      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
                        <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)' }}>{claimLabel}</span>
                        <span style={{ display: 'inline-flex', padding: '1px 7px', borderRadius: 4, background: tone.bg, color: tone.color, fontSize: 10.5, fontWeight: 500 }}>
                          {verdictDisplay}
                        </span>
                      </div>
                      {co.snippet && (
                        <div style={{ fontSize: 11.5, lineHeight: 1.55, color: 'var(--text-2)', fontStyle: 'italic' }}>
                          "{co.snippet}"
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            )}
            {isLovdataDetail && commentary.items !== null && commentary.items.length > 0 && (
              <div style={{ marginTop: 14 }}>
                <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 8 }}>Kommentarer ({commentary.items.length})</div>
                {commentary.items.map((it, i) => (
                  <div key={`comm-${it.pid}-${i}`} style={{ borderLeft: '3px solid var(--border)', paddingLeft: 10, marginBottom: 12 }}>
                    <div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text)', fontFamily: 'Georgia, serif', marginBottom: 2 }}>
                      {it.chapter_no ? `Kap. ${it.chapter_no} — ` : ''}{it.chapter_title || it.subject_law}
                    </div>
                    {(it.title || it.authors) && (
                      <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 4 }}>
                        {it.authors ? `${it.authors}` : ''}{it.title ? `, ${it.title}` : ''}{it.edition_label ? ` (${it.edition_label})` : ''}{it.page_range ? ` — s. ${it.page_range}` : ''}
                      </div>
                    )}
                    <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{it.subject_law}</div>
                  </div>
                ))}
              </div>
            )}
            {!isKofa && d.eurlex_url && (
              <a href={d.eurlex_url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 14, width: '100%', padding: '8px', border: '1px solid var(--border)', borderRadius: 'var(--r)', fontSize: 12, color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontWeight: 500, background: 'var(--surface)', textDecoration: 'none', boxSizing: 'border-box' }}>
                <Icon name="externalLink" size={13}/> Åpne i EUR-Lex
              </a>
            )}
            {!isKofa && d.lovdata_url && (
              <a href={d.lovdata_url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 14, width: '100%', padding: '8px', border: '1px solid var(--border)', borderRadius: 'var(--r)', fontSize: 12, color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontWeight: 500, background: 'var(--surface)', textDecoration: 'none', boxSizing: 'border-box' }}>
                <Icon name="externalLink" size={13}/> {c?.id === 'lovdata' && c?.is_cross_country ? 'Åbn på Lovdata' : 'Åpne på Lovdata'}
              </a>
            )}
            {!isKofa && d.retsinfo_url && (
              <a href={d.retsinfo_url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 14, width: '100%', padding: '8px', border: '1px solid var(--border)', borderRadius: 'var(--r)', fontSize: 12, color: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontWeight: 500, background: 'var(--surface)', textDecoration: 'none', boxSizing: 'border-box' }}>
                <Icon name="externalLink" size={13}/> {strings.open_retsinfo || 'Åbn på Retsinformation'}
              </a>
            )}
            {projects && onSaveToProject && (
              <SaveToProjectButton
                strings={strings}
                projects={projects}
                detail={d}
                corpus={c}
                selectedId={selectedId}
                query={query}
                onSave={onSaveToProject}
                onCreateProject={onCreateProject}/>
            )}
          </div>
        </>
      )}
      {open && !d && detailLoading && (
        <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-3)', fontSize: 13 }}>…</div>
      )}
      {open && !d && !detailLoading && (
        <>
          <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border-light)', flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
            <button onClick={onClose} style={{ ...iconBtn, color: 'var(--text-3)' }}><Icon name="x" size={15}/></button>
          </div>
          <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '24px' }}>
            <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', marginBottom: 6, fontFamily: 'Georgia, serif' }}>{strings.case_not_found}</div>
            <div style={{ fontSize: 12.5, color: 'var(--text-3)', maxWidth: 260 }}>{strings.case_not_found_desc}</div>
          </div>
        </>
      )}
    </div>
  );
}

function _pick(obj, keys) {
  // Return a flat dict of (key → string value) for keys present and non-empty in obj.
  const out = {};
  if (!obj) return out;
  for (const k of keys) {
    const v = obj[k];
    if (v !== undefined && v !== null && v !== '') {
      out[k] = typeof v === 'string' ? v : String(v);
    }
  }
  return out;
}

const _PARAGRAPH_TEXT_MAX = 8000;

function extractSaveData(detail, corpus, selectedId) {
  // Decide whether the current row is being saved as a sak (case) or an
  // avsnitt (paragraph), and capture the right snapshot fields for each.
  // Returns:
  //   {
  //     itemId, title,                                // existing
  //     itemKind: 'case' | 'paragraph',
  //     parentCaseId, paragraphText,                  // populated for paragraph
  //     metadata: { … }                               // corpus-specific snapshot
  //   }
  const cid = corpus && corpus.id;
  const fallbackId = selectedId != null ? String(selectedId) : '';
  const empty = { itemId: fallbackId, title: null, itemKind: 'case',
                  parentCaseId: null, paragraphText: null, metadata: {} };
  if (!detail) return empty;

  const truncText = (s) => {
    const t = (s || '') + '';
    return t.length > _PARAGRAPH_TEXT_MAX ? t.slice(0, _PARAGRAPH_TEXT_MAX) : t;
  };

  // Lovdata + commentary: paragraph-only; citation is the canonical human reference.
  if (cid === 'lovdata' || cid === 'commentary_index') {
    const citation = detail.citation || detail.paragraph_title || detail.doc_title;
    return {
      itemId: fallbackId || detail.pid || '',
      title: citation || null,
      itemKind: 'paragraph',
      parentCaseId: null,
      paragraphText: detail.text ? truncText(detail.text) : null,
      metadata: _pick(detail, ['citation', 'doc_title', 'paragraph_title', 'paragraph', 'ledd', 'chapter_title', 'lovdata_url']),
    };
  }
  if (cid === 'eu_directives' || cid === 'eu_regulations') {
    const citation = detail.citation || detail.doc_short_title;
    const ttl = citation && detail.doc_short_title && citation !== detail.doc_short_title
      ? `${citation} — ${detail.doc_short_title}`
      : citation || detail.doc_short_title || null;
    return {
      itemId: fallbackId || detail.pid || '',
      title: ttl,
      itemKind: 'paragraph',
      parentCaseId: null,
      paragraphText: detail.text ? truncText(detail.text) : null,
      metadata: _pick(detail, ['celex', 'citation', 'doc_short_title', 'doc_title', 'eurlex_url', 'unit_kind']),
    };
  }
  if (cid === 'eu_court_decisions') {
    const ttl = detail.case_no || detail.citation || detail.paragraph;
    return {
      itemId: fallbackId || detail.pid || '',
      title: ttl || null,
      itemKind: 'paragraph',
      parentCaseId: null,
      paragraphText: detail.text ? truncText(detail.text) : null,
      metadata: _pick(detail, ['case_no', 'citation', 'paragraph', 'instance', 'language_code', 'celex', 'eurlex_url']),
    };
  }
  if (cid === 'dfo_veileder') {
    const ttl = detail.title || detail.paragraph_title || detail.citation;
    return {
      itemId: fallbackId || detail.pid || '',
      title: ttl || null,
      itemKind: 'paragraph',
      parentCaseId: null,
      paragraphText: detail.text ? truncText(detail.text) : null,
      metadata: _pick(detail, ['title', 'citation', 'paragraph_title', 'doc_title']),
    };
  }

  // KOFA / KLFU / hoyesterett: detect paragraph view by comparing selectedId
  // to the case-level id. If they differ we're saving an avsnitt; title becomes
  // "Sak <id> — first 80 chars of text" so the project list shows what was saved.
  const caseLevelId = detail.case_id || detail.Saksnummer || detail.jnr || '';
  const itemId = fallbackId || caseLevelId;
  const isParagraph = caseLevelId && itemId && String(itemId) !== String(caseLevelId);

  // KOFA case fields (Norwegian); KLFU uses different field names.
  const kofaCaseMeta = _pick(detail, [
    'case_id', 'Klager', 'Innklaget', 'Saken gjelder', 'Type',
    'Avgjørelse', 'Registrert inn', 'Avsluttet',
  ]);
  const klfuCaseMeta = _pick(detail, [
    'jnr', 'title', 'klager', 'indklagede', 'date', 'categories',
    // v2 KLFU signals — persisted into saved-item metadata_json so
    // CaseCard / ParagraphCard can render verdict badges later without
    // re-fetching the case.
    'outcome_summary', 'verdict_kinds', 'claim_outcomes',
    'fumus_boni_juris', 'opsættende_virkning', 'decision_type',
    'is_annulled', 'has_sanction', 'damages_awarded', 'is_stemmeflertallet',
  ]);
  const caseMeta = Object.keys(kofaCaseMeta).length ? kofaCaseMeta : klfuCaseMeta;

  if (isParagraph) {
    const text = (detail.text || '') + '';
    const snippet = text.slice(0, 80).replace(/\s+/g, ' ').trim();
    const more = text.length > 80;
    const title = snippet ? `${caseLevelId} — "${snippet}${more ? '…' : ''}"` : caseLevelId;
    return {
      itemId,
      title,
      itemKind: 'paragraph',
      parentCaseId: caseLevelId || null,
      paragraphText: text ? truncText(text) : null,
      // For paragraph in a case, snapshot the case context too — useful if the
      // user later wants to see "which case was this from"
      metadata: caseMeta,
    };
  }
  return {
    itemId: itemId || fallbackId,
    title: caseLevelId || detail.title || null,
    itemKind: 'case',
    parentCaseId: null,
    paragraphText: null,
    metadata: caseMeta,
  };
}

function SaveToProjectButton({ strings, projects, detail, corpus, selectedId, query, onSave, onCreateProject }) {
  const [open, setOpen] = useState(false);
  const [creating, setCreating] = useState(false);
  const [draftName, setDraftName] = useState('');
  const [toast, setToast] = useState(null);
  const anchorRef = useRef(null);
  const popRef = useRef(null);

  useEffect(() => {
    if (!open) return;
    const onDocClick = (e) => {
      if (popRef.current && popRef.current.contains(e.target)) return;
      if (anchorRef.current && anchorRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onKey);
    return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); };
  }, [open]);

  const sd = extractSaveData(detail, corpus, selectedId);
  const corpusId = corpus.id;
  const queryText = (query || '').trim() || null;

  const doSave = (project) => {
    if (!sd.itemId) return;
    Promise.resolve(onSave(project.id, {
      corpusId,
      caseId:        String(sd.itemId),
      caseTitle:     sd.title ? String(sd.title) : null,
      itemKind:      sd.itemKind,
      parentCaseId:  sd.parentCaseId,
      paragraphText: sd.paragraphText,
      queryText:     queryText,
      metadata:      sd.metadata,
    }))
      .then(() => {
        setToast(strings.projects_saved_toast.replace('{name}', project.name));
        setOpen(false);
        setTimeout(() => setToast(null), 1800);
      })
      .catch(() => { setOpen(false); });
  };

  const submitCreate = () => {
    const n = draftName.trim();
    if (!n) { setCreating(false); setDraftName(''); return; }
    Promise.resolve(onCreateProject(n)).then(project => {
      setCreating(false);
      setDraftName('');
      if (project && project.id) doSave(project);
    }).catch(() => { setCreating(false); setDraftName(''); });
  };

  return (
    <div style={{ position: 'relative', marginTop: 14 }}>
      <button ref={anchorRef} onClick={() => setOpen(o => !o)}
        style={{ width: '100%', padding: '8px', border: '1px solid var(--border)', borderRadius: 'var(--r)', fontSize: 12, color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontWeight: 500, background: 'var(--surface)', cursor: 'pointer' }}>
        <Icon name="bookmark" size={13} color="var(--text-2)"/> {strings.projects_save_to_button}
      </button>
      {open && (
        <div ref={popRef}
          style={{ position: 'absolute', bottom: 'calc(100% + 6px)', left: 0, right: 0, zIndex: 60,
            background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8,
            boxShadow: '0 12px 40px rgba(0,0,0,0.12), 0 2px 6px rgba(0,0,0,0.06)',
            padding: 6, maxHeight: 280, overflowY: 'auto' }}>
          <div style={{ fontSize: 9.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.06em', textTransform: 'uppercase', padding: '6px 8px 4px' }}>{strings.projects_save_picker_title}</div>
          {projects.length === 0 && !creating && (
            <div style={{ fontSize: 11.5, color: 'var(--text-3)', padding: '6px 10px 8px', fontStyle: 'italic' }}>{strings.projects_empty}</div>
          )}
          {projects.map(p => (
            <button key={p.id} onClick={() => doSave(p)}
              style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '7px 10px', borderRadius: 6, border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left' }}>
              <Icon name="folder" size={13} color="var(--text-3)"/>
              <span style={{ fontSize: 12.5, color: 'var(--text)', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
              <span style={{ fontSize: 10.5, color: 'var(--text-3)' }}>{p.item_count || 0}</span>
            </button>
          ))}
          {creating ? (
            <input autoFocus type="text" value={draftName}
              placeholder={strings.projects_new_placeholder}
              onChange={e => setDraftName(e.target.value.slice(0, 120))}
              onBlur={submitCreate}
              onKeyDown={e => {
                if (e.key === 'Enter') { e.preventDefault(); submitCreate(); }
                else if (e.key === 'Escape') { e.preventDefault(); setCreating(false); setDraftName(''); }
              }}
              style={{ display: 'block', width: 'calc(100% - 16px)', margin: '4px 8px 4px', padding: '6px 8px', fontSize: 12.5, borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--surface)', color: 'var(--text)', outline: 'none', boxSizing: 'border-box' }}/>
          ) : (
            <button onClick={() => setCreating(true)}
              style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '7px 10px', borderRadius: 6, border: 'none', background: 'transparent', color: 'var(--accent)', fontSize: 12, cursor: 'pointer', textAlign: 'left', borderTop: '1px solid var(--border-light)', marginTop: 2 }}>
              <Icon name="plus" size={12} color="var(--accent)"/>
              <span>{strings.projects_new_button}</span>
            </button>
          )}
        </div>
      )}
      {toast && (
        <div style={{ position: 'absolute', top: 'calc(100% + 8px)', left: 0, right: 0, padding: '7px 10px', background: 'var(--text)', color: 'var(--surface)', borderRadius: 'var(--r-sm)', fontSize: 12, textAlign: 'center', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', zIndex: 60 }}>
          {toast}
        </div>
      )}
    </div>
  );
}

function kofaDetailFields(d, corpus) {
  // For KOFA, build a flat list of label/value pairs from the case row.
  const fields = [
    { label: 'Saksnummer',     value: d.case_id || d.Saksnummer },
    { label: 'Klager',         value: d.Klager || d.klager },
    { label: 'Innklaget',      value: d.Innklaget || d.indklagede },
    { label: 'Registrert',     value: d['Registrert inn'] },
    { label: 'Avsluttet',      value: d.Avsluttet },
    { label: 'Type',           value: d.Type, badge: d.Type ? <TypeBadge label={d.Type}/> : null },
    { label: 'Saken gjelder',  value: d['Saken gjelder'] },
    { label: 'Avgjørelse',     value: d['Avgjørelse'], badge: d['Avgjørelse'] ? <AvgjørelseBadge label={d['Avgjørelse']}/> : null },
    { label: 'Regelverk',      value: d.Regelverk },
  ];
  return fields;
}

// ──────────────────────────────────────────────────────────────────────────
// PDF modal
// ──────────────────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────────
// Lovdata viewer modal — Phase 1 of the cross-corpus killer feature.
// Renders the full Lovdata source HTML with chunk-level similarity shading,
// scrolls to the top hit on open, prev/next walks the hit list.
// ──────────────────────────────────────────────────────────────────────────
function LovdataViewer({ corpus, docSlug, query, externalUrl, chapterId, targetPid, onClose }) {
  const [state, setState] = useState({ loading: true, html: null, hitPids: [], topHitPid: null, docTitle: '', error: null });
  const [cursor, setCursor] = useState(0);
  const bodyRef = useRef(null);
  // Both refs are consumed once on the first paint after the HTML lands, then
  // subsequent renders fall through to the per-cursor scroll below.
  const initialChapterScrollRef = useRef(false);
  const initialTargetScrollRef = useRef(false);
  // Source label for the "open externally" affordance (Lovdata vs Retsinformation).
  const sourceName = corpus === 'retsinfo' ? 'Retsinformation' : 'Lovdata';

  useEffect(() => {
    let cancelled = false;
    setState({ loading: true, html: null, hitPids: [], topHitPid: null, docTitle: '', error: null });
    setCursor(0);
    initialChapterScrollRef.current = !!chapterId;
    initialTargetScrollRef.current = targetPid != null;
    Api.getDocumentHtml(corpus || 'lovdata', docSlug, query || '', chapterId || '').then(r => {
      if (cancelled) return;
      if (r.error) {
        setState({ loading: false, html: null, hitPids: [], topHitPid: null, docTitle: '', error: r.error_detail || r.error });
        return;
      }
      setState({ loading: false, html: r.html || '', hitPids: r.hitPids || [], topHitPid: r.topHitPid, docTitle: r.docTitle || docSlug, error: null });
    }).catch(e => {
      if (cancelled) return;
      setState({ loading: false, html: null, hitPids: [], topHitPid: null, docTitle: '', error: String(e) });
    });
    return () => { cancelled = true; };
  }, [docSlug, query, chapterId, targetPid]);

  // Scroll to the active hit + outline it. Re-runs whenever cursor or html changes.
  // Priority on first paint:
  //   1. targetPid prop — the specific ledd the user clicked (LovCard handoff).
  //   2. chapterId prop — table-mode handoff (scroll to chapter header).
  //   3. cursor against state.hitPids / state.topHitPid — the search top hit.
  // After the initial paint, cursor moves drive the scroll exclusively.
  useEffect(() => {
    if (!state.html || !bodyRef.current) return;
    bodyRef.current.querySelectorAll('.chunk-tophit').forEach(el => el.classList.remove('chunk-tophit'));

    if (initialTargetScrollRef.current && targetPid != null) {
      initialTargetScrollRef.current = false;
      const el = bodyRef.current.querySelector(`[data-chunk-pid="${targetPid}"]`);
      if (el) {
        el.classList.add('chunk-tophit');
        el.scrollIntoView({ behavior: 'auto', block: 'center' });
        return;
      }
    }

    if (initialChapterScrollRef.current && chapterId) {
      initialChapterScrollRef.current = false;
      const sec = bodyRef.current.querySelector(`section[id="${CSS.escape(chapterId)}"]`);
      if (sec) {
        sec.scrollIntoView({ behavior: 'auto', block: 'start' });
        // Also outline the top hit inside the chapter if there is one.
        const topPid = state.hitPids[0] != null ? state.hitPids[0] : state.topHitPid;
        if (topPid != null) {
          const el = bodyRef.current.querySelector(`[data-chunk-pid="${topPid}"]`);
          if (el) el.classList.add('chunk-tophit');
        }
        return;
      }
    }

    const navPid = state.hitPids[cursor] != null ? state.hitPids[cursor] : state.topHitPid;
    if (navPid == null) return;
    const el = bodyRef.current.querySelector(`[data-chunk-pid="${navPid}"]`);
    if (!el) return;
    el.classList.add('chunk-tophit');
    el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }, [state.html, state.hitPids, state.topHitPid, cursor, chapterId, targetPid]);

  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'ArrowRight' && state.hitPids.length > 0) {
        setCursor(c => Math.min(c + 1, state.hitPids.length - 1));
      }
      if (e.key === 'ArrowLeft' && state.hitPids.length > 0) {
        setCursor(c => Math.max(c - 1, 0));
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose, state.hitPids.length]);

  const hitCount = state.hitPids.length;
  const showNav = hitCount > 0;

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
      <div onClick={e => e.stopPropagation()} style={{ width: '100%', maxWidth: 1100, height: '100%', background: 'var(--surface)', borderRadius: 10, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 12px 48px rgba(0,0,0,0.25)' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 28px', borderBottom: '1px solid var(--border-light)', gap: 14, background: 'var(--surface)' }}>
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ fontWeight: 600, fontSize: 17, fontFamily: 'Georgia, serif', lineHeight: 1.25, color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {state.docTitle || docSlug}
              </div>
              {hitCount > 0 && (
                <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 3 }}>
                  {hitCount} {hitCount === 1 ? 'treff' : 'treff'} i dokumentet
                </div>
              )}
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              {externalUrl && (
                <a href={externalUrl} target="_blank" rel="noopener noreferrer"
                  title={`Åpne på ${sourceName}`}
                  style={{
                    display: 'inline-flex', alignItems: 'center', gap: 6,
                    padding: '6px 12px', borderRadius: 16,
                    fontSize: 12, fontWeight: 500,
                    color: 'var(--accent)', background: 'var(--accent-bg)',
                    border: '1px solid var(--accent)',
                    textDecoration: 'none', whiteSpace: 'nowrap',
                  }}>
                  Åpne på {sourceName}
                  <Icon name="externalLink" size={13}/>
                </a>
              )}
              <button onClick={onClose} style={{ ...iconBtn, color: 'var(--text-3)' }} title="Lukk (Esc)">
                <Icon name="x" size={16}/>
              </button>
            </div>
          </div>
          <div ref={bodyRef} className="lovdata-viewer-body" style={{ flex: 1, overflowY: 'auto', background: 'var(--surface)' }}>
            <div style={{ maxWidth: 760, margin: '0 auto', padding: '40px 36px 64px' }}>
              {state.loading && <div style={{ color: 'var(--text-3)', fontSize: 13 }}>Laster dokument…</div>}
              {state.error && (
                <div style={{ color: 'var(--text-3)', fontSize: 13 }}>
                  <div style={{ fontWeight: 600, marginBottom: 6 }}>Kunne ikke laste dokumentet</div>
                  <div style={{ fontFamily: 'monospace', fontSize: 11 }}>{state.error}</div>
                </div>
              )}
              {!state.loading && !state.error && state.html && (
                <div dangerouslySetInnerHTML={{ __html: state.html }}/>
              )}
            </div>
          </div>
          {showNav && (
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 28px', borderTop: '1px solid var(--border-light)', background: 'var(--surface-2)', fontSize: 12, color: 'var(--text-2)' }}>
              <div>Treff <span style={{ fontWeight: 600, color: 'var(--text)' }}>{cursor + 1}</span> av {hitCount}</div>
              <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                <button onClick={() => setCursor(c => Math.max(c - 1, 0))} disabled={cursor === 0}
                  style={{ ...iconBtn, color: cursor === 0 ? 'var(--text-3)' : 'var(--text-2)' }}
                  title="Forrige treff (←)">
                  <Icon name="chevronLeft" size={16}/>
                </button>
                <button onClick={() => setCursor(c => Math.min(c + 1, hitCount - 1))} disabled={cursor >= hitCount - 1}
                  style={{ ...iconBtn, color: cursor >= hitCount - 1 ? 'var(--text-3)' : 'var(--text-2)' }}
                  title="Neste treff (→)">
                  <Icon name="chevronRight" size={16}/>
                </button>
              </div>
            </div>
          )}
      </div>
    </div>
  );
}

// Which sections the shaded PDF should highlight (home primary only). The
// backend reads `section_*` query params; we send the union of (a) the user's
// section-filter selection — or the config default if untouched — and (b) the
// section of the avsnitt they opened the PDF from. Cross-country corpora ignore
// section flags server-side (their shading is driven by similarity), so we send
// none. Returns a `{code: true}` map, or null when no flags should be sent.
function pdfShadeSections(state, primary) {
  const open = state.pdfOpen;
  if (!open || open.corpusId) return null;
  const cfgSections = (primary && primary.sections) || [];
  const touched = state.filters && state.filters.sections_touched === true;
  // Emit an EXPLICIT boolean for every section. The backend defaults any
  // *omitted* section to its config default (kv/ku are default:true), so
  // sending only the on-sections would silently re-enable the board section —
  // exactly the "always Klagenemndas vurdering" bug. Send false too.
  const sel = {};
  cfgSections.forEach(s => {
    sel[s.code] = touched ? ((state.filters.sections || {})[s.code] === true) : (s.default === true);
  });
  // Always include the section of the avsnitt the PDF was opened from.
  if (open.section) sel[open.section] = true;
  return sel;
}

function PdfModal({ caseId, page, query, corpusId, sections, onClose, strings }) {
  // Load the shaded PDF via fetch (not a blind form-POST-into-iframe) so we can
  // detect a 404/500 — a missing PDF blob — and show a graceful message instead
  // of rendering the backend's error page inside the viewer. `semantic_query`
  // stays in the POST body; `corpus` + `section_*` ride the query string (the
  // backend reads section flags from request.query_params). `#page=N` on the
  // resulting blob: URL is honoured natively by the browser PDF viewer.
  const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error'
  const [src, setSrc] = useState(null);
  const pageFragment = page ? `#page=${page}` : '';

  const action = (() => {
    const params = new URLSearchParams();
    if (corpusId) params.set('corpus', corpusId);
    if (!corpusId && sections) {
      // Explicit true/false per section — see pdfShadeSections.
      Object.keys(sections).forEach(code => params.set(`section_${code}`, sections[code] ? 'true' : 'false'));
    }
    const qs = params.toString();
    return `/shaded_pdf/${encodeURIComponent(caseId)}${qs ? `?${qs}` : ''}`;
  })();

  useEffect(() => {
    let cancelled = false;
    let objUrl = null;
    const fd = new FormData();
    fd.append('semantic_query', query || '');
    fetch(action, { method: 'POST', body: fd })
      .then(res => { if (!res.ok) throw new Error(String(res.status)); return res.blob(); })
      .then(blob => {
        if (cancelled) return;
        objUrl = URL.createObjectURL(blob);
        setSrc(objUrl + pageFragment);
        setStatus('ok');
      })
      .catch(() => { if (!cancelled) setStatus('error'); });
    return () => { cancelled = true; if (objUrl) URL.revokeObjectURL(objUrl); };
  }, []);

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
      <div onClick={e => e.stopPropagation()} style={{ width: '100%', maxWidth: 1100, height: '100%', background: 'var(--surface)', borderRadius: 8, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
          <div style={{ fontWeight: 600, fontSize: 13, fontFamily: 'Georgia, serif' }}>{caseId}</div>
          <button onClick={onClose} style={{ ...iconBtn, color: 'var(--text-3)' }}><Icon name="x" size={15}/></button>
        </div>
        {status === 'ok' && <iframe src={src} title={caseId} style={{ flex: 1, border: 'none' }}/>}
        {status === 'loading' && (
          <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-3)', fontSize: 13 }}>
            {(strings && strings.loading) || 'Laster…'}
          </div>
        )}
        {status === 'error' && (
          <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, padding: 32, textAlign: 'center', color: 'var(--text-2)' }}>
            <Icon name="document" size={26}/>
            <div style={{ fontWeight: 600, fontSize: 14 }}>{(strings && strings.pdf_unavailable_title) || 'PDF ikke tilgjengelig'}</div>
            <div style={{ fontSize: 12.5, color: 'var(--text-3)', maxWidth: 360 }}>
              {(strings && strings.pdf_unavailable_body) || 'Dokumentet er ikke publisert ennå. Prøv igjen senere.'}
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Top-level App
// ──────────────────────────────────────────────────────────────────────────
function emptyFilterState() {
  return {
    case_id: '', respondent: '', complainant: '', saken_gjelder: '',
    start_date: '', end_date: '',
    sections: {}, sections_touched: false,
    // Default to paragraph (Avsnitt) view — the ranked paragraphs that answer the
    // query are the product's core value. Corpora with their own view_modes
    // (Lovdata/EU) override via SET_CORPUS (validVMs[0]); the case table is one
    // toggle away for everyone else.
    view_mode: 'paragraph',
    sort_by: '', sort_order: 'desc',
    doc_slug: [], unit_kind: [], doc_kind: [], instance: [], section_code: [], language_code: [],
    catchwords_terms: [],
  };
}

const initialState = {
  cfg: null,
  corpus: null,
  query: '',
  // Per-corpus filter pockets (PR B). Each source tab remembers its own
  // filter state — switching tabs swaps pockets, switching back restores
  // what you had. The flat ``filters`` slot below is a *derived view* on
  // ``filtersByCorpus[corpus]`` kept in sync by the reducer, so the 18+
  // existing reads of ``state.filters`` continue to work unchanged.
  filtersByCorpus: {},
  filters: emptyFilterState(),
  page: 1,
  page_size: 20,
  results: [],
  // total: count of the unit shown in the summary line (cases in Sak view, or
  // the pre-cap filtered pool size in Avsnitt view — the "X avsnitt" number).
  // null on boot / corpus switch so the source-tab badge doesn't flash "0";
  // becomes a number once the first SEARCH_OK arrives.
  total: null,
  total_pages: 1,
  // doc_count: distinct highest-level units in the filtered pool (cases for
  // KOFA-kind, documents for paragraph-native). Drives the source-tab badge,
  // stable across view modes. null until first SEARCH_OK.
  doc_count: null,
  // cap_hit: did the FAISS rank pool exhaust K_CAP=500 paragraphs?
  // Drives the "(kun de 500 mest relevante vises)" parenthetical in the
  // Avsnitt-mode summary line.
  cap_hit: false,
  view_mode: 'paragraph',
  meta: {},
  selected: null,         // { corpus, item }
  detail: null,           // detail payload from /api/detail
  detailLoading: false,
  loading: false,
  error: null,
  dark: false,
  pdfOpen: null,          // case_id when PDF modal open
  lovdataViewerOpen: null, // { docSlug, externalUrl, chapterId } when LovdataViewer open
  guideOpen: false,
  supportOpen: false,
  railExpanded: false,
  searchMode: 'best',     // 'best' (hybrid) | 'keyword' (BM25) | 'semantic' (vector) — EFFECTIVE mode
  userSearchMode: 'best', // the user's last explicit pick; effective = corpus.force_search_mode || userSearchMode
  rerank: false,          // «Grundighet» toggle — cross-encoder rerank on/off (per request, default OFF / opt-in)

  // Onboarding tutorial: drives a multi-step modal on first visit and a Help
  // button in the LeftRail to re-open it. `available` flips to false when the
  // userdata backend is unreachable so we don't keep retrying.
  onboarding: { available: true, checked: false, seen: false, completedAt: null, dismissedAt: null, version: null, openOverride: false },

  // Projects (LeftRail panel + DetailPanel "save case" affordance). When
  // `currentId` is set the result list switches to that project's saved cases.
  projects: { available: true, items: [], currentId: null, currentItems: null, currentLoading: false },
};

function reducer(state, action) {
  switch (action.type) {
    case 'BOOT': {
      // Seed the active corpus's filter pocket with whatever filters were
      // restored from Api.getSavedState (or default empty). View_mode comes
      // from action.viewMode (cookie/URL hint) or defaults to 'paragraph'.
      // First-visit default filters declared by the landing corpus (e.g.
      // Vergabekammer defaults the Abschnitt facet to Begründung — the
      // chamber's own reasoning — mirroring KOFA defaulting to kv). Restored
      // state.filters keys win over the declared defaults, so a returning user
      // keeps their choice.
      const bootCorpusObj = (action.cfg?.corpora || []).find(c => c.id === action.corpus);
      const initFilters = { ...(bootCorpusObj?.default_filters || {}), ...state.filters, view_mode: action.viewMode || 'paragraph' };
      return {
        ...state,
        cfg: action.cfg,
        corpus: action.corpus,
        dark: action.dark,
        filters: initFilters,
        // Respect a forced search mode if we boot straight onto such a corpus.
        searchMode: bootCorpusObj?.force_search_mode || state.searchMode,
        filtersByCorpus: action.corpus ? { ...state.filtersByCorpus, [action.corpus]: initFilters } : state.filtersByCorpus,
      };
    }
    case 'SET_CORPUS': {
      // Per-corpus filter memory (PR B). Save current corpus's filters to
      // its pocket; switch to the new corpus's pocket (or an empty default
      // if first visit). view_mode lives inside the pocket so each tab
      // remembers whether you were last in Sak or Avsnitt. searchMode +
      // semantic_query stay top-level — they're user-modal preferences,
      // not data filters.
      //
      // view_mode default per corpus: corpora that ship a `view_modes` config
      // (Lovdata, Retsinfo, …) MUST land on their first declared mode rather
      // than the generic 'case' default in emptyFilterState — otherwise the
      // first search after corpus switch sends view_mode='case' to the
      // backend, hits the legacy PR #35 Kapittel rollup, and renders the old
      // chapter table that PR #44 was supposed to retire. Same normalization
      // for any pocket whose stored view_mode predates PR #44.
      const oldCorpus = state.corpus;
      const newCorpus = action.corpus;
      const pockets = oldCorpus
        ? { ...state.filtersByCorpus, [oldCorpus]: state.filters }
        : state.filtersByCorpus;
      const newCorpusObj = (state.cfg?.corpora || []).find(c => c.id === newCorpus);
      const validVMs = (newCorpusObj?.view_modes || []).map(vm => vm.value);
      const defaultVM = validVMs[0] || 'case';
      const isFirstVisit = !pockets[newCorpus];
      let newFilters = pockets[newCorpus] || emptyFilterState();
      // First visit only: honor the corpus's declared default_view_mode — laws
      // land on their Dokument/Sak rollup, court + reference keep the global
      // Avsnitt default. On return, the remembered pocket wins (we don't touch it).
      if (isFirstVisit && newCorpusObj?.default_filters) {
        newFilters = { ...newCorpusObj.default_filters, ...newFilters };
      }
      if (isFirstVisit && newCorpusObj?.default_view_mode) {
        newFilters = { ...newFilters, view_mode: newCorpusObj.default_view_mode };
      }
      // Normalize a stale/invalid view_mode against the corpus's declared modes.
      if (validVMs.length > 0 && !validVMs.includes(newFilters.view_mode)) {
        newFilters = { ...newFilters, view_mode: defaultVM };
      }
      return {
        ...state,
        corpus: newCorpus,
        page: 1,
        selected: null,
        detail: null,
        filters: newFilters,
        filtersByCorpus: pockets,
        results: [],
        total: null,
        doc_count: null,
        cap_hit: false,
        total_pages: 1,
        view_mode: newFilters.view_mode,
        // A corpus may force a search mode (EU corpora in the DE app force
        // 'semantic': their text is embedded in English, so BM25/hybrid on a
        // German query matches poorly — the multilingual vector doesn't). Fall
        // back to the user's own last pick for corpora that don't force one.
        searchMode: newCorpusObj?.force_search_mode || state.userSearchMode,
        meta: {},
      };
    }
    case 'SET_QUERY':
      return { ...state, query: action.query, page: 1 };
    case 'SET_FILTERS':
      // Write into both the active pocket and the derived ``filters`` view.
      return {
        ...state,
        filters: action.filters,
        filtersByCorpus: state.corpus
          ? { ...state.filtersByCorpus, [state.corpus]: action.filters }
          : state.filtersByCorpus,
        page: 1,
      };
    case 'SET_PAGE':
      return { ...state, page: action.page };
    case 'SEARCH_BEGIN':
      return { ...state, loading: true, error: null };
    case 'SEARCH_OK':
      return { ...state, loading: false, results: action.results, total: action.total, total_pages: action.total_pages, doc_count: action.doc_count, cap_hit: !!action.cap_hit, view_mode: action.view_mode || state.view_mode, meta: action.meta || {} };
    case 'SEARCH_ERR':
      return { ...state, loading: false, error: action.error };
    case 'SELECT':
      if (state.selected && action.selected && state.selected.corpus === action.selected.corpus && state.selected.id === action.selected.id)
        return { ...state, selected: null, detail: null };
      return { ...state, selected: action.selected, detail: null, detailLoading: !!action.selected };
    case 'DETAIL_OK':
      return { ...state, detail: action.detail, detailLoading: false };
    case 'DETAIL_ERR':
      return { ...state, detail: null, detailLoading: false, error: action.error };
    case 'CLOSE_DETAIL':
      return { ...state, selected: null, detail: null };
    case 'TOGGLE_DARK':
      return { ...state, dark: !state.dark };
    case 'OPEN_PDF':
      return { ...state, pdfOpen: { caseId: action.caseId, page: action.page || null, corpusId: action.corpusId || null, section: action.section || null } };
    case 'CLOSE_PDF':
      return { ...state, pdfOpen: null };
    case 'OPEN_LOVDATA_VIEWER':
      return { ...state, lovdataViewerOpen: { docSlug: action.docSlug, externalUrl: action.externalUrl || null, chapterId: action.chapterId || null, targetPid: action.targetPid ?? null, corpus: action.corpus || 'lovdata' } };
    case 'CLOSE_LOVDATA_VIEWER':
      return { ...state, lovdataViewerOpen: null };
    case 'GUIDE_OPEN':
      return { ...state, guideOpen: true };
    case 'GUIDE_CLOSE':
      return { ...state, guideOpen: false };
    case 'SUPPORT_TOGGLE':
      return { ...state, supportOpen: !state.supportOpen };
    case 'SUPPORT_CLOSE':
      return { ...state, supportOpen: false };
    case 'TOGGLE_RAIL':
      return { ...state, railExpanded: !state.railExpanded };
    case 'EXPAND_RAIL':
      return state.railExpanded ? state : { ...state, railExpanded: true };
    case 'SET_SEARCH_MODE':
      // Record it as the user's explicit preference AND as the effective mode.
      // (The chooser is hidden on corpora that force a mode, so this only fires
      // where the user is actually free to choose.)
      return { ...state, searchMode: action.mode, userSearchMode: action.mode, page: 1 };
    case 'SET_RERANK':
      return { ...state, rerank: !!action.on, page: 1 };

    // ─── Onboarding ────────────────────────────────────────────────────
    case 'ONBOARDING_LOADED':
      return { ...state, onboarding: { ...state.onboarding, available: action.available !== false, checked: true, seen: !!action.seen, completedAt: action.completedAt || null, dismissedAt: action.dismissedAt || null, version: action.version ?? null } };
    case 'ONBOARDING_COMPLETE':
      return { ...state, onboarding: { ...state.onboarding, seen: true, completedAt: new Date().toISOString(), version: action.version ?? state.onboarding.version, openOverride: false } };
    case 'ONBOARDING_DISMISS':
      return { ...state, onboarding: { ...state.onboarding, seen: true, dismissedAt: new Date().toISOString(), openOverride: false } };
    case 'HELP_OPEN':
      return { ...state, onboarding: { ...state.onboarding, openOverride: true } };
    case 'HELP_CLOSE':
      return { ...state, onboarding: { ...state.onboarding, openOverride: false } };

    // ─── Projects ──────────────────────────────────────────────────────
    case 'PROJECTS_LOADED':
      return { ...state, projects: { ...state.projects, available: action.available !== false, items: action.items || [] } };
    case 'PROJECT_UPSERT': {
      const next = state.projects.items.slice();
      const idx = next.findIndex(p => p.id === action.project.id);
      if (idx >= 0) next[idx] = { ...next[idx], ...action.project };
      else next.unshift(action.project);
      return { ...state, projects: { ...state.projects, items: next } };
    }
    case 'PROJECT_REMOVE': {
      const next = state.projects.items.filter(p => p.id !== action.projectId);
      const clearCurrent = state.projects.currentId === action.projectId;
      return { ...state, projects: { ...state.projects, items: next, currentId: clearCurrent ? null : state.projects.currentId, currentItems: clearCurrent ? null : state.projects.currentItems } };
    }
    case 'PROJECT_SET_CURRENT':
      return { ...state, projects: { ...state.projects, currentId: action.projectId, currentItems: action.projectId ? state.projects.currentItems : null, currentLoading: !!action.projectId } };
    case 'PROJECT_ITEMS_LOADED':
      if (state.projects.currentId !== action.projectId) return state;  // stale
      return { ...state, projects: { ...state.projects, currentItems: action.items || [], currentLoading: false } };
    case 'PROJECT_ITEM_BUMP_COUNT': {
      const next = state.projects.items.map(p => p.id === action.projectId ? { ...p, item_count: (p.item_count || 0) + (action.delta || 1) } : p);
      return { ...state, projects: { ...state.projects, items: next } };
    }

    default:
      return state;
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [bootError, setBootError] = useState(null);
  // Telemetry: log ONE 'search' event per *settled* query (not per keystroke).
  // { timer } debounces past the search's own 250ms; { lastSig } dedupes so
  // pagination / view-toggle / identical re-fires don't double-count.
  const searchTelemetryRef = useRef({ timer: null, lastSig: null });

  // Boot
  useEffect(() => {
    const dm = Api.loadDarkMode();
    Api.getConfig().then(cfg => {
      const persisted = Api.loadState();
      const corpus = (persisted && persisted.corpus) || cfg.landing_corpus || cfg.default_corpus;
      dispatch({ type: 'BOOT', cfg, corpus, dark: dm === true });
      if (persisted && persisted.searchMode && ['best','keyword','semantic'].includes(persisted.searchMode)) {
        dispatch({ type: 'SET_SEARCH_MODE', mode: persisted.searchMode });
      }
      if (persisted && typeof persisted.rerank === 'boolean') {
        dispatch({ type: 'SET_RERANK', on: persisted.rerank });
      }
      // Per-user state. Fire in parallel; both .catch to null so a 503 from the
      // userdata backend (or a 404 from PROJECTS_ENABLED=false) silently degrades:
      // the LeftRail Prosjekt section and onboarding modal just don't render.
      if (cfg.projects_enabled) {
        Api.getOnboarding().then(r => {
          dispatch({ type: 'ONBOARDING_LOADED', seen: r.seen, completedAt: r.completed_at, dismissedAt: r.dismissed_at, version: r.version });
        }).catch(() => {
          dispatch({ type: 'ONBOARDING_LOADED', available: false, seen: true });
        });
        Api.listProjects().then(r => {
          dispatch({ type: 'PROJECTS_LOADED', items: r.items });
        }).catch(() => {
          dispatch({ type: 'PROJECTS_LOADED', available: false, items: [] });
        });
      } else {
        dispatch({ type: 'ONBOARDING_LOADED', available: false, seen: true });
        dispatch({ type: 'PROJECTS_LOADED', available: false, items: [] });
      }
    }).catch(e => {
      console.error('Boot error:', e);
      setBootError(String(e));
    });
  }, []);

  // Apply dark-mode to root element
  useEffect(() => {
    document.documentElement.dataset.theme = state.dark ? 'dark' : 'light';
    Api.saveDarkMode(state.dark);
  }, [state.dark]);

  // Persist lightweight state
  useEffect(() => {
    if (!state.cfg) return;
    Api.saveState({ corpus: state.corpus, query: state.query, filters: state.filters, searchMode: state.searchMode, rerank: state.rerank });
  }, [state.corpus, state.query, state.filters, state.cfg, state.searchMode, state.rerank]);

  // Fire searches whenever query / filters / corpus / page change.
  // Suppressed when the user is viewing a saved project (LeftRail click);
  // the project's items are rendered as synthetic results instead.
  useEffect(() => {
    if (!state.cfg || !state.corpus) return;
    if (state.projects && state.projects.currentId) return;
    let cancelled = false;
    const ctrl = new AbortController();
    const handle = setTimeout(async () => {
      dispatch({ type: 'SEARCH_BEGIN' });
      try {
        const payload = buildSearchPayload(state);
        const _searchT0 = performance.now();
        const res = await Api.search(payload, ctrl.signal);
        if (cancelled) return;
        if (res.error) {
          const friendly = res.error === 'corpus_unavailable'
            ? 'Denne kilden er ikke synket lokalt. Kjør med USE_BLOB=true for å hente fra Azure (prod-mirror).'
            : res.error === 'unknown_corpus'
              ? 'Ukjent kilde for dette miljøet.'
              : (res.error_detail || res.error);
          dispatch({ type: 'SEARCH_ERR', error: friendly });
          return;
        }
        dispatch({
          type: 'SEARCH_OK',
          results: res.results || [],
          total: typeof res.total === 'number' ? res.total : 0,
          total_pages: res.total_pages || 1,
          doc_count: typeof res.doc_count === 'number' ? res.doc_count : null,
          cap_hit: !!res.cap_hit,
          view_mode: res.view_mode,
          meta: {
            multi_select_options: res.multi_select_options,
            multi_select_counts: res.multi_select_counts,
            default_start_date: res.default_start_date,
            default_end_date: res.default_end_date,
          },
        });
        // Telemetry: log ONE 'search' event once the query settles (content-free).
        // A dedicated 1.5s debounce collapses keystroke bursts to the final query;
        // the signature dedupes pagination / view-toggle / identical re-fires.
        if (state.cfg && state.cfg.events_enabled) {
          const q = (state.query || '').trim();
          if (q) {
            const fsig = { ...state.filters }; delete fsig.view_mode;
            const sig = JSON.stringify([state.corpus, q, fsig, state.searchMode]);
            const total = typeof res.total === 'number' ? res.total : 0;
            const evt = {
              corpus_id: state.corpus,
              search_mode: state.searchMode,
              view_mode: state.filters.view_mode || 'case',
              query_length: q.length,
              result_count: total,
              zero_result: total === 0,
              latency_ms: Math.round(performance.now() - _searchT0),
            };
            const tref = searchTelemetryRef.current;
            clearTimeout(tref.timer);
            tref.timer = setTimeout(() => {
              if (sig !== tref.lastSig) {
                tref.lastSig = sig;
                Api.emitEvent('search', evt);
              }
            }, 1500);
          }
        }
      } catch (e) {
        if (cancelled || e.name === 'AbortError') return;
        dispatch({ type: 'SEARCH_ERR', error: String(e) });
      }
    }, 250);
    return () => { cancelled = true; ctrl.abort(); clearTimeout(handle); };
  }, [state.cfg, state.corpus, state.query, state.filters, state.page, state.searchMode, state.rerank, state.projects?.currentId]);

  // Project items fetch — runs when a project is selected from the LeftRail.
  useEffect(() => {
    const pid = state.projects && state.projects.currentId;
    if (!pid) return;
    let cancelled = false;
    Api.getProject(pid).then(r => {
      if (cancelled) return;
      dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId: pid, items: r.items || [] });
    }).catch(() => {
      if (!cancelled) dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId: pid, items: [] });
    });
    return () => { cancelled = true; };
  }, [state.projects?.currentId]);

  // Detail fetch
  useEffect(() => {
    if (!state.selected) return;
    let cancelled = false;
    Api.getDetail(state.selected.corpus, state.selected.id).then(r => {
      if (cancelled) return;
      if (r.error) dispatch({ type: 'DETAIL_ERR', error: r.error });
      else dispatch({ type: 'DETAIL_OK', detail: r.detail });
    }).catch(e => { if (!cancelled) dispatch({ type: 'DETAIL_ERR', error: String(e) }); });
    return () => { cancelled = true; };
  }, [state.selected]);

  // Pre-cfg fallbacks read the locale hint baked into the SPA template's
  // window.__BOOTSTRAP__ so the boot screen + load error are localized
  // before /api/config arrives.
  const bootIsDanish = (typeof window !== 'undefined' && window.__BOOTSTRAP__ && window.__BOOTSTRAP__.locale === 'da');
  if (bootError) {
    const msg = bootIsDanish ? 'Kunne ikke indlæse konfiguration' : 'Klarte ikke å laste konfigurasjon';
    return <div style={{ padding: 32, color: '#c33' }}>{msg}: {bootError}</div>;
  }
  if (!state.cfg) {
    return <div className="boot-fallback"><div className="boot-spinner"/><div className="boot-label">{bootIsDanish ? 'Indlæser…' : 'Laster…'}</div></div>;
  }

  const cfg = state.cfg;
  const corpora = cfg.corpora;
  const corpusObj = corpora.find(c => c.id === state.corpus) || corpora[0];
  const primary = cfg.primary_corpus;
  const strings = cfg.strings;
  const isPrimary = corpusObj.id === primary.id;
  // Section-code → label map. For a cross-country tab (e.g. KLFU in the NO app)
  // use the foreign corpus's own sections (DK i/p/s/a/ku/h) so the paragraph-card
  // tags resolve to Danish labels instead of leaking the raw code ("ku").
  const sectionSource = (corpusObj?.is_cross_country && corpusObj?.sections?.length) ? corpusObj : primary;
  const sectionLabels = (sectionSource.sections || []).reduce((m, s) => (m[s.code] = s.label, m), {});

  // Active source tab's badge shows `state.doc_count` — distinct cases /
  // documents in the filtered pool, stable across view modes (filter-aware,
  // never the raw paragraph-cap-bounded number). null until first SEARCH_OK
  // so badge doesn't flash "0" on load. See PR A in
  // docs/architecture-roadmap.md.
  const totals = corpora.reduce((m, c) => { m[c.id] = c.id === state.corpus ? state.doc_count : null; return m; }, {})

  const isDanish = cfg.locale === 'da';
  const isGermanUi = cfg.locale === 'de';
  const placeholderSamples = corpusObj.id === 'vergabekammer'
    // German full-question suggestions — fires whether Vergabekammer is the DE
    // primary (APP_MODE=de) or a secondary corpus. §-anchors (GWB/VgV) double as
    // BM25 anchors against the decision text.
    ? [
        'Wann darf der öffentliche Auftraggeber ein Vergabeverfahren aufheben?',
        'Welche Anforderungen gelten für die Aufklärung eines ungewöhnlich niedrigen Angebots?',
        'Wie weit reicht der Beurteilungsspielraum des Auftraggebers bei der Wertung von Konzepten?',
        'Unter welchen Voraussetzungen ist ein Angebot wegen fehlender Eignung auszuschließen?',
        'Wann ist eine Rüge rechtzeitig im Sinne des § 160 Abs. 3 GWB?',
        'Wann ist eine De-facto-Vergabe nach § 135 GWB unwirksam?',
      ]
    : isPrimary
    ? (isDanish
      ? [
          'Hvornår foreligger en væsentlig ændring efter udbudslovens § 178?',
          'Hvilke krav stiller udbudsloven til ordregiverens undersøgelse af et unormalt lavt tilbud?',
          'Hvornår er en ordregivers evalueringsmodel uegnet til at identificere det økonomisk mest fordelagtige tilbud?',
          'Hvornår kan ordregiver annullere et udbud uden erstatningspligt?',
        ]
      : [
          'Når foreligger en vesentlig endring etter anskaffelsesforskriften § 28-2?',
          'Hvilke krav stiller anskaffelsesforskriften til begrunnelsen for avvisning av tilbud?',
          'Hvilke terskelverdier utløser kunngjøringsplikt etter anskaffelsesloven?',
          'Når kan oppdragsgiver avlyse en anskaffelse uten erstatningsansvar?',
        ])
    : corpusObj.id === 'retsinfo'
      ? [
          'Hvilke obligatoriske udelukkelsesgrunde gælder efter udbudslovens § 135?',
          'Hvad er Klagenævnet for Udbuds kompetence til at tilkende erstatning?',
          'Hvornår udløser en kontraktændring krav om nyt udbud efter § 178?',
          'Hvilke betingelser gælder for udbud med forhandling efter § 61?',
        ]
      : corpusObj.id === 'lovdata'
        // Norwegian corpus — keep statutory terms (avvisningsgrunner, KOFA,
        // anskaffelsesforskriften, § refs) in Norwegian even in the Danish UI
        // so BM25 fires against the actual statute text.
        ? (isDanish
          ? [
              'Hvilke avvisningsgrunner gælder efter anskaffelsesforskriften § 24-2?',
              'Hvornår foreligger en vesentlig endring af kontrakt efter § 28-2?',
              'Hvad er KOFAs kompetence til at afgive rådgivende udtalelser?',
              'Hvilke kvalifikasjonskrav kan oppdragsgiver stille efter § 16-1?',
            ]
          : [
              'Hvilke avvisningsgrunner følger av anskaffelsesforskriften § 24-2?',
              'Når foreligger en vesentlig endring av kontrakt etter § 28-2?',
              'Hva er KOFAs kompetanse til å gi rådgivende uttalelser?',
              'Hvilke kvalifikasjonskrav kan oppdragsgiver stille etter § 16-1?',
            ])
        : corpusObj.id === 'eu_directives'
          // Full-question suggestions matching the veiledning's "skriv hele
          // spørsmål, ikke bare nøkkelord" advice (issue #33). Same shape
          // for both locales — the embedding model is multilingual so
          // Norwegian/Danish prompts retrieve the EU-translated text fine.
          // Article numbers (2014/24/EU) are language-neutral BM25 anchors.
          ? (isGermanUi
            ? [
                'Unter welchen Voraussetzungen erlaubt Artikel 72 die Änderung eines öffentlichen Auftrags ohne neues Vergabeverfahren?',
                'Welche Ausschlussgründe in Artikel 57 führen zum zwingenden Ausschluss eines Wirtschaftsteilnehmers?',
                'Welche Anforderungen stellt Artikel 18 an Gleichbehandlung und Transparenz im Vergabeverfahren?',
                'Unter welchen Voraussetzungen ist das Verhandlungsverfahren nach Artikel 29 zulässig?',
              ]
            : isDanish
            ? [
                'Hvilke betingelser i artikel 172 tillader ændring af en offentlig kontrakt uden ny udbudsprocedure?',
                'Hvilke udelukkelsesgrunde i artikel 57 medfører obligatorisk udelukkelse af en tilbudsgiver?',
                'Hvilke krav stiller artikel 18 til ligebehandling og gennemsigtighed i udbudsprocessen?',
                'Hvilke betingelser gælder for udbud med forhandling efter artikel 29?',
              ]
            : [
                'Hvilke vilkår i artikkel 72 tillater endring av en offentlig kontrakt uten ny anskaffelsesprosedyre?',
                'Hvilke utelukkelsesgrunner i artikkel 57 medfører obligatorisk utelukkelse av en leverandør?',
                'Hvilke krav stiller artikkel 18 til likebehandling og åpenhet i anskaffelsesprosessen?',
                'Hvilke vilkår gjelder for konkurranse med forhandling etter artikkel 29?',
              ])
          : (isGermanUi
            ? [
                'Wie legte der EuGH die «wesentliche Änderung» im Urteil pressetext (C-454/06) aus?',
                'Wann verletzt der Ausschluss eines Angebots den Gleichbehandlungsgrundsatz, vgl. Succhi di Frutta (C-496/99 P)?',
                'Welche Anforderungen stellt das Urteil Alcatel (C-81/98) an die Stillhaltefrist?',
                'Wann liegt ein Interessenkonflikt nach dem Urteil Fabricom (C-21/03) vor?',
              ]
            : isDanish
            ? [
                'Hvordan fortolkede EU-Domstolen «væsentlig ændring» i pressetext-dommen (C-454/06)?',
                'Hvornår krænker en afvisning af tilbud ligebehandlingsprincippet, jf. Succhi di Frutta (C-496/99 P)?',
                'Hvilke krav stiller Alcatel-dommen (C-81/98) til standstill-perioden?',
                'Hvornår foreligger en interessekonflikt efter Fabricom-dommen (C-21/03)?',
              ]
            : [
                'Hvordan tolket EU-domstolen «vesentlig endring» i pressetext-dommen (C-454/06)?',
                'Når krenker avvisning av tilbud likebehandlingsprinsippet, jf. Succhi di Frutta (C-496/99 P)?',
                'Hvilke krav stiller Alcatel-dommen (C-81/98) til karensperioden?',
                'Når foreligger en interessekonflikt etter Fabricom-dommen (C-21/03)?',
              ]);

  // Use the cross-country corpus's own filter config for chip enumeration so
  // foreign multi-select chips (e.g. KLFU's verdict_kinds) render in NO app.
  const chipsFilterCfg = (corpusObj?.is_cross_country && corpusObj?.kind === 'kofa') ? corpusObj : primary;
  const chips = activeFilterChips(state.filters, chipsFilterCfg, sectionLabels, strings);

  return (
    <div className="spa-shell" style={{ display: 'flex', flexDirection: 'row', height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
      <LeftRail expanded={state.railExpanded} onToggle={() => dispatch({ type: 'TOGGLE_RAIL' })}
        logoutUrl={cfg.logout_url}
        strings={strings}
        onHelp={cfg.projects_enabled && state.onboarding.available ? () => dispatch({ type: 'HELP_OPEN' }) : null}
        onGuide={() => dispatch({ type: 'GUIDE_OPEN' })}
        projectsAvailable={cfg.projects_enabled && state.projects.available}
        projectsSlot={cfg.projects_enabled && state.projects.available ? (
          <ProjectsRail
            items={state.projects.items}
            currentId={state.projects.currentId}
            strings={strings}
            onSelect={(pid) => dispatch({ type: 'PROJECT_SET_CURRENT', projectId: pid })}
            onCreate={(name) => Api.createProject(name).then(r => dispatch({ type: 'PROJECT_UPSERT', project: r.project }))}
            onRename={(pid, name) => Api.renameProject(pid, name).then(r => dispatch({ type: 'PROJECT_UPSERT', project: r.project }))}
            onDelete={(pid) => Api.deleteProject(pid).then(() => dispatch({ type: 'PROJECT_REMOVE', projectId: pid }))}/>
        ) : null}/>
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0 }}>
        <TopBar brand={cfg.brand} dark={state.dark}
          onToggleDark={() => dispatch({ type: 'TOGGLE_DARK' })}
          onLogout={cfg.logout_url}
          onToggleSupport={() => dispatch({ type: 'SUPPORT_TOGGLE' })}
          supportOpen={state.supportOpen}
          onCloseSupport={() => dispatch({ type: 'SUPPORT_CLOSE' })}
          strings={strings}/>
        {state.projects.currentId ? (
          <div style={{ flex: 1, overflowY: 'auto' }}>
            <ProjectView
              project={state.projects.items.find(p => p.id === state.projects.currentId) || null}
              items={state.projects.currentItems}
              loading={state.projects.currentLoading}
              corpora={corpora}
              strings={strings}
              selectedKey={state.selected ? `${state.selected.corpus}-${state.selected.id}` : null}
              onBack={() => dispatch({ type: 'PROJECT_SET_CURRENT', projectId: null })}
              onSelect={(id, item) => dispatch({ type: 'SELECT', selected: { corpus: (item && item._corpus_source) || corpusObj.id, id, item } })}
              onRemoveItem={(itemId) => {
                const pid = state.projects.currentId;
                Api.removeProjectItem(pid, itemId).then(() => {
                  const nextItems = (state.projects.currentItems || []).filter(it => it.id !== itemId);
                  dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId: pid, items: nextItems });
                  dispatch({ type: 'PROJECT_ITEM_BUMP_COUNT', projectId: pid, delta: -1 });
                });
              }}/>
          </div>
        ) : (
          <>
            <div style={{ padding: '24px 24px 0', flexShrink: 0 }}>
              <div style={{ maxWidth: 1100, margin: '0 auto' }}>
                <SourceTabs corpora={corpora} active={state.corpus} onSelect={id => dispatch({ type: 'SET_CORPUS', corpus: id })} totals={totals}/>
              </div>
            </div>
            <div style={{ flex: 1, overflow: 'hidden', minHeight: 0, padding: '12px 24px 24px' }}>
              <div style={{ maxWidth: 1100, margin: '0 auto', height: '100%', display: 'flex', gap: 16, alignItems: 'stretch' }}>
                {/* Persistent left filter sidebar (experiment) */}
                <aside data-tour="filters" style={{ width: 320, flexShrink: 0, height: '100%', overflowY: 'auto', paddingRight: 2 }}>
                  <FilterAccordion corpus={corpusObj} primary={primary} strings={strings}
                    value={state.filters} onChange={f => dispatch({ type: 'SET_FILTERS', filters: f })}
                    options={state.meta} alwaysOpen/>
                </aside>
                {/* Results column — search bar is a fixed header; the results scroll
                    in their own area below it, so the query stays visible without the
                    search bar ever overlapping the result cards. */}
                <div style={{ flex: 1, minWidth: 0, height: '100%', display: 'flex', flexDirection: 'column' }}>
                  <div style={{ flexShrink: 0 }}>
                    <SearchBar query={state.query} onQuery={q => dispatch({ type: 'SET_QUERY', query: q })} placeholderSamples={placeholderSamples}
                      searchMode={state.searchMode}
                      onSetSearchMode={(m) => dispatch({ type: 'SET_SEARCH_MODE', mode: m })}
                      lockMode={!!corpusObj?.force_search_mode}
                      rerank={state.rerank}
                      rerankEnabled={!!cfg.rerank_enabled}
                      onSetRerank={(on) => dispatch({ type: 'SET_RERANK', on })}
                      dark={state.dark}
                      locale={cfg.locale}/>
                  </div>
                  <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
                  <ActiveFilterChips chips={chips}
                    onRemove={key => dispatch({ type: 'SET_FILTERS', filters: removeChip(state.filters, key) })}
                    onClearAll={() => dispatch({ type: 'SET_FILTERS', filters: emptyFilterState() })}/>
                {corpusObj.supports_view_mode_toggle && (
                  <ViewModeToggle
                    mode={state.filters.view_mode || 'case'}
                    onChange={m => dispatch({ type: 'SET_FILTERS', filters: { ...state.filters, view_mode: m } })}
                    strings={strings}
                    corpus={corpusObj}/>
                )}
                <ResultSummary state={state} corpusObj={corpusObj} primary={primary} strings={strings} isDanish={isDanish} locale={cfg.locale} rerank={state.rerank} rerankEnabled={!!cfg.rerank_enabled}/>
                <div data-tour="result-list">
                  <ResultFade viewMode={state.filters.view_mode || 'case'} render={delayedMode => (
                    <ResultList state={{ ...state, view_mode: delayedMode }} corpus={corpusObj} primary={primary} sectionLabels={sectionLabels} strings={strings} locale={cfg.locale}
                      onSelect={(id, item) => {
                        if (cfg.events_enabled) {
                          const rank = (state.results || []).indexOf(item);
                          Api.emitEvent('result_open', {
                            corpus_id: (item && item._corpus_source) || corpusObj.id,
                            case_id: (item && (item.case_id || item.id)) != null ? String(item.case_id || item.id) : (id != null ? String(id) : null),
                            rank: rank >= 0 ? rank : null,
                            search_mode: state.searchMode,
                            view_mode: state.filters.view_mode || 'case',
                          });
                        }
                        dispatch({ type: 'SELECT', selected: { corpus: (item && item._corpus_source) || corpusObj.id, id, item } });
                      }}
                      onOpenPdf={(cid, page, corpusId, section) => {
                        if (cfg.events_enabled) {
                          const rank = (state.results || []).findIndex(r => String(r.case_id || r.id) === String(cid));
                          Api.emitEvent('pdf_open', {
                            corpus_id: corpusId || corpusObj.id,
                            case_id: cid != null ? String(cid) : null,
                            rank: rank >= 0 ? rank : null,
                            view_mode: state.filters.view_mode || 'case',
                          });
                        }
                        dispatch({ type: 'OPEN_PDF', caseId: cid, page, corpusId, section });
                      }}
                      onOpenLovdataViewer={(docSlug, externalUrl, chapterId, targetPid, corpus) => dispatch({ type: 'OPEN_LOVDATA_VIEWER', docSlug, externalUrl, chapterId, targetPid, corpus })}
                      selectedKey={state.selected ? `${state.selected.corpus}-${state.selected.id}` : null}
                      projects={cfg.projects_enabled && state.projects.available ? state.projects.items : null}
                      query={state.query}
                      onSaveHit={(projectId, payload) => {
                        if (cfg.events_enabled) {
                          const cid = payload && payload.caseId;
                          const rank = (state.results || []).findIndex(r => String(r.case_id || r.id) === String(cid));
                          Api.emitEvent('save', {
                            corpus_id: (payload && payload.corpusId) || corpusObj.id,
                            case_id: cid != null ? String(cid) : null,
                            rank: rank >= 0 ? rank : null,
                            view_mode: state.filters.view_mode || 'case',
                            attrs: { item_kind: (payload && payload.itemKind) || 'case' },
                          });
                        }
                        return Api.addProjectItem(projectId, payload).then(r => {
                          if (state.projects.currentId === projectId) {
                            Api.getProject(projectId).then(p => dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId, items: p.items || [] })).catch(() => {});
                          } else {
                            dispatch({ type: 'PROJECT_ITEM_BUMP_COUNT', projectId, delta: 1 });
                          }
                          return r;
                        });
                      }}
                      onCreateProject={(name) => Api.createProject(name).then(r => {
                        dispatch({ type: 'PROJECT_UPSERT', project: r.project });
                        return r.project;
                      })}/>
                  )}/>
                </div>
                <Pagination page={state.page} totalPages={state.total_pages} onPage={p => dispatch({ type: 'SET_PAGE', page: p })} strings={strings}/>
                </div>
                </div>
              </div>
            </div>
          </>
        )}
      </div>
      <DetailPanel
        open={!!state.selected}
        corpus={state.selected ? (corpora.find(c => c.id === state.selected.corpus) || corpusObj) : corpusObj}
        primary={primary}
        detail={state.detail}
        detailLoading={state.detailLoading}
        selectedId={state.selected ? state.selected.id : null}
        query={state.query}
        onClose={() => dispatch({ type: 'CLOSE_DETAIL' })}
        onOpenPdf={(cid, page, corpusId, section) => {
          if (cfg.events_enabled) {
            const rank = (state.results || []).findIndex(r => String(r.case_id || r.id) === String(cid));
            Api.emitEvent('pdf_open', {
              corpus_id: corpusId || (state.selected && state.selected.corpus) || corpusObj.id,
              case_id: cid != null ? String(cid) : null,
              rank: rank >= 0 ? rank : null,
              view_mode: state.filters.view_mode || 'case',
            });
          }
          dispatch({ type: 'OPEN_PDF', caseId: cid, page, corpusId, section });
        }}
        strings={strings}
        projects={cfg.projects_enabled && state.projects.available ? state.projects.items : null}
        onSaveToProject={(projectId, options) => {
          if (cfg.events_enabled) {
            const cid = options && options.caseId;
            const rank = (state.results || []).findIndex(r => String(r.case_id || r.id) === String(cid));
            Api.emitEvent('save', {
              corpus_id: (options && options.corpusId) || (state.selected && state.selected.corpus) || corpusObj.id,
              case_id: cid != null ? String(cid) : null,
              rank: rank >= 0 ? rank : null,
              view_mode: state.filters.view_mode || 'case',
              attrs: { item_kind: (options && options.itemKind) || 'case' },
            });
          }
          return Api.addProjectItem(projectId, options).then(r => {
            // Refresh the visible project's item list if we're saving into the
            // currently-open project (so the newly saved item shows up under
            // the right query group right away).
            if (state.projects.currentId === projectId) {
              Api.getProject(projectId).then(p =>
                dispatch({ type: 'PROJECT_ITEMS_LOADED', projectId, items: p.items || [] })
              ).catch(() => {});
            } else {
              dispatch({ type: 'PROJECT_ITEM_BUMP_COUNT', projectId, delta: 1 });
            }
            return r;
          });
        }}
        onCreateProject={(name) =>
          Api.createProject(name).then(r => {
            dispatch({ type: 'PROJECT_UPSERT', project: r.project });
            return r.project;
          })
        }/>
      {state.pdfOpen && <PdfModal caseId={state.pdfOpen.caseId} page={state.pdfOpen.page} corpusId={state.pdfOpen.corpusId} query={state.query} sections={pdfShadeSections(state, primary)} onClose={() => dispatch({ type: 'CLOSE_PDF' })} strings={strings}/>}
      {state.lovdataViewerOpen && <LovdataViewer corpus={state.lovdataViewerOpen.corpus} docSlug={state.lovdataViewerOpen.docSlug} externalUrl={state.lovdataViewerOpen.externalUrl} chapterId={state.lovdataViewerOpen.chapterId} targetPid={state.lovdataViewerOpen.targetPid} query={state.query} onClose={() => dispatch({ type: 'CLOSE_LOVDATA_VIEWER' })}/>}
      {state.guideOpen && <Guide onClose={() => dispatch({ type: 'GUIDE_CLOSE' })} strings={strings} rerankEnabled={!!cfg.rerank_enabled}/>}
      {cfg.projects_enabled && state.onboarding.checked && (state.onboarding.openOverride || !state.onboarding.seen) && (
        <OnboardingTour
          strings={strings}
          version={cfg.onboarding_version}
          dispatch={dispatch}
          appState={state}
          onActionTryQuery={() => {
            const example = (placeholderSamples && placeholderSamples[0])
              || 'Hva vil utgjøre en vesentlig endring av en kontrakt?';
            dispatch({ type: 'SET_QUERY', query: example });
            // Switch to paragraph (avsnitt) view so the user sees the ranked
            // paragraph list explained in the next tour step. Harmless on
            // corpora that don't render the toggle.
            dispatch({ type: 'SET_FILTERS', filters: { ...state.filters, view_mode: 'paragraph' } });
          }}
          onComplete={(v) => dispatch({ type: 'ONBOARDING_COMPLETE', version: v })}
          onDismiss={() => dispatch({ type: 'ONBOARDING_DISMISS' })}/>
      )}
    </div>
  );
}

function ResultSummary({ state, corpusObj, primary, strings, isDanish, rerank, rerankEnabled, locale }) {
  // Cap-aware summary line. `total` is the count of the unit shown in the
  // active view; `cap_hit` indicates the FAISS rank pool exhausted K_CAP=500
  // and we append a parenthetical explaining the truncation (only fires on
  // the lowest-grain view per backend logic).
  const isKofa = corpusObj.kind === 'kofa';
  const isGerman = locale === 'de';
  const total = state.total;
  const numLocale = isGerman ? 'de-DE' : (isDanish ? 'da-DK' : 'nb-NO');
  const fmtN = (n) => (typeof n === 'number' ? n.toLocaleString(numLocale) : '—');
  const totalFmt = fmtN(total);
  const capSuffix = state.cap_hit
    ? (isGerman ? ' (nur die 500 relevantesten werden angezeigt)' : ' (kun de 500 mest relevante vises)')
    : '';

  // PR C: when the corpus declares view_modes, derive the unit label from the
  // active view's summary_label. Lovdata Ledd-view → "ledd"; Paragraf →
  // "paragrafer"; Dokument → "dokumenter".
  const viewModes = corpusObj?.view_modes || [];
  const activeVM = viewModes.find(vm => vm.value === state.view_mode);

  let label;
  if (state.loading) label = isGerman ? 'Suche…' : (isDanish ? 'Søger…' : 'Søker…');
  else if (activeVM) {
    const unit = activeVM.summary_label || activeVM.label.toLowerCase();
    label = isGerman
      ? `Treffer pro ${unit} — ${totalFmt} ${unit}${capSuffix}`
      : isDanish
        ? `Hits pr. ${unit} — ${totalFmt} ${unit}${capSuffix}`
        : `Treff per ${unit} — ${totalFmt} ${unit}${capSuffix}`;
  } else {
    label = isGerman
      ? `${totalFmt} Treffer in ${corpusObj.label}${capSuffix}`
      : isDanish
        ? `${totalFmt} hits i ${corpusObj.label}${capSuffix}`
        : `${totalFmt} treff i ${corpusObj.label}${capSuffix}`;
  }
  return (
    <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 10, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8 }}>
      <span>{label}</span>
      {state.loading && rerankEnabled && rerank && (
        <span className="rerank-pill" title={isGerman ? 'Gründlichere Sortierung an — die Treffer werden neu sortiert' : (isDanish ? 'Grundigere sortering på — resultaterne rangeres på ny' : 'Grundigere sortering på — resultatene rangeres på nytt')}>
          {isGerman ? 'Gründlichere Sortierung…' : (isDanish ? 'Grundig sortering…' : 'Grundigere sortering…')}<span className="rerank-sweep"/>
        </span>
      )}
    </div>
  );
}

function ResultList({ state, corpus, primary, sectionLabels, onSelect, onOpenPdf, onOpenLovdataViewer, selectedKey, strings, projects, query, onSaveHit, onCreateProject, locale }) {
  if (state.error) return <div style={{ padding: 24, color: '#c33' }}>{locale === 'de' ? 'Fehler' : 'Feil'}: {state.error}</div>;
  if (!state.results.length && !state.loading) {
    return <div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--text-3)', fontSize: 13 }}>{strings?.no_paragraphs || 'Ingen treff. Juster søket eller filtrene.'}</div>;
  }
  if (corpus.kind === 'kofa') {
    const isCrossCountry = !!corpus.is_cross_country;
    if (state.view_mode === 'paragraph') {
      return state.results.map((hit, i) => {
        const key = `${corpus.id}-${hit.case_id}-${hit.para_idx}`;
        return <KofaParagraphCard key={key + i} hit={hit} sectionLabels={sectionLabels}
          openPdf={onOpenPdf}
          isCrossCountry={isCrossCountry}
          selected={selectedKey === `${corpus.id}-${hit.case_id}`}
          onClick={() => onSelect(hit.case_id, hit)}
          corpus={corpus}
          projects={projects}
          query={query}
          onSaveHit={onSaveHit}
          onCreateProject={onCreateProject}
          strings={strings}/>;
      });
    }
    // Cross-country corpora carry their own result_columns + id field on
    // corpus.* (foreign country's schema). Home primary's columns belong to
    // primary.* (the cfg). Without this branch the cross-country table
    // renders rows whose keys (Saksnummer / jnr / Klager / klager / …) don't
    // match the home country's column headers, leaving every cell blank.
    const tableColumns = (isCrossCountry && corpus.result_columns && corpus.result_columns.length)
      ? corpus.result_columns
      : primary.result_columns;
    const caseIdKey = isCrossCountry ? (tableColumns[0] && tableColumns[0].key) : primary.col_case_id;
    return <KofaCaseTable rows={state.results} columns={tableColumns}
      onSelect={r => onSelect(r[caseIdKey] || r.case_id, r)}
      onOpenPdf={onOpenPdf}
      isCrossCountry={isCrossCountry}
      corpus={corpus}
      selectedId={state.selected?.id}/>;
  }
  // Generic corpora — table view vs card view. The active view_mode picks
  // which result_columns to render against: corpus.view_modes[i].result_columns
  // overrides corpus.result_columns when defined (PR C: Lovdata Paragraf +
  // Dokument modes each ship their own column set). Falls back to corpus-
  // level columns for legacy `view_mode === 'case'` clients (PR #35 Kapittel
  // rollup).
  const activeVM = (corpus.view_modes || []).find(vm => vm.value === state.view_mode);
  const viewModeColumns = activeVM?.result_columns;
  const tableColumns = viewModeColumns
    || (state.view_mode === 'case' ? corpus.result_columns : null);
  const isTableView = !!(tableColumns && tableColumns.length > 0);
  if (isTableView) {
    // Document-viewer corpora (Lovdata / Retsinfo) swap the external-link "Kilde"
    // column for an "Åpne" column that opens the viewer at the row's top hit.
    const viewerOpen = (corpus.supports_document_viewer && onOpenLovdataViewer)
      ? (slug, url, chapterId) => onOpenLovdataViewer(slug, url, chapterId, null, corpus.id)
      : null;
    return <GenericTable rows={state.results} columns={tableColumns} corpus={corpus}
      onSelect={r => onSelect(r.pid, r)}
      selectedId={state.selected?.id}
      urlField={corpus.url_field}
      onOpenViewer={viewerOpen}
      locale={locale}/>;
  }
  if (corpus.kind === 'lovdata') {
    // Both Lovdata (NO) and Retsinformation (DK) render with the same paragraph
    // card — they share the same chunks-per-§ shape. The url field differs
    // (lovdata_url vs retsinfo_url) so we pass the corpus down.
    // A "Vis dokument" affordance opens the full-doc viewer (Phase 1 of the
    // cross-corpus killer feature). Driven by the corpus `supports_document_viewer`
    // flag — Lovdata (NO) + Retsinfo (DK) both set it and each has a matching
    // /api/document-html/<corpus> route + raw statute HTML.
    const viewerCallback = (corpus.supports_document_viewer && onOpenLovdataViewer) ? onOpenLovdataViewer : null;
    return state.results.map((hit, i) => (
      <LovCard key={`${corpus.id}-${hit.pid}-${i}`} hit={hit} corpus={corpus}
        selected={selectedKey === `${corpus.id}-${hit.pid}`}
        onClick={() => onSelect(hit.pid, hit)}
        onOpenViewer={viewerCallback ? () => viewerCallback(hit.doc_slug, hit[corpus.url_field || 'lovdata_url'], null, hit.pid, corpus.id) : null}/>
    ));
  }
  if (corpus.id === 'eu_directives') {
    return state.results.map((hit, i) => (
      <EuDirectiveCard key={`${corpus.id}-${hit._corpus_source || ''}-${hit.pid}-${i}`} hit={hit}
        selected={selectedKey === `${hit._corpus_source || corpus.id}-${hit.pid}`}
        onClick={() => onSelect(hit.pid, hit)}/>
    ));
  }
  if (corpus.id === 'dfo_veileder') {
    return state.results.map((hit, i) => (
      <DfoVeilederCard key={`${corpus.id}-${hit.pid}-${i}`} hit={hit}
        selected={selectedKey === `${corpus.id}-${hit.pid}`}
        onClick={() => onSelect(hit.pid, hit)}/>
    ));
  }
  if (corpus.id === 'eu_court_decisions') {
    return state.results.map((hit, i) => (
      <EuCourtCard key={`${corpus.id}-${hit.pid}-${i}`} hit={hit}
        selected={selectedKey === `${corpus.id}-${hit.pid}`}
        onClick={() => onSelect(hit.pid, hit)}/>
    ));
  }
  if (corpus.kind === 'vergabekammer') {
    return state.results.map((hit, i) => (
      <VergabekammerCard key={`${corpus.id}-${hit.pid}-${i}`} hit={hit}
        selected={selectedKey === `${corpus.id}-${hit.pid}`}
        onClick={() => onSelect(hit.pid, hit)}
        openPdf={onOpenPdf}
        corpus={corpus}/>
    ));
  }
  return null;
}

function Pagination({ page, totalPages, onPage, strings }) {
  if (totalPages <= 1) return null;
  return (
    <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 16 }}>
      <button disabled={page <= 1} onClick={() => onPage(page - 1)} style={pageBtn(page <= 1)}>‹ {strings?.pagination_prev || 'Forrige'}</button>
      <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{strings?.pagination_page_x_of_y ? strings.pagination_page_x_of_y.replace('{x}', page).replace('{y}', totalPages) : `Side ${page} av ${totalPages}`}</span>
      <button disabled={page >= totalPages} onClick={() => onPage(page + 1)} style={pageBtn(page >= totalPages)}>{strings?.pagination_next || 'Neste'} ›</button>
    </div>
  );
}
const pageBtn = (disabled) => ({
  padding: '5px 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)',
  fontSize: 12, color: disabled ? 'var(--text-3)' : 'var(--text-2)', cursor: disabled ? 'default' : 'pointer',
  opacity: disabled ? 0.5 : 1,
});

// ──────────────────────────────────────────────────────────────────────────
// Guided product tour: anchored popovers walk the user through SourceTabs,
// SearchBar, SearchMode, FilterAccordion, ViewModeToggle (avsnitt/sak), and
// the LeftRail Prosjekt section. Steps whose target isn't rendered (e.g. the
// view-mode toggle on corpora that don't support it) are auto-skipped.
// ──────────────────────────────────────────────────────────────────────────
function OnboardingTour({ strings, version, onComplete, onDismiss, dispatch, onActionTryQuery, appState }) {
  // Step list. `selector` null = centered modal (no anchor). `optional` skips
  // forward when the target doesn't exist in the DOM. `ensureRailExpanded`
  // dispatches EXPAND_RAIL before measuring. `actionLabel` + `onAction` add a
  // primary button that runs a side-effect (e.g. fill in the example query)
  // and then advances. `watch(state, baseline)` makes a step interactive: a
  // baseline of relevant slices is captured on entry; once `watch` returns
  // true (the user actually performed the action) the tour auto-advances.
  const allSteps = [
    { kind: 'modal', title: strings.onboarding_step1_title, body: strings.onboarding_step1_body,
      figure: '/static/images/figures/rai-symboler-04.png' },
    { kind: 'tour', selector: '[data-tour="source-tabs"]', placement: 'bottom',
      title: strings.onboarding_tour_sources_title, body: strings.onboarding_tour_sources_body },
    { kind: 'tour', selector: '[data-tour="search-bar"]', placement: 'bottom',
      title: strings.onboarding_step2_title, body: strings.onboarding_step2_body,
      figure: '/static/images/figures/rai-symboler2-07.png',
      // Wait for the search to actually complete — query has changed AND
      // results came back. Without the results check we'd advance during the
      // 250ms debounce + API latency window and land on an empty result list.
      watch: (s, b) => (s.query || '').trim().length > 0
                       && (s.query || '') !== (b.query || '')
                       && !s.loading
                       && (s.results?.length || 0) > 0,
      actionLabel: strings.onboarding_tour_try_action,
      onAction: () => { if (onActionTryQuery) onActionTryQuery(); } },
    { kind: 'tour', selector: '[data-tour="result-list"]', placement: 'inside-right',
      title: strings.onboarding_tour_results_title, body: strings.onboarding_tour_results_body,
      figure: '/static/images/figures/rai-symboler-02.png' },
    { kind: 'tour', selector: '[data-tour="view-mode"]', placement: 'top', optional: true,
      // Ring covers the toggle AND the result list it controls so the user
      // sees them as one related unit when sak ↔ avsnitt is flipped.
      extraSelectors: ['[data-tour="result-list"]'],
      title: strings.onboarding_tour_viewmode_title, body: strings.onboarding_tour_viewmode_body,
      softDim: true,
      autoAdvanceDelayMs: 3500,
      watch: (s, b) => (s.filters?.view_mode || 'case') !== (b.view_mode || 'case') && !s.loading },
    { kind: 'tour', selector: '[data-tour="filters"]', placement: 'right',
      title: strings.onboarding_step3_title, body: strings.onboarding_step3_body },
    { kind: 'tour', selector: '[data-tour="search-mode"]', placement: 'right',
      title: strings.onboarding_tour_searchmode_title, body: strings.onboarding_tour_searchmode_body,
      figure: '/static/images/figures/rai-symboler-05.png',
      // Soft dim so the user can see the result list re-rank when they switch.
      softDim: true,
      // Auto-advance only when the user picks Nøkkelord AND the BM25 re-rank
      // has come back. Long delay so they actually see the new ranking.
      autoAdvanceDelayMs: 3500,
      watch: (s, b) => s.searchMode === 'keyword' && s.searchMode !== b.searchMode && !s.loading },
    { kind: 'tour', selector: '[data-tour="left-rail-projects"]', placement: 'right',
      title: strings.onboarding_step4_title, body: strings.onboarding_step4_body,
      ensureRailExpanded: true,
      figure: '/static/images/figures/rai-symboler2-06.png',
      // Auto-advance when a new project appears in the list.
      watch: (s, b) => (s.projects?.items?.length || 0) > (b.projectsCount || 0) },
    { kind: 'modal',
      title: strings.onboarding_finish_title,
      body: strings.onboarding_finish_body,
      figure: '/static/images/figures/rai-symboler-03.png' },
  ];

  const [step, setStep] = useState(0);
  const [rect, setRect] = useState(null);
  const [missingTarget, setMissingTarget] = useState(false);
  // Baseline = snapshot of relevant state when entering an interactive step.
  // `watch(state, baseline)` returns true once the user has performed the action.
  const [baseline, setBaseline] = useState(null);
  const POPOVER_W = 360;

  const current = allSteps[step] || null;
  const isLast = step === allSteps.length - 1;
  const isFirst = step === 0;
  const isInteractive = !!(current && current.watch);

  const finish = () => {
    Api.completeOnboarding(version).catch(() => {});
    onComplete(version);
  };
  const dismiss = () => {
    Api.dismissOnboarding().catch(() => {});
    onDismiss();
  };
  const goNext = () => { if (isLast) finish(); else setStep(s => s + 1); };
  const goBack = () => setStep(s => Math.max(0, s - 1));

  // When entering a step that needs the rail expanded, kick that off first.
  // Then, run any step-level onEnter side effect (e.g. open the search-mode
  // chooser) on the next frame so the DOM has settled.
  useEffect(() => {
    if (current && current.ensureRailExpanded) dispatch({ type: 'EXPAND_RAIL' });
    if (current && current.onEnter) {
      const raf = requestAnimationFrame(() => {
        try { current.onEnter(); } catch (_) {}
      });
      return () => cancelAnimationFrame(raf);
    }
  }, [step]);

  // Snapshot baseline state on step entry (only for interactive steps that
  // care). Captures view_mode, searchMode, query, projectsCount.
  useEffect(() => {
    if (!current || !current.watch) { setBaseline(null); return; }
    setBaseline({
      view_mode: appState?.filters?.view_mode || 'case',
      searchMode: appState?.searchMode,
      query: appState?.query || '',
      projectsCount: appState?.projects?.items?.length || 0,
    });
  }, [step]);

  // When the user performs the action this step is watching for, advance
  // automatically. Default delay is short (600ms) — long enough to see the
  // click confirm. Steps that re-rank the list (view-mode, search-mode) opt
  // into a longer delay so the user actually sees the new ranking before
  // we move on.
  useEffect(() => {
    if (!current || !current.watch || !baseline || !appState) return;
    if (current.watch(appState, baseline)) {
      const delay = current.autoAdvanceDelayMs ?? 600;
      const t = setTimeout(() => goNext(), delay);
      return () => clearTimeout(t);
    }
  }, [appState?.filters?.view_mode, appState?.searchMode, appState?.query, appState?.projects?.items?.length, appState?.loading, baseline, step]);

  // Recompute target position on step change, scroll, and resize.
  useEffect(() => {
    let raf = 0;
    const recompute = () => {
      if (!current || current.kind !== 'tour') {
        setRect(null); setMissingTarget(false);
        return;
      }
      const el = document.querySelector(current.selector);
      if (!el) {
        if (current.optional) {
          // Skip forward — but only if not the last step (else end).
          setRect(null); setMissingTarget(false);
          if (!isLast) setStep(s => s + 1);
          else finish();
          return;
        }
        setRect(null); setMissingTarget(true);
        return;
      }
      el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
      // Union the primary target rect with any extraSelectors so the highlight
      // ring can encompass multiple related elements as one group (e.g. the
      // view-mode toggle + the result list it controls).
      const rects = [el.getBoundingClientRect()];
      for (const sel of (current.extraSelectors || [])) {
        const ex = document.querySelector(sel);
        if (ex) rects.push(ex.getBoundingClientRect());
      }
      const top = Math.min(...rects.map(r => r.top));
      const left = Math.min(...rects.map(r => r.left));
      const right = Math.max(...rects.map(r => r.right));
      const bottom = Math.max(...rects.map(r => r.bottom));
      setRect({ top, left, width: right - left, height: bottom - top, right, bottom });
      setMissingTarget(false);
    };
    // Run on next frame so any pending dispatches (EXPAND_RAIL) flush first.
    raf = requestAnimationFrame(() => requestAnimationFrame(recompute));
    window.addEventListener('resize', recompute);
    window.addEventListener('scroll', recompute, true);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', recompute);
      window.removeEventListener('scroll', recompute, true);
    };
  }, [step]);

  // Keyboard: Esc dismiss, Enter next, ArrowLeft back.
  useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') dismiss();
      else if (e.key === 'Enter') goNext();
      else if (e.key === 'ArrowLeft' && !isFirst) goBack();
      else if (e.key === 'ArrowRight') goNext();
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  });  // re-bound per render

  if (!current) return null;

  // Compute popover position. Modal-style (centered) when no rect, or when
  // the target is missing for a non-optional step (graceful fallback).
  // Welcome modal (centered) gets a wider canvas for the longer intro copy.
  const popWidth = (current.kind === 'modal') ? 480 : POPOVER_W;
  const popoverStyle = {
    position: 'fixed', zIndex: 92, width: popWidth, maxWidth: 'calc(100vw - 32px)',
    // Smooth glide between steps. Soft ease-in-out curve so the popover
    // doesn't snap into place — feels intentional and unhurried.
    transition: 'top 0.65s cubic-bezier(0.32, 0, 0.18, 1), left 0.65s cubic-bezier(0.32, 0, 0.18, 1), right 0.65s cubic-bezier(0.32, 0, 0.18, 1), bottom 0.65s cubic-bezier(0.32, 0, 0.18, 1)',
  };
  const useCentered = current.kind === 'modal' || !rect || missingTarget;
  if (useCentered) {
    popoverStyle.top = '50%';
    popoverStyle.left = '50%';
    popoverStyle.transform = 'translate(-50%, -50%)';
  } else {
    const m = 14;
    if (current.placement === 'bottom') {
      popoverStyle.top = rect.bottom + m;
      popoverStyle.left = Math.max(16, Math.min(rect.left, window.innerWidth - POPOVER_W - 16));
    } else if (current.placement === 'bottom-start') {
      popoverStyle.top = rect.bottom + m;
      popoverStyle.left = Math.max(16, Math.min(rect.left, window.innerWidth - POPOVER_W - 16));
    } else if (current.placement === 'bottom-end') {
      popoverStyle.top = rect.bottom + m;
      popoverStyle.right = Math.max(16, window.innerWidth - rect.right);
    } else if (current.placement === 'right') {
      popoverStyle.top = Math.max(16, Math.min(rect.top, window.innerHeight - 220));
      popoverStyle.left = rect.right + m;
    } else if (current.placement === 'top') {
      popoverStyle.bottom = window.innerHeight - rect.top + m;
      popoverStyle.left = Math.max(16, Math.min(rect.left, window.innerWidth - POPOVER_W - 16));
    } else if (current.placement === 'inside-right') {
      // Popover floats inside the target's bounds, anchored top-right.
      popoverStyle.top = rect.top + 16;
      popoverStyle.right = Math.max(16, window.innerWidth - rect.right + 16);
    }
  }

  // Highlight ring around the target. Uses a giant box-shadow to dim
  // everything *outside* the ring, so we don't need a separate overlay.
  // Steps that need the user to see the rest of the page (e.g. toggling
  // view-mode and seeing the result list change) can opt into a softer dim.
  const dimAlpha = current && current.softDim ? 0.15 : 0.45;
  const ringStyle = rect && !useCentered ? {
    position: 'fixed',
    top: rect.top - 6, left: rect.left - 6,
    width: rect.width + 12, height: rect.height + 12,
    borderRadius: 8, zIndex: 91,
    boxShadow: `0 0 0 4px rgba(255,255,255,0.85), 0 0 0 9999px rgba(0,0,0,${dimAlpha})`,
    pointerEvents: 'none',
    transition: 'top 0.7s cubic-bezier(0.32, 0, 0.18, 1), left 0.7s cubic-bezier(0.32, 0, 0.18, 1), width 0.7s cubic-bezier(0.32, 0, 0.18, 1), height 0.7s cubic-bezier(0.32, 0, 0.18, 1), box-shadow 0.7s ease',
  } : null;

  // When centered, render an explicit dim layer (no ring to provide it).
  return (
    <>
      {useCentered && (
        <div onClick={dismiss} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 90 }}/>
      )}
      {ringStyle && <div style={ringStyle}/>}
      <div role="dialog" aria-modal="true" style={popoverStyle}>
        <div style={{ background: 'var(--surface)', borderRadius: 'var(--r)', overflow: 'hidden auto', maxHeight: 'calc(100vh - 32px)', boxShadow: '0 24px 60px rgba(0,0,0,0.28)' }}>
          {current.figure && (
            <div style={{
              height: useCentered ? 150 : 80,
              background: `var(--accent-bg) url(${asset(current.figure)}) center / contain no-repeat`,
              borderBottom: '1px solid var(--border-light)',
            }}/>
          )}
          <div style={{ padding: '18px 22px 16px' }}>
            {(() => {
              // Welcome modal sits outside the tour step counter — show
              // progress only on the actual tour steps. Dots are clickable
              // jump-to-step controls.
              if (current.kind === 'modal') return null;
              const tourEntries = allSteps.map((s, i) => ({ s, i })).filter(x => x.s.kind !== 'modal');
              const tourIdx = tourEntries.findIndex(x => x.i === step);
              return (
                <div style={{ display: 'flex', gap: 4, marginBottom: 14 }}>
                  {tourEntries.map((x, i) => (
                    <button key={i}
                      onClick={() => setStep(x.i)}
                      aria-label={`Steg ${i + 1}`}
                      style={{
                        flex: 1, height: 8, padding: 0, border: 'none', cursor: 'pointer', borderRadius: 2,
                        background: 'transparent',
                      }}>
                      <div style={{ height: 3, borderRadius: 2, background: i <= tourIdx ? 'var(--accent)' : 'var(--border)', transition: 'background 140ms var(--ease-standard)' }}/>
                    </button>
                  ))}
                </div>
              );
            })()}
            <h2 style={{ fontSize: useCentered ? 22 : 16, fontWeight: 600, margin: '0 0 10px', color: 'var(--text)', fontFamily: 'Georgia, serif', lineHeight: 1.2 }}>{current.title}</h2>
            <p style={{ fontSize: useCentered ? 14 : 13, lineHeight: 1.6, color: 'var(--text-2)', margin: 0, whiteSpace: 'pre-line' }}>{current.body}</p>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 20, gap: 10 }}>
              <button onClick={dismiss} style={{ background: 'transparent', border: 'none', color: 'var(--text-3)', fontSize: 12, cursor: 'pointer', padding: '6px 4px' }}>{strings.onboarding_skip}</button>
              <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                {current.kind !== 'modal' && (() => {
                  const tourSteps = allSteps.filter(s => s.kind !== 'modal');
                  const tourIdx = allSteps.slice(0, step + 1).filter(s => s.kind !== 'modal').length;
                  return <span style={{ fontSize: 11, color: 'var(--text-3)' }}>{tourIdx} / {tourSteps.length}</span>;
                })()}
                {!isFirst && (
                  <button onClick={goBack} style={{ padding: '5px 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12, color: 'var(--text-2)', cursor: 'pointer' }}>{strings.onboarding_back}</button>
                )}
                {(() => {
                  // "Kom i gang" only appears on the final wrap-up modal. Every
                  // other step shows "Neste" (or its actionLabel / start_tour
                  // override). Auto-advance from watch-steps still works on top
                  // of this — the button is the explicit-advance fallback.
                  const label = current.actionLabel
                    || (isLast ? strings.onboarding_done
                      : isFirst ? strings.onboarding_start_tour
                      : strings.onboarding_next);
                  const btnStyle = { padding: useCentered ? '7px 18px' : '5px 14px', borderRadius: 'var(--r-sm)', border: '1px solid var(--accent)', background: 'var(--accent)', color: '#fff', fontSize: useCentered ? 13 : 12, cursor: 'pointer', fontWeight: 500 };
                  return (
                    <button
                      onClick={() => {
                        if (current.onAction) { try { current.onAction(); } catch (_) {} }
                        goNext();
                      }}
                      style={btnStyle}>
                      {label}
                    </button>
                  );
                })()}
              </div>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Guide / documentation modal — opens from the LeftRail "Veiledning" button.
// Static content driven entirely by `strings`; layout is a centered overlay
// with a sticky table of contents on the left and scrolling sections on
// the right. Sections + tips are localized via country_config.py.
// ──────────────────────────────────────────────────────────────────────────
function Guide({ onClose, strings, rerankEnabled }) {
  // Close on Escape.
  useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [onClose]);

  const sections = [
    { id: 'search',   title: strings.guide_section_search_title,   body: strings.guide_section_search_body },
    { id: 'modes',    title: strings.guide_section_modes_title,    body: strings.guide_section_modes_body },
    ...(rerankEnabled && strings.guide_section_grundighet_title
        ? [{ id: 'grundighet', title: strings.guide_section_grundighet_title, body: strings.guide_section_grundighet_body }]
        : []),
    { id: 'why',      title: strings.guide_section_why_title,      body: strings.guide_section_why_body },
    { id: 'views',    title: strings.guide_section_views_title,    body: strings.guide_section_views_body },
    { id: 'filters',  title: strings.guide_section_filters_title,  body: strings.guide_section_filters_body },
    { id: 'projects', title: strings.guide_section_projects_title, body: strings.guide_section_projects_body },
    { id: 'sources',  title: strings.guide_section_sources_title,  body: strings.guide_section_sources_body,
      bullets: [
        // DE-app source bullets (defined only in DE_CONFIG). NO/DK leave these
        // undefined and .filter(Boolean) drops them, so this is additive.
        strings.guide_sources_vergabekammer,
        strings.guide_sources_eu_rechtsakte,
        strings.guide_sources_eu_rechtsprechung,
        strings.guide_sources_kofa,
        strings.guide_sources_norsk_rett,
        strings.guide_sources_eu_rettsakter,
        strings.guide_sources_eu_rettspraksis,
        strings.guide_sources_dfo,
        strings.guide_sources_cross,
      ].filter(Boolean) },
    { id: 'tips',     title: strings.guide_section_tips_title,
      bullets: [strings.guide_tips_1, strings.guide_tips_2, strings.guide_tips_3, strings.guide_tips_4].filter(Boolean) },
  // Drop sections whose title is undefined for this locale (e.g. the DE app has
  // projects off and defines no projects-guide strings) so no empty section renders.
  ].filter(s => s.title);

  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 80,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" style={{
        background: 'var(--surface)', borderRadius: 12, maxWidth: 900, width: '100%',
        maxHeight: 'calc(100vh - 48px)', display: 'flex', flexDirection: 'column',
        boxShadow: '0 24px 60px rgba(0,0,0,0.28)', overflow: 'hidden',
      }}>
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '18px 24px', borderBottom: '1px solid var(--border-light)' }}>
          <h2 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: 'var(--text)', fontFamily: 'Georgia, serif', letterSpacing: '-0.005em' }}>{strings.guide_title}</h2>
          <button onClick={onClose} title={strings.guide_close} aria-label={strings.guide_close} style={iconBtn}>
            <Icon name="x" size={16} color="var(--text-2)"/>
          </button>
        </div>
        {/* Body: TOC + content */}
        <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
          <aside style={{
            width: 200, flexShrink: 0, padding: '20px 16px 20px 24px',
            borderRight: '1px solid var(--border-light)', overflowY: 'auto',
            background: 'var(--surface-2)',
          }}>
            <nav>
              {sections.map(s => (
                <a key={s.id} href={`#guide-${s.id}`}
                  onClick={(e) => {
                    e.preventDefault();
                    const el = document.getElementById(`guide-${s.id}`);
                    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
                  }}
                  style={{
                    display: 'block', padding: '6px 10px', marginBottom: 2,
                    fontSize: 12.5, color: 'var(--text-2)', textDecoration: 'none',
                    borderRadius: 6,
                  }}>{s.title}</a>
              ))}
              <a href="#guide-support"
                onClick={(e) => {
                  e.preventDefault();
                  const el = document.getElementById('guide-support');
                  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
                }}
                style={{
                  display: 'block', padding: '6px 10px', marginTop: 8,
                  fontSize: 12.5, color: 'var(--accent)', textDecoration: 'none',
                  borderRadius: 6, fontWeight: 500,
                }}>{strings.guide_support_cta}</a>
            </nav>
          </aside>
          <main style={{ flex: 1, overflowY: 'auto', padding: '0 28px 32px' }}>
            {/* Hero figure — sets the tone, echoes the tour welcome */}
            <div style={{
              height: 140, margin: '20px 0 22px',
              background: `var(--accent-bg) url(${asset('/static/images/figures/rai-symboler-04.png')}) center / contain no-repeat`,
              borderRadius: 8, border: '1px solid var(--border-light)',
            }}/>
            <p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--text-2)', margin: '0 0 24px', fontFamily: 'Georgia, "Source Serif Pro", serif' }}>{strings.guide_intro}</p>
            {sections.map(s => (
              <section key={s.id} id={`guide-${s.id}`} style={{ marginBottom: 28, scrollMarginTop: 16 }}>
                <h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', margin: '0 0 8px', fontFamily: 'Georgia, serif', letterSpacing: '-0.005em' }}>{s.title}</h3>
                {s.body && (
                  <p style={{ fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-2)', margin: '0 0 10px', whiteSpace: 'pre-line' }}>{s.body}</p>
                )}
                {s.bullets && s.bullets.length > 0 && (
                  <ul style={{ margin: '8px 0 0', paddingLeft: 20, fontSize: 13.5, lineHeight: 1.65, color: 'var(--text-2)' }}>
                    {s.bullets.map((b, i) => <li key={i} style={{ marginBottom: 6 }}>{b}</li>)}
                  </ul>
                )}
              </section>
            ))}
            {/* Contact-support callout. Sits at the bottom so users hit it after
             * scanning the rest. Email is intentionally hardcoded as the single
             * source of truth (not localized — it's an address, not copy). */}
            <section id="guide-support" style={{
              marginTop: 12, padding: '18px 20px', borderRadius: 10,
              background: 'var(--accent-bg)', border: '1px solid var(--border-light)',
              scrollMarginTop: 16,
            }}>
              <h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--text)', margin: '0 0 6px', fontFamily: 'Georgia, serif', letterSpacing: '-0.005em' }}>{strings.guide_support_title}</h3>
              <p style={{ fontSize: 13.5, lineHeight: 1.6, color: 'var(--text-2)', margin: '0 0 12px' }}>{strings.guide_support_body}</p>
              <a href="mailto:support@reliableai.no" style={{
                display: 'inline-flex', alignItems: 'center', gap: 8,
                padding: '7px 14px', borderRadius: 'var(--r-sm)',
                background: 'var(--accent)', color: '#fff', textDecoration: 'none',
                fontSize: 13, fontWeight: 500,
              }}>
                <Icon name="feedback" size={13} color="#fff"/>
                {strings.guide_support_cta} — support@reliableai.no
              </a>
            </section>
          </main>
        </div>
      </div>
    </div>
  );
}


// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
function buildSearchPayload(state) {
  return {
    corpus: state.corpus,
    semantic_query: state.query,
    filters: state.filters,
    sort_by: state.filters.sort_by,
    sort_order: state.filters.sort_order,
    view_mode: state.filters.view_mode || 'case',
    sections: state.filters.sections,
    sections_touched: state.filters.sections_touched,
    page: state.page,
    page_size: state.page_size,
    search_mode: state.searchMode || 'best',
    rerank: !!state.rerank,
  };
}

function activeFilterChips(filters, primary, sectionLabels, strings) {
  const chips = [];
  const lblCase = (strings && strings.case_id_label) || 'Saksnr';
  const lblResp = (strings && strings.respondent_label) || 'Innklaget';
  const lblCompl = (strings && strings.complainant_label) || 'Klager';
  const lblSubj = (strings && strings.saken_gjelder_label) || 'Gjelder';
  const lblFrom = (strings && strings.date_from_label) || 'Fra';
  const lblTo = (strings && strings.date_to_label) || 'Til';
  if (filters.case_id) chips.push({ key: 'case_id', label: `${lblCase}: ${filters.case_id}` });
  if (filters.respondent) chips.push({ key: 'respondent', label: `${lblResp}: ${filters.respondent}` });
  if (filters.complainant) chips.push({ key: 'complainant', label: `${lblCompl}: ${filters.complainant}` });
  if (filters.saken_gjelder) chips.push({ key: 'saken_gjelder', label: `${lblSubj}: ${filters.saken_gjelder}` });
  if (filters.start_date) chips.push({ key: 'start_date', label: `${lblFrom} ${filters.start_date}` });
  if (filters.end_date) chips.push({ key: 'end_date', label: `${lblTo} ${filters.end_date}` });
  Object.entries(filters.sections || {}).forEach(([code, on]) => {
    if (on) chips.push({ key: `section_${code}`, label: sectionLabels[code] || code });
  });
  ['doc_slug', 'unit_kind', 'doc_kind'].forEach(k => {
    (filters[k] || []).forEach(v => chips.push({ key: `${k}:${v}`, label: v }));
  });
  (filters.language_code || []).forEach(v => {
    const nb = LANGUAGE_LABELS_NB[v];
    chips.push({ key: `language_code:${v}`, label: nb ? `${nb} (${v})` : v });
  });
  (filters.instance || []).forEach(v => {
    chips.push({ key: `instance:${v}`, label: COURT_LABELS_NB[v] || v });
  });
  (filters.section_code || []).forEach(v => {
    const lbl = (primary?.id === 'klfu' ? SECTION_CODE_LABELS_DA : SECTION_CODE_LABELS_NB)[v] || v;
    chips.push({ key: `section_code:${v}`, label: lbl });
  });
  (filters.catchwords_terms || []).forEach(v => {
    chips.push({ key: `catchwords_terms:${v}`, label: v });
  });
  (primary?.multi_select_filters || []).forEach(ms => {
    (filters[ms.param] || []).forEach(v => chips.push({ key: `${ms.param}:${v}`, label: v }));
  });
  (primary?.boolean_filters || []).forEach(bf => {
    if (filters[bf.param]) chips.push({ key: bf.param, label: bf.label });
  });
  return chips;
}

function removeChip(filters, key) {
  if (key.includes(':')) {
    const [k, v] = key.split(':');
    return { ...filters, [k]: (filters[k] || []).filter(x => x !== v) };
  }
  if (key.startsWith('section_')) {
    const code = key.slice(8);
    return { ...filters, sections: { ...(filters.sections || {}), [code]: false }, sections_touched: true };
  }
  return { ...filters, [key]: '' };
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <ErrorBoundary><App/></ErrorBoundary>
);
