
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
//   const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
//     "primaryColor": "#D97757",
//     "fontSize": 16,
//     "density": "regular",
//     "dark": false
//   }/*EDITMODE-END*/;
//
//   function App() {
//     const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
//     return (
//       <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
//         Hello
//         <TweaksPanel>
//           <TweakSection label="Typography" />
//           <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
//                        onChange={(v) => setTweak('fontSize', v)} />
//           <TweakRadio  label="Density" value={t.density}
//                        options={['compact', 'regular', 'comfy']}
//                        onChange={(v) => setTweak('density', v)} />
//           <TweakSection label="Theme" />
//           <TweakColor  label="Primary" value={t.primaryColor}
//                        onChange={(v) => setTweak('primaryColor', v)} />
//           <TweakToggle label="Dark mode" value={t.dark}
//                        onChange={(v) => setTweak('dark', v)} />
//         </TweaksPanel>
//       </div>
//     );
//   }
//
// ─────────────────────────────────────────────────────────────────────────────

// Tweaks-panel styles live in docs/css/styles.css under the "Tweaks panel"
// section. Selectors here (.twk-*) match those CSS rules.

// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
  const [values, setValues] = React.useState(defaults);
  // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
  // useState-style call doesn't write a "[object Object]" key into the persisted
  // JSON block.
  const setTweak = React.useCallback((keyOrEdits, val) => {
    const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
      ? keyOrEdits : { [keyOrEdits]: val };
    setValues((prev) => ({ ...prev, ...edits }));
    // Same-origin only—host page must live on window.location.origin.
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, window.location.origin);
  }, []);
  return [values, setTweak];
}

// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability—if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
  const [open, setOpen] = React.useState(false);
  const dragRef = React.useRef(null);
  const offsetRef = React.useRef({ x: 16, y: 16 });
  const PAD = 16;

  const clampToViewport = React.useCallback(() => {
    const panel = dragRef.current;
    if (!panel) return;
    const w = panel.offsetWidth, h = panel.offsetHeight;
    const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
    const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
    offsetRef.current = {
      x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
      y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
    };
    panel.style.right = offsetRef.current.x + 'px';
    panel.style.bottom = offsetRef.current.y + 'px';
  }, []);

  React.useEffect(() => {
    if (!open) return;
    clampToViewport();
    if (typeof ResizeObserver === 'undefined') {
      window.addEventListener('resize', clampToViewport);
      return () => window.removeEventListener('resize', clampToViewport);
    }
    const ro = new ResizeObserver(clampToViewport);
    ro.observe(document.documentElement);
    return () => ro.disconnect();
  }, [open, clampToViewport]);

  React.useEffect(() => {
    const onMsg = (e) => {
      // Only honor messages from same-origin frames. Cross-origin postMessage
      // can be from anywhere and we don't want a hostile parent toggling the
      // tweak panel state.
      if (e.origin !== window.location.origin) return;
      const t = e?.data?.type;
      if (t === '__activate_edit_mode') setOpen(true);
      else if (t === '__deactivate_edit_mode') setOpen(false);
    };
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, window.location.origin);
    return () => window.removeEventListener('message', onMsg);
  }, []);

  const dismiss = () => {
    setOpen(false);
    window.parent.postMessage({ type: '__edit_mode_dismissed' }, window.location.origin);
  };

  const onDragStart = (e) => {
    const panel = dragRef.current;
    if (!panel) return;
    const target = e.currentTarget;
    const r = panel.getBoundingClientRect();
    const sx = e.clientX, sy = e.clientY;
    const startRight = window.innerWidth - r.right;
    const startBottom = window.innerHeight - r.bottom;
    // Pointer capture: routes pointermove/pointerup to the originating
    // target regardless of whether the cursor crosses an iframe boundary
    // or leaves the window. Cleaner than window-level listeners.
    const move = (ev) => {
      offsetRef.current = {
        x: startRight - (ev.clientX - sx),
        y: startBottom - (ev.clientY - sy),
      };
      clampToViewport();
    };
    const up = (ev) => {
      target.removeEventListener('pointermove', move);
      target.removeEventListener('pointerup', up);
      target.removeEventListener('pointercancel', up);
      try { target.releasePointerCapture(ev.pointerId); } catch {}
    };
    try { target.setPointerCapture(e.pointerId); } catch {}
    target.addEventListener('pointermove', move);
    target.addEventListener('pointerup', up);
    target.addEventListener('pointercancel', up);
  };

  if (!open) return null;
  return (
    <>
      <div ref={dragRef} className="twk-panel"
           style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
        <div className="twk-hd" onPointerDown={onDragStart}>
          <b>{title}</b>
          <button className="twk-x" aria-label="Close tweaks"
                  onPointerDown={(e) => e.stopPropagation()}
                  onClick={dismiss}>✕</button>
        </div>
        <div className="twk-body">{children}</div>
      </div>
    </>
  );
}

