// LabsPage — public-facing showcase of the THS scraper + ML pipeline.
//
// Two sections, per the approved plan (see
// /home/dylan/.claude/plans/1-ranked-list-2-federated-owl.md):
//
//   1. LIVE DATA PULSE — inventory strip, 30-day training-growth bar chart,
//      4 rotating fun-fact strings. Fetched live from the labs-pulse edge
//      function. No model internals, no per-listing scores, no PII.
//
//   2. TOOLS PREVIEW — Sequence Planner + Listing Optimizer input cards.
//      UI only; "coming soon / waitlist" on submit. Real ML backends for
//      these live in a future pass.
//
// INPUT SAFETY: v2 tool endpoints will strictly validate user inputs
// (char whitelist, length caps, file type + size, per-IP rate limits,
// content gate on uploads). See the labs-pulse edge-function header for
// the full controls list. This component's UI enforces the client-side
// portion (file count, size, mime type) as a first gate.

const {
  useState: useLabsState,
  useEffect: useLabsEffect,
  useMemo: useLabsMemo,
  useRef: useLabsRef,
  useCallback: useLabsCallback,
} = React;

const LABS_PULSE_URL = `${window.SUPABASE_URL}/functions/v1/labs-pulse`;
const LABS_CACHE_KEY = 'da_labs_pulse_v1';
const LABS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 min — numbers update every ~20min scrape

const LabsPage = () => {
  // Stale-while-revalidate: read last-known-good from localStorage synchronously
  // so the page renders with real numbers immediately, then refresh in the
  // background. The edge function is 5–10s on cold start; without the cache
  // users saw the dashed skeleton for the entire initial fetch. With the
  // cache, the shimmer is almost never visible after the first visit.
  const [data, setData] = useLabsState(() => {
    try {
      const raw = localStorage.getItem(LABS_CACHE_KEY);
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (!parsed || !parsed.cachedAt || Date.now() - parsed.cachedAt > LABS_CACHE_TTL_MS) return null;
      return parsed.data;
    } catch { return null; }
  });
  const [err, setErr] = useLabsState(null);

  useLabsEffect(() => {
    let cancelled = false;
    fetch(LABS_PULSE_URL)
      .then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
      .then(d => {
        if (cancelled) return;
        setData(d);
        try { localStorage.setItem(LABS_CACHE_KEY, JSON.stringify({ cachedAt: Date.now(), data: d })); } catch {}
      })
      .catch(e => { if (!cancelled) setErr(e.message); });
    return () => { cancelled = true; };
  }, []);

  return (
    <main className="labs">
      <LabsPulse data={data} err={err} />
      <LabsTools />
    </main>
  );
};

// ---- Section 1 — data pulse -------------------------------------------------

// Daily slot tied to the cache's 08:00 UTC refresh boundary. Returns
// e.g. '2026-05-01T08'. Same browser within a slot keeps the same
// 4-fact pick; after 08:00 UTC a fresh sample drops.
function currentLabsSlot() {
  const now = new Date();
  const boundary = new Date(now);
  boundary.setUTCHours(8, 0, 0, 0);
  if (now < boundary) boundary.setUTCDate(boundary.getUTCDate() - 1);
  return boundary.toISOString().slice(0, 13);
}

// Pick 4 facts from the full pool, preserving the user's pick across
// page reloads within the daily slot. Backed by localStorage; falls
// back gracefully when storage is unavailable / quota-exceeded.
const FACT_SLOT_KEY = 'labs.fact.picks.v1';
function pickStickyFour(pool) {
  if (!Array.isArray(pool) || pool.length === 0) return [];
  const slot = currentLabsSlot();
  try {
    const stored = JSON.parse(localStorage.getItem(FACT_SLOT_KEY) || 'null');
    if (stored?.slot === slot && Array.isArray(stored.picks)) {
      const found = stored.picks
        .map(k => pool.find(f => f.key === k))
        .filter(Boolean);
      if (found.length === 4) return found;
    }
  } catch {}
  const shuffled = [...pool].sort(() => Math.random() - 0.5);
  const picks = shuffled.slice(0, Math.min(4, shuffled.length));
  try {
    localStorage.setItem(FACT_SLOT_KEY, JSON.stringify({
      slot,
      picks: picks.map(p => p.key),
    }));
  } catch {}
  return picks;
}

