// screens-interview.jsx — LiveInterviewScreen, CompletedScreen
// Uses LiveKit JS SDK for server-side STT/TTS/LLM — no browser SpeechRecognition
const { useState: useStateL, useEffect: useEffectL, useRef: useRefL, useCallback: useCallbackL } = React;

// This app is deployed on its own domain (interview.reclova.com, a static
// Vercel deployment), separate from the FastAPI backend (AWS) — so it can no
// longer assume same-origin like it could when the backend itself served
// these files via /interview-static. window.BACKEND_URL lets it be overridden
// per-environment (e.g. injected via a small <script> in the HTML shell);
// falls back to the known production backend URL, or localhost for local dev.
const API_BASE = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
  ? `${window.location.protocol}//${window.location.hostname}:8000`
  : (window.BACKEND_URL || 'https://recruiter-api.reclova.com');

function fmt(seconds) {
  const m = String(Math.floor(seconds / 60)).padStart(2, '0');
  const s = String(seconds % 60).padStart(2, '0');
  return `${m}:${s}`;
}

// ── LiveKit room manager ─────────────────────────────────────────────────────
// All room state in refs so nothing triggers re-connects on re-render.
// Connection states: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'ended'

const RECONNECT_MAX_ATTEMPTS = 4;
const RECONNECT_GIVE_UP_SECS = 45;