// ── Layout helpers ──────────────────────────────────────────────────────────

function TweakSection({ label, children }) {
  return (
    <>
      <div className="twk-sect">{label}</div>
      {children}
    </>
  );
}

function TweakRow({ label, value, children, inline = false }) {
  return (
    <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
      <div className="twk-lbl">
        <span>{label}</span>
        {value != null && <span className="twk-val">{value}</span>}
      </div>
      {children}
    </div>
  );
}

// ── Controls ────────────────────────────────────────────────────────────────

function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
  return (
    <TweakRow label={label} value={`${value}${unit}`}>
      <input type="range" className="twk-slider" min={min} max={max} step={step}
             value={value} onChange={(e) => onChange(Number(e.target.value))} />
    </TweakRow>
  );
}

function TweakToggle({ label, value, onChange }) {
  return (
    <div className="twk-row twk-row-h">
      <div className="twk-lbl"><span>{label}</span></div>
      <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
              role="switch" aria-checked={!!value}
              onClick={() => onChange(!value)}><i /></button>
    </div>
  );
}

function TweakRadio({ label, value, options, onChange }) {
  const trackRef = React.useRef(null);
  const [dragging, setDragging] = React.useState(false);
  const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
  const idx = Math.max(0, opts.findIndex((o) => o.value === value));
  const n = opts.length;

  // The active value is read by pointer-move handlers attached for the lifetime
  // of a drag—ref it so a stale closure doesn't fire onChange for every move.
  const valueRef = React.useRef(value);
  valueRef.current = value;

  const segAt = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const inner = r.width - 4;
    const i = Math.floor(((clientX - r.left - 2) / inner) * n);
    return opts[Math.max(0, Math.min(n - 1, i))].value;
  };

  const onPointerDown = (e) => {
    setDragging(true);
    const v0 = segAt(e.clientX);
    if (v0 !== valueRef.current) onChange(v0);
    const move = (ev) => {
      if (!trackRef.current) return;
      const v = segAt(ev.clientX);
      if (v !== valueRef.current) onChange(v);
    };
    const up = () => {
      setDragging(false);
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };

  return (
    <TweakRow label={label}>
      <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
           className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
        <div className="twk-seg-thumb"
             style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
                      width: `calc((100% - 4px) / ${n})` }} />
        {opts.map((o) => (
          <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
            {o.label}
          </button>
        ))}
      </div>
    </TweakRow>
  );
}

function TweakSelect({ label, value, options, onChange }) {
  return (
    <TweakRow label={label}>
      <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
        {options.map((o) => {
          const v = typeof o === 'object' ? o.value : o;
          const l = typeof o === 'object' ? o.label : o;
          return <option key={v} value={v}>{l}</option>;
        })}
      </select>
    </TweakRow>
  );
}

function TweakText({ label, value, placeholder, onChange }) {
  return (
    <TweakRow label={label}>
      <input className="twk-field" type="text" value={value} placeholder={placeholder}
             onChange={(e) => onChange(e.target.value)} />
    </TweakRow>
  );
}

function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
  const clamp = (n) => {
    if (min != null && n < min) return min;
    if (max != null && n > max) return max;
    return n;
  };
  const startRef = React.useRef({ x: 0, val: 0 });
  const onScrubStart = (e) => {
    e.preventDefault();
    startRef.current = { x: e.clientX, val: value };
    const decimals = (String(step).split('.')[1] || '').length;
    const move = (ev) => {
      const dx = ev.clientX - startRef.current.x;
      const raw = startRef.current.val + dx * step;
      const snapped = Math.round(raw / step) * step;
      onChange(clamp(Number(snapped.toFixed(decimals))));
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };
  return (
    <div className="twk-num">
      <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
      <input type="number" value={value} min={min} max={max} step={step}
             onChange={(e) => onChange(clamp(Number(e.target.value)))} />
      {unit && <span className="twk-num-unit">{unit}</span>}
    </div>
  );
}