const LabsPulse = ({ data, err }) => {
  const inventory = data?.inventory;
  const growth = data?.training_growth || [];
  const factPool = data?.fun_facts || [];
  // Edge fn now returns the full ~18-fact pool. Client samples 4,
  // memoized on the pool identity so switching routes doesn't reroll.
  const facts = useLabsMemo(() => pickStickyFour(factPool), [factPool]);

  return (
    <section className="labs-pulse">
      {inventory?.last_updated && (
        <p className="labs-pulse__updated mono-caps" aria-label="last updated">
          updated {relativeTime(inventory.last_updated)}
        </p>
      )}

      <LabsInventory inventory={inventory} loading={!data && !err} err={err} />
      <LabsGrowth rows={growth} loading={!data && !err} />
      <LabsFacts facts={facts} loading={!data && !err} />
    </section>
  );
};

// Inventory strip — six `VALUE / LABEL` pairs, flex-wrap. Skeleton dashes
// while loading so the layout doesn't jump when numbers arrive.
const LabsInventory = ({ inventory, loading, err }) => {
  const items = [
    { label: 'active sits',     key: 'active_sits' },
    { label: 'training rows',   key: 'training_rows' },
    { label: 'experiment sits', key: 'experiment_sits' },
    { label: 'inactive sits',   key: 'inactive_sits' },
  ];
  return (
    <ul className="labs-inv" aria-label="dataset inventory">
      {items.map(it => {
        const v = inventory?.[it.key];
        const display = loading ? '—' : (err || v == null) ? '—' : v.toLocaleString();
        return (
          <li key={it.key} className="labs-inv__item" data-loading={loading ? 'true' : 'false'}>
            <span className="labs-inv__n">{display}</span>
            <span className="labs-inv__label mono-caps">{it.label}</span>
          </li>
        );
      })}
    </ul>
  );
};

// 30-day bar chart — vertical strokes, one per day. Height is peak-relative.
// Pure CSS (no charting library). Hover reveals exact count.
const LabsGrowth = ({ rows, loading }) => {
  const max = useLabsMemo(() => rows.reduce((m, r) => Math.max(m, r.n), 1), [rows]);
  return (
    <figure className="labs-growth">
      <figcaption className="labs-growth__cap mono-caps">
        training rows added · last 30 days
      </figcaption>
      <div className="labs-growth__chart" role="img" aria-label="30-day training rows added">
        {loading && Array.from({ length: 30 }).map((_, i) => (
          <span
            key={`s${i}`}
            className="labs-growth__bar labs-growth__bar--skeleton"
            style={{ height: `${18 + ((i * 7) % 55)}%`, animationDelay: `${i * 30}ms` }}
          />
        ))}
        {!loading && rows.map((r) => {
          const pct = Math.max(2, Math.round((r.n / max) * 100));
          return (
            <span
              key={r.day}
              className="labs-growth__bar"
              style={{ height: `${pct}%` }}
              title={`${r.day}: ${r.n.toLocaleString()} rows`}
            />
          );
        })}
      </div>
      {!loading && rows.length > 0 && (
        <div className="labs-growth__axis mono-caps" aria-hidden="true">
          <span>{formatDayShort(rows[0]?.day)}</span>
          <span>{formatDayShort(rows[rows.length - 1]?.day)}</span>
        </div>
      )}
    </figure>
  );
};

// Fun facts — vertical list of 4 templated strings. Edge function picks
// which queries returned and templates them; client just renders.
const LabsFacts = ({ facts, loading }) => (
  <ul className="labs-facts" aria-label="data pulse — fun facts">
    {loading && <li className="labs-facts__item mono-caps">loading data pulse…</li>}
    {!loading && facts.map((f, i) => (
      <li key={i} className="labs-facts__item">
        {renderTemplate(f.template, f.tokens)}
      </li>
    ))}
  </ul>
);

// ---- Section 2 — tools preview ---------------------------------------------