function useLiveKitRoom() {
  const roomRef    = useRefL(null);  // LivekitClient.Room instance
  const lkUrlRef   = useRefL(null);  // stored for reconnect
  const lkTokenRef = useRefL(null);  // stored for reconnect (refetch on each attempt)
  const interviewTokenRef = useRefL(null); // backend interview token for re-fetching LK token
  const reconnectAttemptsRef = useRefL(0);
  const reconnectTimerRef    = useRefL(null);
  const giveUpTimerRef       = useRefL(null);
  const intentionalEndRef    = useRefL(false); // true when we called disconnect() ourselves

  // agentSpeaking (exposed to the UI) is the OR of two independent signals, not a single
  // value one source can blindly overwrite:
  //  - audioActiveRef: raw LiveKit ActiveSpeakersChanged (real audio energy) — flickers
  //    false during ordinary mid-sentence pauses even though the AI's turn isn't over.
  //  - turnStreamingRef: true from the first agent_delta of a turn until agent_done for
  //    that turn arrives — the backend's own "this turn is fully generated" signal.
  // Driving the UI off audioActive alone was the bug: "Listening" would flash on screen
  // mid-utterance during a natural pause, before the AI had actually finished talking.
  const audioActiveRef   = useRefL(false);
  const turnStreamingRef = useRefL(false);

  const [turns, setTurns]                 = useStateL([]);
  const [agentSpeaking, setAgentSpeaking] = useStateL(false);
  const _recomputeSpeaking = () => setAgentSpeaking(audioActiveRef.current || turnStreamingRef.current);
  const [connState, setConnState]         = useStateL('idle'); // 'idle'|'connecting'|'connected'|'reconnecting'|'failed'|'ended'
  const [liveInterim, setLiveInterim]     = useStateL('');
  const [roomError, setRoomError]         = useStateL(null);
  const [reconnectCountdown, setReconnectCountdown] = useStateL(RECONNECT_GIVE_UP_SECS);
  const [progress, setProgress]           = useStateL(null); // { question, total, phase, elapsed_minutes, ... }
  const [thinkingMode, setThinkingMode]   = useStateL(false);
  const [agentError, setAgentError]       = useStateL(null);
  const [aiAudioStream, setAiAudioStream] = useStateL(null); // raw MediaStream of the agent's voice, for real amplitude

  const onCompleteCbRef    = useRefL(null);
  const onConnFailCbRef    = useRefL(null); // called if all reconnect attempts exhaust

  const _clearReconnectTimers = () => {
    if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
    if (giveUpTimerRef.current) { clearInterval(giveUpTimerRef.current); giveUpTimerRef.current = null; }
  };

  const _attachRoomListeners = (room) => {
    // Play agent audio automatically
    room.on(LivekitClient.RoomEvent.TrackSubscribed, (track) => {
      if (track.kind === LivekitClient.Track.Kind.Audio) {
        const el = track.attach();
        el.setAttribute('data-lk-audio', '1');
        el.style.display = 'none';
        document.body.appendChild(el);
        // Expose the raw MediaStreamTrack so the AI orb can be driven by REAL amplitude
        // (via useMicLevel's AnalyserNode) instead of a fixed-cadence CSS loop that pulses
        // the same way regardless of what's actually being said.
        try {
          if (track.mediaStreamTrack) setAiAudioStream(new MediaStream([track.mediaStreamTrack]));
        } catch (e) {}
      }
    });

    room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track) => {
      if (track.kind === LivekitClient.Track.Kind.Audio) {
        document.querySelectorAll('audio[data-lk-audio]').forEach(el => { try { el.srcObject = null; el.remove(); } catch(e){} });
        setAiAudioStream(null);
      }
    });

    room.on(LivekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
      const localId = room.localParticipant?.identity || '';
      audioActiveRef.current = speakers.some(s => s.identity !== localId && speakers.length > 0);
      _recomputeSpeaking();
    });

    // ── Reconnection events ──
    room.on(LivekitClient.RoomEvent.Reconnecting, () => {
      if (intentionalEndRef.current) return;
      setConnState('reconnecting');
      audioActiveRef.current = false;
      turnStreamingRef.current = false;
      _recomputeSpeaking();
    });

    room.on(LivekitClient.RoomEvent.Reconnected, () => {
      if (intentionalEndRef.current) return;
      _clearReconnectTimers();
      reconnectAttemptsRef.current = 0;
      setConnState('connected');
      setRoomError(null);
      // Re-enable mic after reconnect
      try { room.localParticipant.setMicrophoneEnabled(true); } catch(_) {}
    });

    room.on(LivekitClient.RoomEvent.Disconnected, (reason) => {
      if (intentionalEndRef.current) return;
      setConnState('reconnecting');
      audioActiveRef.current = false;
      turnStreamingRef.current = false;
      _recomputeSpeaking();
      _scheduleReconnect();
    });

    room.on(LivekitClient.RoomEvent.DataReceived, (payload) => {
      try {
        const text = new TextDecoder().decode(payload);
        const msg = JSON.parse(text);

        if (msg.type === 'agent_response' && msg.text) {
          // Greeting / silence-prompt only (see voice_pipeline.py _greet /
          // _start_silence_timer) — these are one-shot, non-streamed, and by the time
          // this message arrives the TTS audio has already fully finished playing, so
          // clearing speaking state immediately here is accurate, not a guess.
          const clean = msg.text.replace(/\[INTERVIEW_COMPLETE\]/g, '').trim();
          if (clean) setTurns(t => [...t, { who: 'ai', text: clean }]);
          audioActiveRef.current = false;
          turnStreamingRef.current = false;
          _recomputeSpeaking();
          if (msg.text.includes('[INTERVIEW_COMPLETE]')) {
            setTimeout(() => { if (onCompleteCbRef.current) onCompleteCbRef.current(); }, 2500);
          }
        }
        if (msg.type === 'agent_delta' && msg.text) {
          // Streaming turn (normal conversational responses, not the greeting) — force
          // "speaking" true the moment text starts arriving. Real audio energy
          // (audioActiveRef) alone flickers false during ordinary mid-sentence pauses,
          // which used to flash "Listening" on screen while the AI was still mid-turn.
          turnStreamingRef.current = true;
          _recomputeSpeaking();
          const deltaClean = msg.text.replace(/\[INTERVIEW_COMPLETE\]/g, '');
          if (deltaClean) {
            setTurns(t => {
              if (t.length && t[t.length - 1].who === 'ai') {
                const c = [...t];
                c[c.length - 1] = { who: 'ai', text: c[c.length - 1].text + deltaClean };
                return c;
              }
              return [...t, { who: 'ai', text: deltaClean }];
            });
          }
        }
        if (msg.type === 'agent_done') {
          // Backend's authoritative "this turn's full response has been generated and
          // sent" signal — previously unhandled entirely on the frontend. Clears the
          // streaming flag (only now is it accurate to show "Listening") and replaces
          // the accumulated-from-deltas bubble with the exact final text, so any
          // delta-ordering hiccup can't leave the transcript slightly wrong.
          turnStreamingRef.current = false;
          _recomputeSpeaking();
          const finalClean = (msg.text || '').replace(/\[INTERVIEW_COMPLETE\]/g, '').trim();
          if (finalClean) {
            setTurns(t => {
              if (t.length && t[t.length - 1].who === 'ai') {
                const c = [...t];
                c[c.length - 1] = { who: 'ai', text: finalClean };
                return c;
              }
              return [...t, { who: 'ai', text: finalClean }];
            });
          }
        }
        if (msg.type === 'user_interim' && msg.text) {
          setLiveInterim(msg.text);
        }
        if (msg.type === 'user_transcript' && msg.text) {
          setLiveInterim('');
          setTurns(t => [...t, { who: 'you', text: msg.text }]);
        }
        if (msg.type === 'call_ended') {
          setTimeout(() => { if (onCompleteCbRef.current) onCompleteCbRef.current(); }, 1500);
        }
        if (msg.type === 'interview_progress' && msg.text) {
          try { setProgress(JSON.parse(msg.text)); } catch(_) {}
        }
        if (msg.type === 'thinking_mode' && msg.text) {
          try { setThinkingMode(!!JSON.parse(msg.text).active); } catch(_) {}
        }
        if (msg.type === 'agent_error') {
          setAgentError('The AI interviewer hit a technical issue — if it doesn\'t recover in a few seconds, try repeating your last answer.');
          setTimeout(() => setAgentError(null), 8000);
        }
      } catch(e) {}
    });
  };

  const _scheduleReconnect = () => {
    _clearReconnectTimers();
    reconnectAttemptsRef.current += 1;
    if (reconnectAttemptsRef.current > RECONNECT_MAX_ATTEMPTS) {
      setConnState('failed');
      setRoomError('Could not reconnect after multiple attempts.');
      if (onConnFailCbRef.current) onConnFailCbRef.current();
      return;
    }

    // Countdown timer shown in UI
    let secsLeft = RECONNECT_GIVE_UP_SECS;
    setReconnectCountdown(secsLeft);
    giveUpTimerRef.current = setInterval(() => {
      secsLeft -= 1;
      setReconnectCountdown(secsLeft);
      if (secsLeft <= 0) {
        _clearReconnectTimers();
        setConnState('failed');
        setRoomError('Connection lost and could not be restored.');
        if (onConnFailCbRef.current) onConnFailCbRef.current();
      }
    }, 1000);

    // Attempt reconnect: fetch a fresh LK token then reconnect
    const delay = Math.min(3000 * reconnectAttemptsRef.current, 10000);
    reconnectTimerRef.current = setTimeout(async () => {
      if (intentionalEndRef.current) return;
      try {
        // Fetch a fresh token — old one may have expired
        let url = lkUrlRef.current;
        let token = lkTokenRef.current;
        if (interviewTokenRef.current) {
          const resp = await fetch(`${API_BASE}/api/v1/public/interview/${interviewTokenRef.current}/livekit-token`, { method: 'POST' });
          if (resp.ok) {
            const d = await resp.json();
            if (d.token && d.url) { url = d.url; token = d.token; lkTokenRef.current = d.token; }
          }
        }

        if (roomRef.current) {
          // Try the room's built-in reconnect first
          await roomRef.current.connect(url, token);
          await roomRef.current.localParticipant.setMicrophoneEnabled(true);
          _clearReconnectTimers();
          reconnectAttemptsRef.current = 0;
          setConnState('connected');
          setRoomError(null);
        }
      } catch(e) {
        // Retry will be triggered again by the Disconnected event
        if (!intentionalEndRef.current) _scheduleReconnect();
      }
    }, delay);
  };

  const connect = useCallbackL(async (url, token, onComplete, onConnFail, interviewToken, priorTurns) => {
    if (typeof LivekitClient === 'undefined') {
      setRoomError('LiveKit SDK not loaded. Check your internet connection.');
      setConnState('failed');
      return;
    }
    if (roomRef.current) return; // already connected

    onCompleteCbRef.current = onComplete;
    onConnFailCbRef.current = onConnFail;
    lkUrlRef.current = url;
    lkTokenRef.current = token;
    interviewTokenRef.current = interviewToken || null;
    intentionalEndRef.current = false;
    setConnState('connecting');

    // Pre-populate transcript with previous turns when resuming
    if (priorTurns && priorTurns.length > 0) {
      // Add a separator marker so the UI can visually split history from new turns
      setTurns([...priorTurns, { who: 'system', text: 'Connection resumed — continuing from where you left off' }]);
    }

    const room = new LivekitClient.Room({
      adaptiveStream: true,
      dynacast: true,
      reconnectPolicy: { maxRetryDelay: 10000, minRetryDelay: 1000, retryAttempts: 4 },
      audioCaptureDefaults: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
    });
    roomRef.current = room;
    _attachRoomListeners(room);

    try {
      await room.connect(url, token);
      await room.localParticipant.setMicrophoneEnabled(true);
      setConnState('connected');
      setRoomError(null);
    } catch(e) {
      setRoomError(`Could not connect to voice session: ${e.message}`);
      setConnState('failed');
      roomRef.current = null;
    }
  }, []);

  const disconnect = useCallbackL(() => {
    intentionalEndRef.current = true;
    _clearReconnectTimers();
    onCompleteCbRef.current = null;
    if (roomRef.current) {
      try { roomRef.current.disconnect(); } catch(e) {}
      roomRef.current = null;
    }
    document.querySelectorAll('audio[data-lk-audio]').forEach(el => { try { el.srcObject = null; el.remove(); } catch(e){} });
    setConnState('ended');
    setAgentSpeaking(false);
  }, []);

  const setMic = useCallbackL((enabled) => {
    if (roomRef.current?.localParticipant) {
      roomRef.current.localParticipant.setMicrophoneEnabled(enabled).catch(() => {});
    }
  }, []);

  const connected = connState === 'connected';
  const reconnecting = connState === 'reconnecting';

  return { turns, agentSpeaking, connected, reconnecting, connState, reconnectCountdown, liveInterim, roomError, progress, thinkingMode, agentError, aiAudioStream, connect, disconnect, setMic };
}