function TweakColor({ label, value, onChange }) {
  return (
    <div className="twk-row twk-row-h">
      <div className="twk-lbl"><span>{label}</span></div>
      <input type="color" className="twk-swatch" value={value}
             onChange={(e) => onChange(e.target.value)} />
    </div>
  );
}

function TweakButton({ label, onClick, secondary = false }) {
  return (
    <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
            onClick={onClick}>{label}</button>
  );
}

// ── SensitivityTester (item 6) ──────────────────────────────────────────────
// Operator-set composite weights (volume/KD/CPC/residual; sum guarded to ~1.0)
// + a live verdict replay against the focused brief. Replays composite via
// the same scoreComponents path verdictFlipThresholds uses. Save / load
// named presets via localStorage `dk-v4-weight-presets`.
//
// Renders nothing if window.weightsFor / scoreComponents are unavailable —
// the util.js agent's helpers may still be in flight.
const SENS_PRESETS_KEY = 'dk-v4-weight-presets';
function _sensLoadPresets() {
  try { const v = JSON.parse(localStorage.getItem(SENS_PRESETS_KEY) || '[]'); return Array.isArray(v) ? v : []; }
  catch { return []; }
}
function _sensSavePresets(arr) {
  try { localStorage.setItem(SENS_PRESETS_KEY, JSON.stringify(arr)); } catch {}
}