const LabsTools = () => (
  <section className="labs-tools">
    <div className="labs-tools__grid">
      <SequencePlannerCard />
      <ListingOptimizerCard />
    </div>
  </section>
);

// Small (i) button that reveals a short tooltip explaining what the tool
// does and how to use it. Keyboard-operable (Enter/Space toggles). The
// tool cards keep almost no copy of their own — this button carries the
// explanation without cluttering the default state.
const InfoButton = ({ label, children }) => {
  const [open, setOpen] = useLabsState(false);
  const rootRef = useLabsRef(null);
  // Dismiss on outside click — but not on the button itself (that's toggle).
  useLabsEffect(() => {
    if (!open) return undefined;
    const onDocClick = (e) => {
      if (rootRef.current && !rootRef.current.contains(e.target)) 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]);
  return (
    <span className="labs-info" ref={rootRef}>
      <button
        type="button"
        className="labs-info__btn"
        aria-expanded={open}
        aria-label={`How ${label} works`}
        onClick={(e) => { e.preventDefault(); setOpen(v => !v); }}
      >
        i
      </button>
      {open && (
        <span className="labs-info__pop" role="note">
          {children}
        </span>
      )}
    </span>
  );
};

// 2a. Sequence Planner — live endpoint: POSTs to labs-search, which runs a
// validated query on active sits and returns the top 5 matches. Not the full
// optimizer (that's a future tick); but real results from the real dataset.
const LABS_SEARCH_URL = `${window.SUPABASE_URL}/functions/v1/labs-search`;

const PLANNER_ERRORS = {
  'bad-date-range':    'Start date has to be before end date.',
  'dates-too-far':     'That window is too wide — try a range of up to 6 months.',
  'rate-limited':      'Hit the hourly limit — wait a few minutes and try again.',
  'bad-input':         'One of the inputs contained unsupported characters.',
  'region-too-long':   'Location text is too long — try a shorter region name.',
  'upstream-error':    'The search service hit an error. Try again in a moment.',
};

const SequencePlannerCard = () => {
  const [form, setForm] = useLabsState({ region: '', start: '', end: '', minDays: '7' });
  const [state, setState] = useLabsState({ phase: 'idle', chains: null, error: null });
  // phase: 'idle' | 'loading' | 'done' | 'error'

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!form.start || !form.end) {
      setState({ phase: 'error', chains: null, error: 'pick a start and end date' });
      return;
    }
    setState({ phase: 'loading', chains: null, error: null });
    try {
      const r = await fetch(LABS_SEARCH_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          region: form.region,
          start: form.start,
          end: form.end,
          min_days: Number(form.minDays) || 7,
          limit: 5,
        }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        const slug = j.error || `http-${r.status}`;
        throw new Error(PLANNER_ERRORS[slug] || j.detail || `Couldn't reach the planner (${slug})`);
      }
      setState({ phase: 'done', chains: j.chains || [], error: null });
    } catch (err) {
      const raw = String(err.message || err);
      const msg = raw.toLowerCase().includes('failed to fetch')
        ? 'Network error — check your connection and try again.'
        : raw;
      setState({ phase: 'error', chains: null, error: msg });
    }
  };

  const reset = () => setState({ phase: 'idle', chains: null, error: null });

  return (
    <form className="labs-tool" onSubmit={handleSubmit}>
      <div className="labs-tool__head">
        <h3 className="labs-tool__h mono-caps">
          Sequence Planner
          <InfoButton label="Sequence Planner">
            Give me a region, a date range, and how short a sit you'll accept.
            I query the live board of active sits and return the top matches
            that fit your window. No login, no tracking.
          </InfoButton>
        </h3>
        <span className="labs-tool__badge mono-caps">live</span>
      </div>
      <label className="labs-tool__row">
        <span className="labs-tool__label mono-caps">where</span>
        <input
          type="text"
          value={form.region}
          onChange={(e) => setForm({ ...form, region: e.target.value.slice(0, 80) })}
          placeholder="city, region, or country"
          className="labs-tool__input"
          maxLength={80}
        />
      </label>
      <label className="labs-tool__row">
        <span className="labs-tool__label mono-caps">when</span>
        <span className="labs-tool__range">
          <input
            type="date"
            value={form.start}
            onChange={(e) => setForm({ ...form, start: e.target.value })}
            className="labs-tool__input labs-tool__input--date"
          />
          <span className="labs-tool__to mono-caps">to</span>
          <input
            type="date"
            value={form.end}
            onChange={(e) => setForm({ ...form, end: e.target.value })}
            className="labs-tool__input labs-tool__input--date"
          />
        </span>
      </label>
      <label className="labs-tool__row">
        <span className="labs-tool__label mono-caps">min days</span>
        <input
          type="number"
          min={1}
          max={60}
          value={form.minDays}
          onChange={(e) => setForm({ ...form, minDays: e.target.value.replace(/[^0-9]/g, '').slice(0, 2) })}
          className="labs-tool__input labs-tool__input--short"
        />
      </label>
      <div className="labs-tool__row labs-tool__row--actions">
        <button type="submit" className="labs-tool__cta" disabled={state.phase === 'loading'}>
          {state.phase === 'loading' ? 'searching…' : 'build my sequence →'}
        </button>
        {state.phase !== 'idle' && (
          <button type="button" className="labs-tool__link mono-caps" onClick={reset}>
            reset
          </button>
        )}
      </div>
      {state.phase === 'error' && (
        <p className="labs-tool__msg mono-caps" role="alert">{state.error}</p>
      )}
      {state.phase === 'done' && (
        <SequenceResults chains={state.chains} region={form.region} />
      )}
    </form>
  );
};