// ── main screen ──────────────────────────────────────────────────────────────

function LiveInterviewScreen({ go, cfg, onComplete, getLkToken }) {
  const { stream } = useMediaStream(true);
  const videoRef = useVideoRef(stream);

  // Use refs for values used inside async callbacks to avoid stale closures
  const secondsRef  = useRefL(0);
  const flagsRef    = useRefL(0);
  const endingRef   = useRefL(false); // prevents double-submit

  const [seconds, setSeconds]     = useStateL(0);
  const [muted, setMuted]         = useStateL(false);
  const [flags, setFlags]         = useStateL(0);
  const [tabWarn, setTabWarn]     = useStateL(false);
  const [showEnd, setShowEnd]     = useStateL(false);
  const [submitting, setSubmitting] = useStateL(false);
  const [lkConnecting, setLkConnecting] = useStateL(true);
  const [lkFetchError, setLkFetchError] = useStateL(null);

  // Keep refs in sync
  useEffectL(() => { secondsRef.current = seconds; }, [seconds]);
  useEffectL(() => { flagsRef.current = flags; }, [flags]);

  // Timer
  useEffectL(() => {
    const iv = setInterval(() => setSeconds(s => s + 1), 1000);
    return () => clearInterval(iv);
  }, []);

  // Integrity signals — previously these only ever updated local React state (shown to the
  // candidate as a scary-sounding warning) but never reached the backend, so "this will be
  // flagged for the hiring team" was not actually true. Now every signal is POSTed live so
  // it shows up on the recruiter-facing interview report.
  const reportFlag = useCallbackL((type, detail) => {
    if (!cfg.interviewToken) return;
    fetch(`${API_BASE}/api/v1/public/interview/${cfg.interviewToken}/integrity-flag`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ type, detail: detail || '' }),
      keepalive: true,
    }).catch(() => {});
  }, [cfg.interviewToken]);

  useEffectL(() => {
    const onVisibility = () => {
      if (document.hidden && !endingRef.current) {
        setFlags(f => f + 1);
        setTabWarn(true);
        reportFlag('tab_hidden');
      }
    };
    const onBlur = () => { if (!endingRef.current) reportFlag('window_blur'); };
    const onFullscreenChange = () => {
      if (!document.fullscreenElement && !endingRef.current) reportFlag('fullscreen_exit');
    };
    const onCopy = () => { if (!endingRef.current) reportFlag('copy'); };
    const onPaste = () => { if (!endingRef.current) reportFlag('paste'); };
    document.addEventListener('visibilitychange', onVisibility);
    window.addEventListener('blur', onBlur);
    document.addEventListener('fullscreenchange', onFullscreenChange);
    document.addEventListener('copy', onCopy);
    document.addEventListener('paste', onPaste);
    return () => {
      document.removeEventListener('visibilitychange', onVisibility);
      window.removeEventListener('blur', onBlur);
      document.removeEventListener('fullscreenchange', onFullscreenChange);
      document.removeEventListener('copy', onCopy);
      document.removeEventListener('paste', onPaste);
    };
  }, [reportFlag]);

  // ── Select / right-click / cut blocking ──────────────────────────────────────
  // Unlike copy/paste above (reported but not prevented, since a candidate pasting
  // into e.g. a code-answer field can be legitimate), text selection and the context
  // menu have no legitimate use during a live interview — actually block them, not
  // just log them. Throttle the flag POST per type so a candidate mashing right-click
  // can't blow through the 200-flag cap on its own.
  const lastBlockReportRef = useRefL({});
  useEffectL(() => {
    const throttledReport = (type) => {
      const now = Date.now();
      const last = lastBlockReportRef.current[type] || 0;
      if (now - last > 10000) {
        lastBlockReportRef.current[type] = now;
        reportFlag(type);
      }
    };
    const onSelectStart = (e) => { e.preventDefault(); if (!endingRef.current) throttledReport('select_blocked'); };
    const onContextMenu = (e) => { e.preventDefault(); if (!endingRef.current) throttledReport('context_menu_blocked'); };
    const onCut = (e) => { e.preventDefault(); if (!endingRef.current) throttledReport('select_blocked'); };
    document.addEventListener('selectstart', onSelectStart);
    document.addEventListener('contextmenu', onContextMenu);
    document.addEventListener('cut', onCut);
    return () => {
      document.removeEventListener('selectstart', onSelectStart);
      document.removeEventListener('contextmenu', onContextMenu);
      document.removeEventListener('cut', onCut);
    };
  }, [reportFlag]);

  // ── Screenshot deterrent ──────────────────────────────────────────────────────
  // Honesty note: a browser tab has no API to detect or block an OS-level screenshot
  // (macOS Cmd+Shift+3/4/5, most Linux tools) — nothing runs in-page before or during
  // that capture. What IS reliably catchable is the Windows/ChromeOS PrintScreen key
  // (and Win+Shift+S, which also fires it), so on those platforms we black the screen
  // out fast enough to spoil the frame. On macOS this only catches the key event for
  // logging (Cmd+Shift+3/4/5 never reaches the page at all) — it's a deterrent and an
  // evidence trail, not a guarantee, and the report must never claim otherwise.
  const [screenBlackout, setScreenBlackout] = useStateL(false);
  useEffectL(() => {
    const triggerBlackout = () => {
      if (endingRef.current) return;
      setScreenBlackout(true);
      reportFlag('screenshot_attempt');
      setTimeout(() => setScreenBlackout(false), 2500);
    };
    const onKeyUp = (e) => {
      if (e.key === 'PrintScreen') triggerBlackout();
    };
    const onKeyDown = (e) => {
      // macOS shortcuts never reach page JS, but Win+Shift+S (Windows Snipping Tool) does.
      if (e.metaKey && e.shiftKey && ['3', '4', '5'].includes(e.key)) triggerBlackout();
      if (e.key === 's' && e.shiftKey && (e.metaKey || e.getModifierState('OS'))) triggerBlackout();
    };
    document.addEventListener('keyup', onKeyUp);
    document.addEventListener('keydown', onKeyDown);
    return () => {
      document.removeEventListener('keyup', onKeyUp);
      document.removeEventListener('keydown', onKeyDown);
    };
  }, [reportFlag]);

  // ── DevTools-open heuristic ───────────────────────────────────────────────────
  // No reliable direct API; the size-delta heuristic (a docked DevTools pane shrinks
  // the viewport relative to the outer window) has false positives (e.g. a real
  // browser toolbar/sidebar) so this is reported as a probability signal, weighted
  // accordingly in integrity.py, not treated as certain.
  const devtoolsOpenRef = useRefL(false);
  useEffectL(() => {
    const THRESHOLD = 170;
    const check = () => {
      const widthDelta = window.outerWidth - window.innerWidth;
      const heightDelta = window.outerHeight - window.innerHeight;
      const open = widthDelta > THRESHOLD || heightDelta > THRESHOLD;
      if (open && !devtoolsOpenRef.current && !endingRef.current) {
        devtoolsOpenRef.current = true;
        reportFlag('devtools_suspected');
      } else if (!open) {
        devtoolsOpenRef.current = false;
      }
    };
    check();
    const interval = setInterval(check, 2000);
    return () => clearInterval(interval);
  }, [reportFlag]);

  // ── Virtual camera/mic detection ──────────────────────────────────────────────
  // One-shot check once the real media stream is available — device labels are only
  // readable post-permission, which every interview already requires.
  const virtualDeviceCheckedRef = useRefL(false);
  useEffectL(() => {
    if (!stream || virtualDeviceCheckedRef.current) return;
    virtualDeviceCheckedRef.current = true;
    const match = window.detectVirtualDevice && window.detectVirtualDevice(stream);
    if (match) reportFlag('virtual_device_detected', `${match.kind}: ${match.label}`);
  }, [stream, reportFlag]);

  // Camera health during the call. Previously nothing watched this at all — a candidate
  // covering the lens, disabling the camera at the OS level, or the track just dying mid-call
  // left the interview running exactly as if nothing had changed: no UI change, nothing
  // reported to the backend, nothing on the recruiter's report. Poll rather than rely solely
  // on track events — OS-level camera revocation (e.g. macOS Control Center) doesn't reliably
  // fire mute/ended on every browser.
  const [cameraLive, setCameraLive] = useStateL(false);
  const cameraEverLiveRef = useRefL(false);
  useEffectL(() => {
    const checkTrack = () => {
      const track = stream && stream.getVideoTracks()[0];
      const live = !!track && track.readyState === 'live' && track.enabled && !track.muted;
      setCameraLive(prevLive => {
        if (live === prevLive) return prevLive;
        if (live) cameraEverLiveRef.current = true;
        if (!endingRef.current) reportFlag(live ? 'camera_on' : 'camera_off');
        return live;
      });
    };
    checkTrack();
    const interval = setInterval(checkTrack, 2000);
    const track = stream && stream.getVideoTracks()[0];
    track && track.addEventListener && track.addEventListener('mute', checkTrack);
    track && track.addEventListener && track.addEventListener('unmute', checkTrack);
    track && track.addEventListener && track.addEventListener('ended', checkTrack);
    return () => {
      clearInterval(interval);
      track && track.removeEventListener && track.removeEventListener('mute', checkTrack);
      track && track.removeEventListener && track.removeEventListener('unmute', checkTrack);
      track && track.removeEventListener && track.removeEventListener('ended', checkTrack);
    };
  }, [stream, reportFlag]);

  // ── Safeguard 1: warn before accidental close/refresh ──
  useEffectL(() => {
    const onBeforeUnload = (e) => {
      if (endingRef.current) return; // interview already ending — don't block
      e.preventDefault();
      e.returnValue = 'Your interview is still in progress. Are you sure you want to leave? Closing this window will end your interview session.';
      return e.returnValue;
    };
    window.addEventListener('beforeunload', onBeforeUnload);
    return () => window.removeEventListener('beforeunload', onBeforeUnload);
  }, []);

  const { turns, agentSpeaking, connected, reconnecting, connState, reconnectCountdown, liveInterim, roomError, progress, thinkingMode, agentError, aiAudioStream, connect, disconnect, setMic } = useLiveKitRoom();

  // Real amplitude for both sides of the call — drives VoiceOrb (AI) and the candidate's
  // own speaking indicator so the motion reflects actual voice energy, not a canned loop.
  // simulate=false: unlike the pre-check screen's decorative meter, these must never
  // fabricate motion when the stream isn't available yet (e.g. before the AI's track has
  // been subscribed) — silence should look like silence, not fake activity.
  const aiLevel = useMicLevel(aiAudioStream, connected, false);
  const myLevel = useMicLevel(stream, !muted, false);

  // Current-question card — derived from the live transcript itself, NOT fetched from the
  // backend (the full question list is intentionally never sent to the candidate's browser;
  // this only ever shows a question the candidate has already heard spoken aloud).
  // Shows ONLY the question sentence, not the AI's whole turn — showing the full turn
  // ("Thanks for that overview. Could you highlight... Next, how do you...") made the card
  // a confusing wall of text instead of a quick "what was I asked again?" reference.
  const currentQuestion = React.useMemo(() => {
    for (let i = turns.length - 1; i >= 0; i--) {
      if (turns[i].who !== 'ai') continue;
      const text = turns[i].text.trim();
      if (!text.includes('?')) continue;
      // Split into sentences, keep only ones ending in "?", take the last — that's the
      // question currently on the table even if the AI (against instructions) said more.
      const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
      const questions = sentences.map(s => s.trim()).filter(s => s.endsWith('?'));
      if (questions.length) return questions[questions.length - 1];
    }
    return null;
  }, [turns]);

  // Transcript scroll
  const transcriptRef = useRefL(null);
  useEffectL(() => {
    if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
  }, [turns, liveInterim]);

  // ── end-interview logic (single shared function used by button AND auto-complete) ──
  const endInterview = useCallbackL(async (reason) => {
    if (endingRef.current) return; // guard double-calls
    endingRef.current = true;

    // 1. Immediately disconnect LiveKit so mic stops
    disconnect();

    // 2. Stop local camera/mic
    if (stream) { try { stream.getTracks().forEach(t => t.stop()); } catch(e) {} }
    try { _stopShared(); } catch(e) {}

    // 3. Show spinner while submitting
    setSubmitting(true);
    setShowEnd(false);

    // 4. Notify backend — use ref values (never stale)
    let evalResult = null;
    if (cfg.interviewToken) {
      try {
        const resp = await fetch(`${API_BASE}/api/v1/public/interview/${cfg.interviewToken}/complete`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ answers: [], camera_active: cameraEverLiveRef.current }), // transcript captured server-side
        });
        if (resp.ok) evalResult = await resp.json();
      } catch(e) {}
    }

    // 5. Navigate to completed screen
    // Note: score/recommendation/strengths/etc are intentionally NOT returned by
    // /complete — the hiring decision is the recruiter's to communicate, not
    // something the candidate's browser should ever see.
    const dur = fmt(secondsRef.current);
    const fl  = flagsRef.current;
    setSubmitting(false);
    if (onComplete) onComplete({
      duration: dur,
      answered: evalResult?.answered_count || 0,
      flags: fl,
    });
    go('completed');
  }, [cfg, stream, disconnect, onComplete, go]);

  // ── Fetch LiveKit token and connect ──
  useEffectL(() => {
    if (!cfg.interviewToken) {
      setLkConnecting(false);
      setLkFetchError('Demo mode: no live session available.');
      return;
    }

    let cancelled = false;

    // getLkToken() returns the SAME shared promise the check screen already kicked off —
    // never a second independent fetch (see app.jsx's ensureLkToken). Two token requests
    // for the same interview means two dispatched agents in the same room.
    const tokenPromise = getLkToken
      ? getLkToken()
      : fetch(`${API_BASE}/api/v1/public/interview/${cfg.interviewToken}/livekit-token`, { method: 'POST' })
          .then(r => r.json());

    tokenPromise
      .then(async d => {
        if (cancelled) return;
        if (d.token && d.url) {
          // If resuming, fetch existing transcript to show in chat history
          let priorTurns = [];
          if (d.is_resuming && cfg.interviewToken) {
            try {
              const txResp = await fetch(`${API_BASE}/api/v1/public/interview/${cfg.interviewToken}/transcript`);
              if (txResp.ok) {
                const txData = await txResp.json();
                priorTurns = txData.turns || [];
              }
            } catch(_) {}
          }
          setLkConnecting(false);
          connect(
            d.url, d.token,
            () => endInterview('auto'),          // onComplete
            () => endInterview('conn_failed'),   // onConnFail — gracefully end after all retries exhausted
            cfg.interviewToken,                  // pass so reconnect can fetch fresh LK tokens
            priorTurns                           // pre-populate chat history on resume
          );
        } else {
          setLkFetchError(d.detail || 'Could not get session token.');
          setLkConnecting(false);
        }
      })
      .catch(e => {
        if (!cancelled) {
          setLkFetchError(`Network error: ${e.message}`);
          setLkConnecting(false);
        }
      });

    // Also mark interview as started
    fetch(`${API_BASE}/api/v1/public/interview/${cfg.interviewToken}/start`, { method: 'POST' }).catch(() => {});

    return () => { cancelled = true; };
  }, []); // run once on mount — cfg.interviewToken never changes

  // Mic mute (only runs when muted changes, not on initial render)
  const mountedRef = useRefL(false);
  useEffectL(() => {
    if (!mountedRef.current) { mountedRef.current = true; return; }
    setMic(!muted);
  }, [muted]);

  const aiSpeaking = agentSpeaking;
  const listening  = connected && !agentSpeaking;
  const statusLabel = lkConnecting ? 'Connecting…'
    : (lkFetchError || roomError) ? 'Error'
    : reconnecting ? 'Reconnecting…'
    : connected ? (aiSpeaking ? 'Speaking' : 'Listening')
    : 'Joining…';

  // True from mount until the agent's first real content arrives — covers both the
  // token-fetch/room-connect phase AND the gap after the room connects but before the
  // (possibly cold-starting) agent process has actually spoken. Previously the loading
  // state (lkConnecting) cleared as soon as the LiveKit room itself connected, leaving a
  // silent, unexplained gap on screen until the agent caught up.
  const awaitingGreeting = !lkFetchError && !roomError && turns.length === 0;

  // ── render ───────────────────────────────────────────────────────────────────
  return (
    <div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg)', userSelect: 'none', WebkitUserSelect: 'none' }}>

      {/* ── Screenshot deterrent: black out everything the instant a catchable
          screenshot shortcut fires. See the screenshot-deterrent effect above for
          why this can't be 100% (OS-level captures on macOS never reach page JS). ── */}
      {screenBlackout && (
        <div style={{ position: 'fixed', inset: 0, background: '#000', zIndex: 9999, display: 'grid', placeItems: 'center' }}>
          <span style={{ color: '#fff', fontSize: 14, fontWeight: 600, opacity: 0.85 }}>
            Screenshots are not permitted during this interview. This attempt has been recorded.
          </span>
        </div>
      )}

      {/* ── Persistent close-warning banner ── */}
      <div style={{
        background: 'oklch(0.28 0.06 38)', color: '#fff', fontSize: 12.5, fontWeight: 600,
        textAlign: 'center', padding: '7px 16px', letterSpacing: '0.01em', flexShrink: 0,
        display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
      }}>
        <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink:0}}>
          <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
        </svg>
        Avoid closing or refreshing this tab. If you do get disconnected, reopening your interview link will resume the session from where you left off.
      </div>

      {/* top bar — role is the first thing a candidate should read on this screen, so it
          takes the prominent left slot; REC + timer (status details, not something the
          candidate needs to act on) sit on the right. Kept deliberately minimal:
          - No "Question X of Y" — there's no fixed, strictly-ordered question count in
            practice (follow-ups and skips make any such number a lie).
          - No live tab-flag count — showing a candidate a running tally of how many times
            they've been flagged tips off exactly what's being watched and how close they
            are to a threshold; the one-time warning modal (see tabWarn below) already does
            the actual job of telling them to stop switching tabs. The flags themselves are
            still reported to the backend and shown on the recruiter's report either way.
          - No "Live session" pill — REC already communicates the session is live; a second
            "connected" indicator next to it was redundant, not informative. */}
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 18px', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
          <Logo size={19} />
          <div style={{ width: 1, height: 22, background: 'var(--border)' }} />
          <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>{cfg.role}</span>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 7, background: 'var(--danger-soft)', padding: '5px 11px', borderRadius: 999 }}>
            <span style={{ width: 9, height: 9, borderRadius: '50%', background: 'var(--danger)', animation: 'recPulse 1.4s ease-in-out infinite' }} />
            <span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--danger)', letterSpacing: '0.02em' }}>REC</span>
          </div>
          <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>{fmt(seconds)}</span>
        </div>
      </div>

      {/* body */}
      <div style={{ flex: 1, display: 'flex', minHeight: 0 }} className="iv-body">

        {/* candidate video + controls */}
        <div style={{ flex: 1, position: 'relative', padding: '14px 14px 14px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 10 }} className="iv-video-wrap">
          <div style={{ flex: 1, position: 'relative', borderRadius: 16, overflow: 'hidden', background: 'var(--video-bg)', boxShadow: 'var(--shadow-lg)' }}>
            {stream && cameraLive ? (
              <video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover', transform: 'scaleX(-1)' }} />
            ) : (
              <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>
                <div style={{ textAlign: 'center' }}>
                  <div style={{ width: 96, height: 96, borderRadius: '50%', background: 'oklch(0.4 0.03 60)', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 34, fontWeight: 700, margin: '0 auto' }}>
                    {(cfg.candidate.name || '?').split(' ').map(n => n[0]).join('').toUpperCase()}
                  </div>
                  {/* Visible parity with what's now reported to the recruiter — a candidate
                      whose camera drops mid-call should see the same "camera off" state a
                      human interviewer would notice on their own screen, not silence. */}
                  <div style={{ marginTop: 14, color: 'oklch(0.82 0.01 60)', fontSize: 13.5, fontFamily: 'ui-monospace, monospace' }}>
                    {stream && !cameraLive ? 'camera off — visible to the interviewer' : 'camera off'}
                  </div>
                </div>
              </div>
            )}

            {/* Current-question card — pinned so the candidate can glance back at the
                question without asking the AI to repeat it. Text comes from the live
                transcript, never a separate "reveal the questions" API call. */}
            {currentQuestion && (
              <div style={{
                position: 'absolute', top: 16, left: 16, right: 228,
                background: 'oklch(0.14 0.012 60 / 0.62)', backdropFilter: 'blur(10px)',
                padding: '9px 14px', borderRadius: 12,
              }}>
                <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.05em', textTransform: 'uppercase', color: 'oklch(0.72 0.01 60)', marginBottom: 3 }}>Current question</div>
                <div style={{ fontSize: 13, lineHeight: 1.5, color: '#fff', fontWeight: 500 }}>{currentQuestion}</div>
              </div>
            )}

            {/* AI tile */}
            <div style={{
              position: 'absolute', bottom: 16, right: 16, width: 196, height: 130, borderRadius: 14, overflow: 'hidden',
              background: 'oklch(0.155 0.012 60)', display: 'grid', placeItems: 'center',
              border: aiSpeaking ? '2px solid var(--accent)' : '1px solid oklch(0.9 0.01 60 / 0.16)',
              boxShadow: 'var(--shadow-md)', transition: 'border-color .2s',
            }}>
              <VoiceOrb speaking={aiSpeaking} level={aiLevel} size={82} />
              <div style={{ position: 'absolute', top: 8, left: 8, display: 'flex', alignItems: 'center', gap: 5, background: 'oklch(0.1 0.01 60 / 0.5)', backdropFilter: 'blur(6px)', padding: '3px 8px', borderRadius: 999 }}>
                <span style={{ width: 6, height: 6, borderRadius: '50%', background: aiSpeaking ? 'var(--accent)' : (listening ? 'var(--success)' : 'oklch(0.72 0.02 60)'), transition: 'background .2s' }} />
                <span style={{ fontSize: 10.5, fontWeight: 600, color: '#fff', whiteSpace: 'nowrap' }}>{statusLabel}</span>
              </div>
              <div style={{ position: 'absolute', left: 8, bottom: 8, display: 'flex', alignItems: 'center', gap: 6, background: 'oklch(0.1 0.01 60 / 0.5)', backdropFilter: 'blur(6px)', padding: '4px 9px', borderRadius: 999 }}>
                <Icon name="cameraOff" size={12} stroke={2} style={{ color: 'oklch(0.85 0.01 60)' }} />
                <span style={{ fontSize: 11.5, color: '#fff', fontWeight: 600, whiteSpace: 'nowrap' }}>Reclova AI</span>
              </div>
            </div>

            {/* candidate name tag — the dot reacts to real mic amplitude (same AnalyserNode
                approach as the AI orb) so the candidate gets the same "you're being heard
                right now" feedback a human interviewer would give with eye contact/nodding,
                instead of no indication their voice is even being picked up. */}
            <div style={{ position: 'absolute', left: 16, bottom: 16, display: 'flex', alignItems: 'center', gap: 8, background: 'oklch(0.18 0.02 60 / 0.55)', backdropFilter: 'blur(10px)', padding: '6px 12px', borderRadius: 999 }}>
              {muted ? (
                <Icon name="micOff" size={14} stroke={2.2} style={{ color: 'var(--danger)' }} />
              ) : (
                <span style={{
                  width: 8, height: 8, borderRadius: '50%', background: 'var(--success)',
                  transform: `scale(${1 + Math.min(1, myLevel) * 0.9})`,
                  opacity: 0.55 + Math.min(1, myLevel) * 0.45,
                  transition: 'transform 0.1s ease-out, opacity 0.1s ease-out',
                }} />
              )}
              <span style={{ fontSize: 13, color: '#fff', fontWeight: 600, whiteSpace: 'nowrap' }}>{cfg.candidate.name} (You)</span>
            </div>
          </div>

          {/* connection / error banners — the "connecting" state itself now lives in the
              full-screen overlay below (see awaitingGreeting) so it can stay up through
              both the token/room-connect phase AND the gap while the agent process warms
              up and speaks its first words, instead of disappearing the moment the LiveKit
              room itself connects (which used to leave a confusing silent gap on screen). */}
          {!lkConnecting && (lkFetchError || roomError) && (
            <div style={{ background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 12, padding: '12px 16px', fontSize: 13.5, color: 'var(--danger)' }}>
              ⚠ {lkFetchError || roomError}
            </div>
          )}
          {agentError && (
            <div style={{ background: 'var(--danger-soft)', border: '1px solid var(--danger)', borderRadius: 12, padding: '12px 16px', fontSize: 13.5, color: 'var(--danger)' }}>
              ⚠ {agentError}
            </div>
          )}
          {thinkingMode && (
            <div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
              <Spinner size={14} />
              <span style={{ fontSize: 13, color: 'var(--text-dim)' }}>Take your time — the interviewer is waiting for you to continue.</span>
            </div>
          )}

          {/* controls — centered within the video panel */}
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, paddingBottom: 2 }}>
            <Button variant={muted ? 'danger' : 'secondary'} icon={muted ? 'micOff' : 'mic'} onClick={() => setMuted(m => !m)}>{muted ? 'Unmute' : 'Mute'}</Button>
            <Button variant="dangerSolid" icon="phoneOff" onClick={() => setShowEnd(true)}>End interview</Button>
          </div>
        </div>

        {/* transcript panel */}
        <div style={{ width: 360, borderLeft: '1px solid var(--border)', display: 'flex', flexDirection: 'column', flexShrink: 0, background: 'var(--surface)' }} className="iv-transcript">
          <div style={{ padding: '15px 20px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
            <span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)', whiteSpace: 'nowrap' }}>Live transcript</span>
            <span style={{ fontSize: 11.5, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 6 }}>
              <span style={{ width: 7, height: 7, borderRadius: '50%', background: connected ? 'var(--success)' : 'var(--text-faint)', transition: 'background .3s' }} />
              {connected ? 'Live' : 'Waiting…'}
            </span>
          </div>

          <div ref={transcriptRef} style={{ flex: 1, overflowY: 'auto', padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
            {turns.length === 0 && (
              <div style={{ color: 'var(--text-faint)', fontSize: 13.5, textAlign: 'center', marginTop: 30 }}>
                {connected ? 'Your conversation will appear here…' : 'Waiting for connection…'}
              </div>
            )}
            {turns.map((t, i) => {
              if (t.who === 'system') return (
                <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0' }}>
                  <div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
                  <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-faint)', whiteSpace: 'nowrap', letterSpacing: '0.03em' }}>{t.text}</span>
                  <div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
                </div>
              );
              return (
                <div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: t.who === 'you' ? 'flex-end' : 'flex-start' }}>
                  <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.03em', textTransform: 'uppercase', color: t.who === 'ai' ? 'var(--accent)' : 'var(--text-faint)' }}>
                    {t.who === 'ai' ? 'Reclova AI' : 'You'}
                  </span>
                  <div style={{
                    fontSize: 14, lineHeight: 1.55, padding: '10px 14px', borderRadius: 13, maxWidth: '94%',
                    background: t.who === 'ai' ? 'var(--accent-soft)' : 'var(--surface-2)',
                    color: 'var(--text)',
                    borderTopLeftRadius: t.who === 'ai' ? 4 : 13,
                    borderTopRightRadius: t.who === 'you' ? 4 : 13,
                  }}>
                    {t.text}
                    {aiSpeaking && t.who === 'ai' && i === turns.length - 1 && <span className="caret">▍</span>}
                  </div>
                </div>
              );
            })}

            {/* live interim from server STT */}
            {liveInterim && (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
                <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.03em', textTransform: 'uppercase', color: 'var(--text-faint)' }}>You</span>
                <div style={{ fontSize: 14, lineHeight: 1.55, padding: '10px 14px', borderRadius: 13, borderTopRightRadius: 4, maxWidth: '94%', background: 'var(--surface-2)', color: 'var(--text-dim)', fontStyle: 'italic', opacity: 0.7 }}>
                  {liveInterim}<span className="caret">▍</span>
                </div>
              </div>
            )}

            {listening && !liveInterim && (
              <div style={{ alignSelf: 'flex-end', display: 'flex', alignItems: 'center', gap: 7, color: 'var(--text-faint)', fontSize: 12.5 }}>
                <Spinner size={12} /> Listening…
              </div>
            )}
          </div>
        </div>
      </div>

      {/* ── Waiting-for-interviewer overlay — front and center, stays up from mount until
          the agent's first words actually arrive. Not shown once reconnecting takes over
          (that overlay covers mid-call drops with its own messaging). ── */}
      {awaitingGreeting && !reconnecting && !endingRef.current && (
        <div style={{ position: 'fixed', inset: 0, zIndex: 700, display: 'grid', placeItems: 'center', background: 'rgba(14,17,22,.78)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
          <div style={{ width: '100%', maxWidth: 440, background: 'var(--surface)', borderRadius: 22, padding: '40px 52px', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, boxShadow: 'var(--shadow-lg)' }}>
            <Spinner size={30} />
            <div>
              <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.01em' }}>
                {lkConnecting ? 'Connecting you to the interview room…' : 'Connecting you with your interviewer…'}
              </div>
              <div style={{ fontSize: 13.5, color: 'var(--text-faint)', marginTop: 6, lineHeight: 1.5 }}>
                {lkConnecting
                  ? 'Setting up a secure session and connecting you with your interviewer.'
                  : 'Aira is joining now and will start speaking in just a moment.'}
              </div>
            </div>
          </div>
        </div>
      )}

      {/* ── Reconnecting overlay — shown on network drop ── */}
      {reconnecting && !endingRef.current && (
        <div style={{ position: 'fixed', inset: 0, zIndex: 800, display: 'grid', placeItems: 'center', background: 'rgba(14,17,22,.78)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }}>
          <div style={{ background: 'var(--surface)', borderRadius: 22, padding: '40px 52px', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, boxShadow: 'var(--shadow-lg)', maxWidth: 380 }}>
            {/* animated reconnect rings */}
            <div style={{ position: 'relative', width: 64, height: 64 }}>
              <div style={{ position: 'absolute', inset: 0, borderRadius: '50%', border: '3px solid var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 1s linear infinite' }} />
              <div style={{ position: 'absolute', inset: 8, borderRadius: '50%', border: '2px solid var(--border)', borderTopColor: 'var(--accent)', opacity: 0.5, animation: 'spin 1.4s linear infinite reverse' }} />
              <div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>
                <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
                  <path d="M1 6v6h6"/><path d="M23 18v-6h-6"/>
                  <path d="M20.49 9A9 9 0 005.64 5.64L1 10M23 14l-4.64 4.36A9 9 0 013.51 15"/>
                </svg>
              </div>
            </div>
            <div>
              <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.01em', marginBottom: 6 }}>Connection interrupted</div>
              <div style={{ fontSize: 13.5, color: 'var(--text-dim)', lineHeight: 1.6 }}>
                Don't close this window — we're automatically reconnecting your session. Your progress is saved.
              </div>
            </div>
            <div style={{ background: 'var(--surface-2)', borderRadius: 12, padding: '12px 20px', width: '100%' }}>
              <div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 4 }}>Reconnecting… giving up in</div>
              <div style={{ fontSize: 28, fontWeight: 700, fontFamily: 'var(--font-display)', color: reconnectCountdown <= 10 ? 'var(--danger)' : 'var(--text)', transition: 'color .3s' }}>
                {reconnectCountdown}s
              </div>
            </div>
            <div style={{ fontSize: 12, color: 'var(--text-faint)', lineHeight: 1.5 }}>
              If you accidentally closed the tab, reopen the link from your invitation email to resume.
            </div>
          </div>
        </div>
      )}

      {/* closing session overlay — shown while submitting */}
      {submitting && (
        <div style={{ position: 'fixed', inset: 0, zIndex: 900, display: 'grid', placeItems: 'center', background: 'rgba(14,17,22,.72)', backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)' }}>
          <div style={{ background: 'var(--surface)', borderRadius: 22, padding: '40px 52px', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 18, boxShadow: 'var(--shadow-lg)' }}>
            <Spinner size={30} />
            <div>
              <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.01em' }}>Closing your session</div>
              <div style={{ fontSize: 13.5, color: 'var(--text-faint)', marginTop: 6 }}>Saving your interview — please don't close this window</div>
            </div>
          </div>
        </div>
      )}

      {/* end confirmation modal */}
      {showEnd && (
        <Modal onClose={() => setShowEnd(false)} width={400}>
          <div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text)', marginBottom: 8 }}>End this interview?</div>
          <div style={{ fontSize: 13.5, color: 'var(--text-dim)', lineHeight: 1.6, marginBottom: 22 }}>
            This will conclude your session immediately. You won't be able to resume or restart — this is your single attempt.
          </div>
          <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
            <Button variant="secondary" onClick={() => setShowEnd(false)}>Keep going</Button>
            <Button variant="dangerSolid" icon="phoneOff" onClick={() => endInterview('manual')}>Yes, end now</Button>
          </div>
        </Modal>
      )}

      {/* tab-switch warning */}
      {tabWarn && (
        <Modal onClose={() => setTabWarn(false)} tone="danger" width={400}>
          <div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text)', marginBottom: 8 }}>You switched away from the interview</div>
          <div style={{ fontSize: 13.5, color: 'var(--text-dim)', lineHeight: 1.6, marginBottom: 22 }}>
            Leaving this tab during the interview has been <strong style={{ color: 'var(--text)' }}>recorded and flagged</strong> for the hiring team's review. This is flag {flags} of a small allowance. Please stay on this tab until you finish.
          </div>
          <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
            <Button onClick={() => setTabWarn(false)}>I understand, continue</Button>
          </div>
        </Modal>
      )}
    </div>
  );
}