function SensitivityTester({ focusedBrief }) {
  const balanced = (typeof window.weightsFor === 'function') ? window.weightsFor('balanced') : null;
  const fallback = balanced || { volume: 0.75, kd: 0.10, cpc: 0.10, residual: 0.05 };
  const [w, setW] = React.useState(fallback);
  const [presets, setPresets] = React.useState(() => _sensLoadPresets());
  React.useEffect(() => { _sensSavePresets(presets); }, [presets]);

  // Recompute focused-brief verdict under operator weights. Mirrors the
  // verdictFlipThresholds replay shape (scoreComponents → composite → cohort
  // re-rank → bucket via VERDICT_THRESHOLDS_RUNTIME).
  const replay = React.useMemo(() => {
    if (!focusedBrief) return { verdict: null, reason: 'no focused brief' };
    const briefs = (typeof window !== 'undefined' && window.BRIEFS) || [];
    if (!briefs.length) return { verdict: null, reason: 'no briefs loaded' };
    const sc = (typeof window.scoreComponents === 'function') ? window.scoreComponents : null;
    if (!sc) return { verdict: null, reason: 'scoreComponents unavailable' };
    const score = (b) => {
      const c = sc(b);
      return (c.volNorm ?? 0) * (w.volume ?? 0) +
             (c.invKdNorm ?? 0) * (w.kd ?? 0) +
             (c.cpcNorm ?? 0) * (w.cpc ?? 0) +
             (c.residualNorm ?? 0) * (w.residual ?? 0);
    };
    const scored = briefs.map(b => ({ id: b.id, score: score(b) }));
    scored.sort((a, b) => b.score - a.score);
    const idx = scored.findIndex(s => s.id === focusedBrief.id);
    if (idx < 0) return { verdict: null, reason: 'brief not in cohort' };
    const total = scored.length;
    const pct = total > 1 ? 1 - (idx / (total - 1)) : 1;
    // Resolve verdict via the shared engine helper so the replay matches the live
    // verdict exactly (advisory-aware hard-skip + TH-trend 'monitor').
    const auth = (typeof window.authorityState === 'function') ? window.authorityState(focusedBrief) : null;
    const skips = (typeof window.hardSkipChecks === 'function' && auth)
      ? window.hardSkipChecks(focusedBrief, auth) : [];
    // A non-advisory hard-skip forces 'skip' independent of thresholds—short-circuit
    // before the VT guard so that state matches the engine even if VT is missing.
    if (skips.find(c => !c.passed && !c.advisory)) return { verdict: 'skip', reason: 'hard auto-skip', pct };
    const VT = (typeof window !== 'undefined' && window.VERDICT_THRESHOLDS_RUNTIME) || null;
    if (!VT) return { verdict: null, reason: 'thresholds unavailable', pct };
    if (typeof window.resolveCohortVerdict === 'function') {
      const r = window.resolveCohortVerdict(pct, skips, focusedBrief);
      return { verdict: r.verdict, reason: r.hardSkipFailed ? 'hard auto-skip' : undefined, pct };
    }
    // Fallback (helper unavailable): cohort cut only. The non-advisory hard-skip
    // was already applied at line ~396, so by here no such check is failing.
    if (pct >= 1 - VT.highOpportunityPct) return { verdict: 'high-opportunity', pct };
    if (pct >= 1 - VT.highOpportunityPct - VT.worthTestingPct) return { verdict: 'worth-testing', pct };
    return { verdict: 'skip', pct };
  }, [w, focusedBrief]);

  const onSlider = (key) => (val) => setW(prev => ({ ...prev, [key]: val }));
  const reset = () => setW(fallback);
  const saveAsPreset = () => {
    const name = (typeof window !== 'undefined' && window.prompt)
      ? window.prompt('Name this weight preset:') : null;
    if (!name) return;
    setPresets(prev => {
      const without = prev.filter(p => p.name !== name);
      return [...without, { name, weights: { ...w } }];
    });
  };
  const loadPreset = (name) => {
    const p = presets.find(x => x.name === name);
    if (p && p.weights) setW({ ...fallback, ...p.weights });
  };
  const deletePreset = (name) => {
    setPresets(prev => prev.filter(p => p.name !== name));
  };

  const verdictLabel = replay.verdict === 'high-opportunity' ? 'High Opportunity'
    : replay.verdict === 'worth-testing' ? 'Worth Testing'
    : replay.verdict === 'monitor' ? 'Monitor'
    : replay.verdict === 'skip' ? 'Skip'
    : '—';
  const verdictClass = replay.verdict ? ('sens-v-' + replay.verdict) : 'sens-v-none';
  const sum = (w.volume + w.kd + w.cpc + w.residual);

  return (
    <>
      <TweakSlider label="Volume weight"   value={Math.round(w.volume*100)/100}   min={0} max={1} step={0.01} onChange={(v)=>onSlider('volume')(v)} />
      <TweakSlider label="KD weight"       value={Math.round(w.kd*100)/100}       min={0} max={1} step={0.01} onChange={(v)=>onSlider('kd')(v)} />
      <TweakSlider label="CPC weight"      value={Math.round(w.cpc*100)/100}      min={0} max={1} step={0.01} onChange={(v)=>onSlider('cpc')(v)} />
      <TweakSlider label="Residual weight" value={Math.round(w.residual*100)/100} min={0} max={1} step={0.01} onChange={(v)=>onSlider('residual')(v)} />
      <div className="twk-row" style={{gap: 4}}>
        <div className="twk-lbl">
          <span>Sum (target ≈ 1.00)</span>
          <span className="twk-val">{sum.toFixed(2)}</span>
        </div>
        <div className={'sens-replay ' + verdictClass}>
          <div className="sens-replay-head">
            <span>Replay verdict</span>
            <span className="sens-replay-meta">{focusedBrief ? (focusedBrief.topic || focusedBrief.id) : '—'}</span>
          </div>
          <div className="sens-replay-body">
            <span className="sens-replay-v">{verdictLabel}</span>
            {replay.pct != null && (
              <span className="sens-replay-pct">pct {(replay.pct*100).toFixed(0)}%</span>
            )}
            {replay.reason && !replay.verdict && (
              <span className="sens-replay-reason">{replay.reason}</span>
            )}
          </div>
        </div>
      </div>
      <div className="twk-row twk-row-h" style={{gap: 6}}>
        <button type="button" className="twk-btn secondary" onClick={reset}>reset balanced</button>
        <button type="button" className="twk-btn" onClick={saveAsPreset}>save as preset</button>
      </div>
      {presets.length > 0 && (
        <div className="twk-row">
          <div className="twk-lbl"><span>Presets</span></div>
          <div className="sens-presets">
            {presets.map(p => (
              <div key={p.name} className="sens-preset-row">
                <button type="button" className="twk-btn secondary sens-preset-load" onClick={() => loadPreset(p.name)}>{p.name}</button>
                <button type="button" className="twk-btn secondary sens-preset-del" title="Delete preset" onClick={() => deletePreset(p.name)}>×</button>
              </div>
            ))}
          </div>
        </div>
      )}
    </>
  );
}

Object.assign(window, {
  useTweaks, TweaksPanel, TweakSection, TweakRow,
  TweakSlider, TweakToggle, TweakRadio, TweakSelect,
  TweakText, TweakNumber, TweakColor, TweakButton,
  SensitivityTester,
});