// Indicator chip vocabulary — slug → { label, tone }.
// Tones: 'positive' (default), 'neutral' (muted), 'warning' (attention).
// Asterisk prefix on label = ML-derived (not a fact about the chain shape).
const SP_INDICATORS = {
  perfect_chain:        { label: 'perfect chain',          tone: 'positive' },
  long_sits:            { label: 'long sits',              tone: 'positive' },
  tight_cluster:        { label: 'tight cluster',          tone: 'positive' },
  weekend_gaps_only:    { label: 'weekend gaps only',      tone: 'positive' },
  quick_start:          { label: 'quick start',            tone: 'positive' },
  gaps_likely_to_fill:  { label: '*gaps likely to fill',   tone: 'positive' },
  single_region:        { label: 'single region',          tone: 'neutral'  },
  multi_region:         { label: 'multi-region',           tone: 'neutral'  },
  long_gap:             { label: 'long gap',               tone: 'warning'  },
  far_flung:            { label: 'far-flung',              tone: 'warning'  },
  risky_gaps:           { label: '*risky gaps',            tone: 'warning'  },
};

// Inline chain results. Each chain renders as: meta line + chip row + vertical
// timeline of sits. Score is hidden — it determined the order, that's all.
const SequenceResults = ({ chains, region }) => {
  if (!chains?.length) {
    return (
      <div className="labs-results labs-results--empty">
        <p className="mono-caps">
          no chains {region ? `in ${region}` : ''} for that window.
          widen the dates or drop the minimum days.
        </p>
      </div>
    );
  }
  return (
    <div className="labs-results labs-chains" aria-label="sequence chains">
      <p className="labs-results__cap mono-caps">
        {chains.length} chain{chains.length === 1 ? '' : 's'} from the live board
      </p>
      <ul className="labs-chains__list">
        {chains.map((c, i) => (
          <SequenceChain key={i} chain={c} />
        ))}
      </ul>
      <p className="labs-results__footnote mono-caps">
        sourced from the live trustedhousesitters board · updated every ~20 min ·
        <span className="labs-chains__asterisk-note"> *prediction from arrival-rate model</span>
      </p>
    </div>
  );
};