// ── ControlBtn ────────────────────────────────────────────────────────────────

function ControlBtn({ icon, label, onClick, active, danger }) {
  const [hover, setHover] = useStateL(false);
  return (
    <button
      onClick={onClick}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5, background: 'transparent', border: 0, cursor: 'pointer', fontFamily: 'var(--font)' }}
    >
      <span style={{
        width: 52, height: 52, borderRadius: '50%', display: 'grid', placeItems: 'center',
        background: danger ? 'var(--danger-soft)' : (hover ? 'var(--surface-2)' : 'var(--surface)'),
        color: danger ? 'var(--danger)' : 'var(--text)',
        border: `1px solid ${danger ? 'var(--danger)' : 'var(--border-strong)'}`,
        transition: 'all .15s',
      }}>
        <Icon name={icon} size={22} stroke={2} />
      </span>
      <span style={{ fontSize: 11.5, color: 'var(--text-faint)', fontWeight: 600 }}>{label}</span>
    </button>
  );
}

// ── CompletedScreen ───────────────────────────────────────────────────────────

function CompletedScreen({ go, cfg, stats }) {
  useEffectL(() => { try { _stopShared(); } catch(e) {} }, []);
  const s = stats || {};

  return (
    <div style={{ width: '100%' }}>
      <TopBar right={null} />
      <Screen max={600} style={{ padding: '48px 24px 80px' }}>
        <div style={{ width: 80, height: 80, borderRadius: '50%', background: 'var(--success-soft)', color: 'var(--success)', display: 'grid', placeItems: 'center', marginBottom: 24, animation: 'popIn .4s cubic-bezier(.2,.8,.3,1.4)' }}>
          <Icon name="check" size={42} stroke={2.6} />
        </div>
        <h1 style={{ fontSize: 'clamp(28px,4vw,38px)', fontFamily: 'var(--font-display)', fontWeight: 500, letterSpacing: '-0.01em', width: '100%', color: 'var(--text)', margin: 0, textAlign: 'center' }}>
          Interview complete
        </h1>
        <p style={{ fontSize: 17, lineHeight: 1.6, color: 'var(--text-dim)', marginTop: 14, marginBottom: 32, textAlign: 'center', textWrap: 'pretty' }}>
          Thank you, {cfg.candidate.first || cfg.candidate.name.split(' ')[0]}. Your responses have been securely submitted to the {cfg.company} hiring team.
        </p>

        {/* Stats row — integrity-flag count is intentionally NOT shown here: it's a
            recruiter-facing signal (see the interview report), not something a candidate
            should see broken down, since that just tells a bad actor what tripped detection. */}
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 12, width: '100%', marginBottom: 24 }}>
          {[
            { label: 'Duration', value: s.duration || '—' },
            { label: 'Questions', value: s.answered || '—' },
          ].map(item => (
            <div key={item.label} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '16px 14px', textAlign: 'center' }}>
              <div style={{ fontSize: 22, fontWeight: 700, fontFamily: 'var(--font-display)', color: 'var(--text)', letterSpacing: '-0.02em' }}>{item.value}</div>
              <div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 4, fontWeight: 500 }}>{item.label}</div>
            </div>
          ))}
        </div>

        {/* What happens next */}
        <Card style={{ width: '100%' }} pad={28}>
          <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.07em', marginBottom: 4 }}>What happens next</div>
          {[
            { icon: 'sparkle', t: 'Your responses are being reviewed', d: 'The hiring team will carefully review your interview and get back to you within 3–5 business days.' },
            { icon: 'mail', t: `${cfg.recruiter.name} will be in touch`, d: `Updates will come from the ${cfg.company} hiring team.` },
          ].map((x, i, arr) => (
            <div key={x.t}>
              <div style={{ display: 'flex', gap: 15, padding: '18px 0' }}>
                <Icon name={x.icon} size={20} stroke={2} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
                <div>
                  <div style={{ fontSize: 15.5, fontWeight: 600, color: 'var(--text)' }}>{x.t}</div>
                  <div style={{ fontSize: 13.5, color: 'var(--text-faint)', marginTop: 3, lineHeight: 1.5 }}>{x.d}</div>
                </div>
              </div>
              {i < arr.length - 1 && <div style={{ height: 1, background: 'var(--border)' }} />}
            </div>
          ))}
        </Card>

        <div style={{ marginTop: 28, width: '100%', textAlign: 'center' }}>
          <span style={{ fontSize: 13.5, color: 'var(--text-faint)' }}>You can safely close this window.</span>
        </div>
      </Screen>
    </div>
  );
}