const SequenceChain = ({ chain }) => {
  const sits = chain.sits || [];
  if (!sits.length) return null;
  const first = sits[0];
  const last = sits[sits.length - 1];
  const indicators = (chain.indicators || []).filter(s => SP_INDICATORS[s]);

  return (
    <li className="labs-chain">
      <div className="labs-chain__head">
        <span className="labs-chain__sits-count mono-caps">
          {sits.length} sit{sits.length === 1 ? '' : 's'}
        </span>
        <span className="labs-chain__coverage mono-caps">
          {chain.total_coverage_days}d covered
          {chain.total_gap_days > 0 && ` · ${chain.total_gap_days}d gap`}
        </span>
        <span className="labs-chain__range mono-caps">
          {formatDayShort(first.start)} → {formatDayShort(last.end)}
        </span>
      </div>
      {indicators.length > 0 && (
        <ul className="labs-chain__chips" aria-label="indicators">
          {indicators.map(slug => {
            const def = SP_INDICATORS[slug];
            return (
              <li key={slug} className={`labs-chip labs-chip--${def.tone}`}>
                {def.label}
              </li>
            );
          })}
        </ul>
      )}
      <ol className="labs-chain__timeline">
        {sits.map((s, i) => (
          <li key={i} className="labs-chain__stop">
            <span className="labs-chain__city">{s.city || '—'}</span>
            <span className="labs-chain__stop-meta mono-caps">
              {formatDayShort(s.start)} → {formatDayShort(s.end)} · {s.duration}d
              {s.hometype ? ` · ${s.hometype}` : ''}
            </span>
          </li>
        ))}
      </ol>
    </li>
  );
};

// 2b. Listing Optimizer — drag-drop photos, get a projected listing score.
// Live endpoint: POSTs FormData to labs-optimize edge fn, which SigV4-signs
// forward to the inference Lambda (CLIP ONNX + home_quality sklearn model).
// Client enforces file count / size / mime as a first gate; edge + Lambda
// re-validate (magic-byte sniff, decompression-bomb guard) before scoring.
const MAX_FILES = 6;
const MAX_BYTES = 4 * 1024 * 1024; // 4 MB
const ALLOWED_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const LABS_OPTIMIZE_URL = `${window.SUPABASE_URL}/functions/v1/labs-optimize`;

// Map machine error codes → human explanations. The edge function + Lambda
// return short slugs for compactness; the UI translates before showing.
const OPT_ERRORS = {
  'bad-mime':              'That file type isn\'t supported. Use JPEG, PNG, or WebP.',
  'mime-mismatch':         'The file\'s contents don\'t match its extension. Try re-saving it as JPEG.',
  'file-too-large':        'One of your files is over 4 MB. Try a smaller export.',
  'payload-too-large':     'Total upload is over 25 MB. Try fewer or smaller photos.',
  'too-many-files':        `Too many photos — upload up to ${MAX_FILES} at a time.`,
  'no-files':              'Add at least one photo before submitting.',
  'rate-limited':          'You\'ve hit the hourly limit. Try again in about an hour.',
  'daily-cap-reached':     'The free daily budget is used up. Check back tomorrow.',
  'bad-multipart':         'Upload got corrupted in transit — try again.',
  'text-chars':            'Title or description contained unsupported characters.',
  'text-control-chars':    'Title or description contained hidden control characters.',
  'title-too-long':        'Title is too long (max 120 characters).',
  'description-too-long':  'Description is too long (max 2000 characters).',
  'upstream-auth-failed':  'The scoring service can\'t authenticate right now. Dylan\'s been notified.',
  'upstream-timeout':      'The scoring service is slow — give it 30 seconds and try again.',
  'upstream-unavailable':  'The scoring service is temporarily offline. Try again soon.',
  'upstream-error':        'The scoring service hit an error. Try again in a moment.',
  'upstream-bad-json':     'Got an unexpected response from the scoring service.',
  'misconfigured':         'The scoring endpoint isn\'t configured. Dylan\'s been notified.',
};
function humanizeOptError(slug, detail) {
  const base = OPT_ERRORS[slug] || `Error: ${slug || 'unknown'}`;
  return detail ? `${base} (${detail})` : base;
}

// Sniff magic bytes on the first 12 bytes of a file blob. Returns the
// canonical mime string if recognized, or null otherwise. We use this before
// upload so a JPEG renamed to .png fails with a clear message, and before
// the server-side check runs. Mirrors the server-side check in
// supabase/functions/labs-optimize/index.ts:magicBytesMatch.
async function sniffImageMime(file) {
  try {
    const slice = await file.slice(0, 12).arrayBuffer();
    const b = new Uint8Array(slice);
    if (b.length < 4) return null;
    if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return 'image/jpeg';
    if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return 'image/png';
    if (b.length >= 12 &&
        b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
        b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp';
    return null;
  } catch {
    return null;
  }
}

const ListingOptimizerCard = () => {
  const [files, setFiles] = useLabsState([]);
  const [msg, setMsg] = useLabsState('');
  const [state, setState] = useLabsState({ phase: 'idle', result: null, error: null });
  // phase: 'idle' | 'loading' | 'done' | 'error'
  const inputRef = useLabsRef(null);

  const addFiles = useLabsCallback(async (incoming) => {
    const accepted = [];
    const rejected = [];
    for (const f of incoming) {
      if (!ALLOWED_MIMES.has(f.type)) {
        rejected.push(`${f.name}: not a JPEG/PNG/WebP`);
        continue;
      }
      if (f.size > MAX_BYTES) {
        rejected.push(`${f.name}: over 4MB`);
        continue;
      }
      // Magic-byte check — catches e.g. HEIC that Safari labeled image/png,
      // or an actual JPEG renamed to .png. Mirrors server-side logic so the
      // failure is caught before network round-trip.
      const sniffed = await sniffImageMime(f);
      if (!sniffed) {
        rejected.push(`${f.name}: unreadable — not a valid image`);
        continue;
      }
      if (sniffed !== f.type) {
        rejected.push(`${f.name}: contents look like ${sniffed.replace('image/', '').toUpperCase()} — re-save with the matching extension`);
        continue;
      }
      accepted.push(f);
    }
    setFiles((prev) => {
      const merged = [...prev, ...accepted].slice(0, MAX_FILES);
      if (merged.length + rejected.length < prev.length + incoming.length) {
        rejected.push(`max ${MAX_FILES} photos per submission`);
      }
      return merged;
    });
    setMsg(rejected.length ? rejected.join('; ') : '');
  }, []);

  const onDrop = (e) => {
    e.preventDefault();
    addFiles(Array.from(e.dataTransfer.files || []));
  };
  const onPick = (e) => addFiles(Array.from(e.target.files || []));
  const removeAt = (i) => setFiles((prev) => prev.filter((_, idx) => idx !== i));
  const clearAll = () => {
    setFiles([]); setMsg('');
    setState({ phase: 'idle', result: null, error: null });
  };

  const onSubmit = async (e) => {
    e.preventDefault();
    if (files.length === 0) return;
    setState({ phase: 'loading', result: null, error: null });
    try {
      const fd = new FormData();
      files.forEach((f) => fd.append('files', f, f.name));
      const r = await fetch(LABS_OPTIMIZE_URL, { method: 'POST', body: fd });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        throw new Error(humanizeOptError(j.error || `http-${r.status}`, j.detail));
      }
      setState({ phase: 'done', result: j, error: null });
    } catch (err) {
      // Network-level failure (CORS, offline, DNS) surfaces here before we
      // ever get an error slug. Keep the raw message — it's usually enough
      // to distinguish "offline" from "server rejected".
      const raw = String(err.message || err);
      const msg = raw.toLowerCase().includes('failed to fetch')
        ? 'Network error — check your connection and try again.'
        : raw;
      setState({ phase: 'error', result: null, error: msg });
    }
  };

  return (
    <form
      className="labs-tool"
      onSubmit={onSubmit}
      onDragOver={(e) => e.preventDefault()}
      onDrop={onDrop}
    >
      <div className="labs-tool__head">
        <h3 className="labs-tool__h mono-caps">
          Listing Optimizer
          <InfoButton label="Listing Optimizer">
            Drop 1–6 photos of your home (jpeg / png / webp, up to 4 MB each).
            You get back a projected home-quality score and 2–3 notes on what
            would lift it. No THS account required; photos aren't stored —
            they're discarded after scoring.
          </InfoButton>
        </h3>
        <span className="labs-tool__badge mono-caps">live</span>
      </div>

      <label className="labs-drop" aria-label="upload photos">
        <input
          ref={inputRef}
          type="file"
          accept="image/jpeg,image/png,image/webp"
          multiple
          className="labs-drop__input"
          onChange={onPick}
          disabled={state.phase === 'loading'}
        />
        <span className="mono-caps labs-drop__hint">
          {files.length === 0
            ? 'drag images here · or click to choose'
            : `${files.length} / ${MAX_FILES} selected`}
        </span>
      </label>

      {files.length > 0 && (
        <ul className="labs-drop__preview">
          {files.map((f, i) => (
            <LabsThumb key={i} file={f} onRemove={() => removeAt(i)} />
          ))}
        </ul>
      )}

      {msg && <p className="labs-tool__msg mono-caps" role="alert">{msg}</p>}
      <div className="labs-tool__row labs-tool__row--actions">
        <button
          type="submit"
          className="labs-tool__cta"
          disabled={files.length === 0 || state.phase === 'loading'}
        >
          {state.phase === 'loading' ? 'scoring…' : 'score my listing →'}
        </button>
        {(files.length > 0 || state.phase !== 'idle') && (
          <button type="button" className="labs-tool__link mono-caps" onClick={clearAll}>
            clear
          </button>
        )}
      </div>
      {state.phase === 'error' && (
        <p className="labs-tool__msg mono-caps" role="alert">{state.error}</p>
      )}
      {state.phase === 'done' && <OptimizerResults result={state.result} />}
    </form>
  );
};

// Render the Lambda response in the card — either the gate-fail explanation
// or the score + insights. Mono 13px meta, serif for the primary datum.
const OptimizerResults = ({ result }) => {
  if (!result) return null;
  if (result.content_gate === 'fail') {
    return (
      <div className="labs-results labs-results--empty">
        <p className="mono-caps">
          {result.fail_reason || "photos didn't read as a home — try interior / exterior shots."}
        </p>
      </div>
    );
  }
  const scoreDisplay = Number.isFinite(result.score) ? `${Math.round(result.score)}` : '—';
  return (
    <div className="labs-results labs-optimizer-results" aria-label="listing score">
      <div className="labs-optimizer-results__score">
        <span className="labs-optimizer-results__n">{scoreDisplay}</span>
        <span className="labs-optimizer-results__label mono-caps">home-quality score · 0–100</span>
      </div>
      {result.insights && result.insights.length > 0 && (
        <ul className="labs-optimizer-results__insights">
          {result.insights.map((s, i) => (
            <li key={i} className="labs-optimizer-results__insight">{s}</li>
          ))}
        </ul>
      )}
      <p className="labs-results__footnote mono-caps">
        photos discarded after scoring · no retained uploads
      </p>
    </div>
  );
};

const LabsThumb = ({ file, onRemove }) => {
  const [url, setUrl] = useLabsState('');
  useLabsEffect(() => {
    const u = URL.createObjectURL(file);
    setUrl(u);
    return () => URL.revokeObjectURL(u);
  }, [file]);
  return (
    <li className="labs-drop__thumb">
      {url && <img src={url} alt="" />}
      <button type="button" className="labs-drop__x" onClick={onRemove} aria-label="remove">×</button>
    </li>
  );
};

// ---- helpers ----------------------------------------------------------------

function renderTemplate(template, tokens) {
  if (!template) return '';
  return template.replace(/\{(\w+)\}/g, (_, k) => {
    const v = tokens?.[k];
    if (v == null) return '';
    if (typeof v === 'number') return v.toLocaleString();
    return String(v);
  });
}

function formatDayShort(iso) {
  if (!iso) return '';
  const d = new Date(iso + 'T00:00:00');
  if (Number.isNaN(d.getTime())) return '';
  return d.toLocaleString('en', { month: 'short', day: 'numeric' }).toLowerCase();
}

function relativeTime(iso) {
  if (!iso) return '';
  const then = new Date(iso).getTime();
  if (Number.isNaN(then)) return '';
  const diff = Date.now() - then;
  const mins = Math.round(diff / 60000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins} min ago`;
  const hrs = Math.round(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  const days = Math.round(hrs / 24);
  return `${days}d ago`;
}

window.LabsPage = LabsPage;
