// 영어 AI 프리토킹 — 오늘 회차의 핵심표현을 대화 속에서 써 보는 탭.
// 일반 계정은 서버가 하루 사용 비용(추정 $0.1)을 제한하고, 관리자는 제한이 없다.
// 대화 기록은 이 컴포넌트만 들고 있다가 매 턴 통째로 서버에 보낸다(서버·DB 저장 없음).
// 회차가 바뀌면 부모가 key={episode.id}로 새로 만들어 기록이 초기화된다.
//
// RolePlay.jsx와 같은 이유로 classic <script type="text/babel">로 로드된다 — import/export 금지,
// 전역 함수 선언만. speakAs/apiFetch는 index.html 메인 스크립트의 전역 함수를 그대로 쓴다.
const { useState, useEffect, useRef } = React;

const FREETALK_MAX_TURNS = 15;

// AI 수준 — 서버 FREETALK_LEVELS와 번호를 맞춘다. 문장 길이·어휘는 서버가, 읽기 속도·힌트 대기는 여기서.
const FREETALK_LEVELS = {
  1: { name: '쉬움',   desc: '짧고 쉬운 문장 · 천천히 읽기',  rate: 0.8, hintSec: 20 },
  2: { name: '보통',   desc: '일상 회화 길이',               rate: 0.9, hintSec: 25 },
  3: { name: '원어민', desc: '자연스러운 구어체 · 보통 속도', rate: 1.0, hintSec: 30 },
};

// apiFetch는 실패 시 응답 본문({"error": "..."})을 그대로 message에 담아 던진다
function freetalkErr(e) {
  try { return JSON.parse(e.message).error ?? e.message; } catch { return e.message; }
}

function freetalkPost(path, body) {
  return apiFetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
}

// 새 말풍선이 보이게 .main만 스크롤한다. scrollIntoView는 window까지 밀어서
// 날짜 줄이 헤더 밑에 숨는 문제를 다시 만든다 (index.html body 높이 주석 참고).
function freetalkScrollMain(root, target = null) {
  const main = root?.closest('.main');
  if (!main) return;
  const top = target
    ? target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - 12
    : main.scrollHeight;
  main.scrollTo({ top, behavior: 'smooth' });
}

// ── 음성 입력 ──────────────────────────────────────────────────
// browser: 브라우저 음성인식(무료, 기기·브라우저마다 품질 차이) / whisper: 녹음해서 서버가 whisper-1로 받아쓰기(분당 $0.006)
// 둘 다 HTTPS(보안 컨텍스트)에서만 마이크를 쓸 수 있다.
const FREETALK_STT_KEY = 'ft.stt';

function freetalkSttSupport() {
  const secure  = !!window.isSecureContext;
  const browser = secure && !!(window.SpeechRecognition || window.webkitSpeechRecognition);
  const whisper = secure && !!navigator.mediaDevices?.getUserMedia && typeof window.MediaRecorder !== 'undefined';
  return { secure, browser, whisper };
}

// 인식 방식은 관리자만 고른다(테스트 중 — 개발이 끝나면 Whisper로 통일할 예정).
// 일반 계정은 브라우저 인식으로 고정하고, 브라우저 인식이 없는 브라우저에서만 Whisper를 쓴다.
function freetalkDefaultEngine(sup, isAdmin) {
  let saved = null;
  if (isAdmin) try { saved = localStorage.getItem(FREETALK_STT_KEY); } catch {}
  if (saved && sup[saved]) return saved;
  return sup.browser ? 'browser' : sup.whisper ? 'whisper' : null;
}

function freetalkPickMime() {
  for (const c of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']) {
    if (window.MediaRecorder?.isTypeSupported?.(c)) return c;
  }
  return '';
}

const FREETALK_STT_ERRORS = {
  'not-allowed':         '마이크 권한이 필요해요. 브라우저 설정에서 마이크를 허용해 주세요.',
  'service-not-allowed': '이 브라우저에서는 음성 인식을 쓸 수 없어요.',
  'no-speech':           '말소리가 들리지 않았어요. 다시 눌러 말해 보세요.',
  'audio-capture':       '마이크를 찾을 수 없어요.',
  'network':             '음성 인식 서버에 연결하지 못했어요.',
};

const FREETALK_MIC_MAX_MS = 60000;   // 한 번 말하기 최대 1분

// 브라우저 인식이 잠깐 멈춘 뒤 다시 들을 때 앞뒤 문장을 잇는다
function freetalkJoin(a, b) { return [a, b].filter(Boolean).join(' ').trim(); }

// onText(text): 지금까지 들린 말 전체(중간 결과 포함) / onDone(text): 말하기가 끝나고 정해진 최종 문장
// onError(message, code) — 오류로 끝나면 onDone은 부르지 않는다.
// getLevel(): 0~1 목소리 크기. Whisper는 녹음 스트림의 실제 크기, 브라우저 인식은 인식 이벤트로 흉내 낸 값이다
// (인식 중에 getUserMedia를 따로 열면 안드로이드에서 마이크가 충돌해서 열지 않는다).
function useFreeTalkMic({ engine, episodeId, onText, onDone, onError }) {
  const [state, setState] = useState('idle');   // idle | listening | transcribing
  const recRef   = useRef(null);
  const aliveRef = useRef(true);
  const wantRef  = useRef(false);               // 사용자가 ■를 누르기 전까지 계속 듣는다
  // 듣기 한 번마다 번호를 붙인다. 취소·종료 뒤에 늦게 오는 이전 인식의 이벤트(onend 등)가
  // 새로 시작한 듣기의 ref·state를 덮어써서 마이크가 멈추던 문제를 막는다 — 번호가 다르면 무시한다.
  const sidRef   = useRef(0);
  const levelRef = useRef(0);
  const meterRef = useRef(null);                // Whisper 녹음 중 { an, buf }
  const cbRef    = useRef({ onText, onDone, onError });
  cbRef.current  = { onText, onDone, onError };

  const bump = (v) => { levelRef.current = Math.max(levelRef.current, v); };

  // 브라우저 인식은 continuous=false로 둔다(continuous는 안드로이드에서 결과가 중복된다).
  // 대신 말이 잠깐 멈춰 인식이 끝나면(onend) 사용자가 ■를 누르기 전까지 새 인식을 이어 붙인다.
  const startBrowser = () => {
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    const sid = ++sidRef.current;
    const mine = () => sidRef.current === sid && aliveRef.current;
    const t0 = Date.now();
    let done    = '';      // 끝난 인식들의 문장
    let session = '';      // 지금 인식의 문장 (중간 결과 포함)
    let quick   = 0;       // 결과 없이 곧바로 끝난 인식 수 — 3번 이어지면 무한 재시작으로 보고 멈춘다
    let failed  = false;
    let ended   = false;
    const limit = setTimeout(() => { if (mine()) stopBrowser(); }, FREETALK_MIC_MAX_MS);

    // 한 번만 끝낸다 — onend와 ■ 감시 타이머 중 먼저 온 쪽
    const finish = () => {
      if (ended) return;
      ended = true;
      clearTimeout(limit);
      if (!mine()) return;
      done = freetalkJoin(done, session); session = '';
      recRef.current = null;
      wantRef.current = false;
      setState('idle');
      if (!failed) cbRef.current.onDone?.(done);
    };

    const run = () => {
      const rec = new SR();
      rec.lang = 'en-US';
      rec.interimResults = true;
      rec.continuous = false;
      rec.maxAlternatives = 1;
      const began = Date.now();
      session = '';
      rec.onresult = (e) => {
        if (!mine() || ended) return;
        let text = '';
        for (let i = 0; i < e.results.length; i++) text += e.results[i][0].transcript;
        session = text.trim();
        bump(0.55 + Math.random() * 0.45);
        cbRef.current.onText(freetalkJoin(done, session));
      };
      rec.onspeechstart = () => { if (mine()) bump(0.7); };
      rec.onerror = (e) => {
        if (!mine() || ended || e.error === 'aborted') return;
        if (e.error === 'no-speech' && wantRef.current) return;   // 잠깐 조용했을 뿐 — onend에서 다시 듣는다
        failed = true;
        wantRef.current = false;
        cbRef.current.onError(FREETALK_STT_ERRORS[e.error] ?? `음성 인식 오류 (${e.error})`, e.error);
      };
      rec.onend = () => {
        if (!mine() || ended) { clearTimeout(limit); return; }
        quick = !session && Date.now() - began < 300 ? quick + 1 : 0;
        if (wantRef.current && quick < 3 && Date.now() - t0 < FREETALK_MIC_MAX_MS) {
          done = freetalkJoin(done, session);
          try { run(); return; } catch {}
        }
        finish();
      };
      recRef.current = { kind: 'browser', rec, finish };
      rec.start();
    };

    wantRef.current = true;
    try { run(); setState('listening'); }
    catch (err) {
      clearTimeout(limit);
      ended = true;
      wantRef.current = false;
      recRef.current = null;
      cbRef.current.onError(`음성 인식을 시작하지 못했어요 (${err.message})`, 'start');
    }
  };

  // ■ — stop()하면 보통 마지막 결과와 onend가 오지만, 안드로이드는 가끔 onend를 빠뜨린다.
  // 2초 안에 안 끝나면 지금까지 들린 말로 직접 끝낸다.
  const stopBrowser = () => {
    const r = recRef.current;
    wantRef.current = false;
    if (!r) return;
    try { r.rec.stop(); } catch {}
    setTimeout(() => r.finish(), 2000);
  };

  const startWhisper = async () => {
    const sid = ++sidRef.current;
    const mine = () => sidRef.current === sid && aliveRef.current;
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
    catch {
      if (mine()) { recRef.current = null; setState('idle'); cbRef.current.onError(FREETALK_STT_ERRORS['not-allowed'], 'not-allowed'); }
      return;
    }
    if (!mine()) { stream.getTracks().forEach(t => t.stop()); return; }   // 권한 창이 떠 있는 사이 취소됨
    // 목소리 크기 — 같은 스트림에 분석기를 붙인다. 안 되면 크기 표시만 빠진다.
    let ctx = null;
    try {
      const AC = window.AudioContext || window.webkitAudioContext;
      ctx = new AC();
      ctx.resume?.();
      const an = ctx.createAnalyser();
      an.fftSize = 512;
      ctx.createMediaStreamSource(stream).connect(an);
      meterRef.current = { an, buf: new Uint8Array(an.fftSize), sid };
    } catch { ctx = null; }
    const mime = freetalkPickMime();
    const recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
    const chunks = [];
    let timer = null;
    recorder.ondataavailable = (e) => { if (e.data?.size) chunks.push(e.data); };
    recorder.onstop = async () => {
      clearTimeout(timer);
      stream.getTracks().forEach(t => t.stop());
      if (meterRef.current?.sid === sid) meterRef.current = null;
      try { ctx?.close(); } catch {}
      if (!mine()) return;   // 취소됨 — 받아쓰기(과금)도 하지 않는다
      recRef.current = null;
      const type = (recorder.mimeType || mime || 'audio/webm').split(';')[0];
      const blob = new Blob(chunks, { type });
      if (blob.size < 1000) { setState('idle'); cbRef.current.onError('녹음이 너무 짧아요. 버튼을 누르고 말한 뒤 다시 눌러 끝내세요.', 'short'); return; }
      setState('transcribing');
      try {
        const d = await apiFetch(`/api/freetalk/transcribe?episode_id=${episodeId}`, {
          method: 'POST', headers: { 'Content-Type': type }, body: blob,
        });
        const text = String(d.text ?? '').trim();
        if (!mine()) return;
        setState('idle');
        if (text) { cbRef.current.onText(text); cbRef.current.onDone?.(text); }
        else cbRef.current.onError('말소리를 알아듣지 못했어요. 조금 더 크게 다시 말해 보세요.', 'empty');
        return;
      } catch (e) {
        if (mine()) cbRef.current.onError(freetalkErr(e), 'whisper');
      }
      if (mine()) setState('idle');
    };
    timer = setTimeout(() => { if (recorder.state === 'recording') recorder.stop(); }, FREETALK_MIC_MAX_MS);
    recRef.current = { kind: 'whisper', recorder };
    recorder.start();
  };

  const toggle = () => {
    if (state === 'transcribing' || !engine) return;
    const r = recRef.current;
    if (state === 'listening') {
      if (r?.kind === 'browser') stopBrowser();
      else if (r?.recorder?.state === 'recording') r.recorder.stop();
      else { sidRef.current++; recRef.current = null; setState('idle'); }   // 붙잡을 인식이 없으면 그냥 풀어 준다
      return;
    }
    window.speechSynthesis?.cancel();   // AI 목소리가 마이크로 들어가지 않게
    levelRef.current = 0;
    if (engine === 'browser') startBrowser();
    else { setState('listening'); startWhisper(); }   // 권한 창이 뜨는 동안에도 ✕로 취소할 수 있게 먼저 바꾼다
  };

  // 말하는 중에 ✕ — 들은 말을 버린다. 브라우저 인식은 abort(최종 결과 없이 끝남), Whisper는 녹음만 멈추고 올리지 않는다.
  // 번호를 올려 이 듣기의 늦은 이벤트를 모두 무시하고, 화면은 브라우저 이벤트를 기다리지 않고 바로 idle로 돌린다.
  const cancel = () => {
    if (state !== 'listening') return;
    sidRef.current++;
    wantRef.current = false;
    const r = recRef.current;
    recRef.current = null;
    meterRef.current = null;
    try {
      if (r?.kind === 'browser') r.rec.abort();
      else if (r?.recorder?.state === 'recording') r.recorder.stop();
    } catch {}
    setState('idle');
  };

  // 화면 애니메이션이 매 프레임 부른다
  const getLevel = () => {
    const m = meterRef.current;
    if (m) {
      m.an.getByteTimeDomainData(m.buf);
      let sum = 0;
      for (let i = 0; i < m.buf.length; i++) { const x = (m.buf[i] - 128) / 128; sum += x * x; }
      return Math.min(1, Math.sqrt(sum / m.buf.length) * 5);
    }
    levelRef.current *= 0.93;
    return levelRef.current;
  };

  useEffect(() => () => {
    aliveRef.current = false;
    wantRef.current = false;
    sidRef.current++;
    const r = recRef.current;
    try {
      if (r?.kind === 'browser') r.rec.abort();
      else if (r?.recorder?.state === 'recording') r.recorder.stop();
    } catch {}
  }, []);

  return { state, toggle, cancel, getLevel };
}

// ── 되받아 주기(recast) ────────────────────────────────────────
// 켜 두면 AI가 내 문장의 틀린 부분을 지적하지 않고 맞는 형태로 자기 말 속에 다시 써 준다.
// 화면은 그 부분에 점선 밑줄을 긋고, 누르면 "내가 쓴 말 → 고친 말"을 보여 준다.
const FREETALK_RECAST_KEY = 'ft.recast';

function freetalkDefaultRecast() {
  try { return localStorage.getItem(FREETALK_RECAST_KEY) !== 'off'; } catch { return true; }
}

function FreeTalkAiText({ text, recast, open, onToggle }) {
  const at = recast?.to ? text.toLowerCase().indexOf(recast.to.toLowerCase()) : -1;
  if (at < 0) return <div className="ft-en">{text}</div>;
  const end = at + recast.to.length;
  return (
    <div className="ft-en">
      {text.slice(0, at)}
      <button className={`ft-recast ${open ? 'open' : ''}`} onClick={onToggle} title="내 문장을 고쳐서 되받아 준 부분">
        {text.slice(at, end)}
      </button>
      {text.slice(end)}
    </div>
  );
}

function FreeTalkLevelPicker({ level, onChange }) {
  return (
    <div className="ft-levels" role="radiogroup" aria-label="AI 수준">
      {Object.entries(FREETALK_LEVELS).map(([k, v]) => (
        <button key={k} role="radio" aria-checked={level === +k}
          className={`ft-level ${level === +k ? 'on' : ''}`} onClick={() => onChange(+k)}>
          <span className="ft-level-n">{v.name}</span>
          <span className="ft-level-d">{v.desc}</span>
        </button>
      ))}
    </div>
  );
}

// ── 음성 대화 화면 ─────────────────────────────────────────────
// 말로 대화할 때는 채팅 로그 대신 이 화면을 쓴다. AI 영어 문장은 "문장 보기"를 눌러야 보인다.
// 막대·배경 빛은 rAF에서 ref로 직접 움직인다(매 프레임 React를 다시 그리지 않는다).
// AI 목소리는 브라우저 TTS라 실제 음량을 얻을 수 없어, 단어 경계 이벤트와 노이즈로 흉내 낸다.
const FREETALK_MODE_KEY = 'ft.mode';
const FREETALK_BARS = 23;

// 색으로 누가 말하는지 가른다.
// - AI(말하기·생각 중): 프로그램 색 한 가지 막대(CSS --ac) + 같은 색 빛
// - 나(듣는 중): 네 색이 왼쪽→오른쪽으로 흐르고 뒤의 빛도 같은 속도로 따라간다 (2026-09-24 샘플 G안 "흐르는 멀티 글로우")
// - 대기·받아쓰기: CSS 회색
const FREETALK_FLOW = 0.16;   // 1초에 막대 줄 폭의 16%만큼 흐른다 (한 바퀴 약 6초)
const FREETALK_WAVE_COLORS = {
  easy:  ['#D0664F', '#E0A03A', '#6E8B55', '#8E4A6B'],   // 코랄 · 금빛 · 세이지 · 자두
  power: ['#5E7A46', '#2F7D78', '#C58A2C', '#4A6A8A'],   // 초록 · 청록 · 황토 · 청회
};
const FREETALK_AI_COLOR = { easy: '#B85735', power: '#5E7A46' };   // index.html --primary / --green

function freetalkRgb(hex) { return [1, 3, 5].map(k => parseInt(hex.slice(k, k + 2), 16)); }

// 색 고리(마지막 색 다음은 첫 색) 위의 x(0~1) 자리 색
function freetalkRingColor(cols, x) {
  const p = x * cols.length, k = Math.floor(p) % cols.length, f = p - Math.floor(p);
  const a = cols[k], b = cols[(k + 1) % cols.length];
  return `rgb(${a.map((v, j) => Math.round(v + (b[j] - v) * f)).join(',')})`;
}

function freetalkDefaultMode(canVoice) {
  let saved = null;
  try { saved = localStorage.getItem(FREETALK_MODE_KEY); } catch {}
  if (saved === 'text') return 'text';
  return canVoice ? 'voice' : 'text';
}

const FtIcon = {
  mic: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="9" y="3" width="6" height="11" rx="3" /><path d="M5.5 11a6.5 6.5 0 0 0 13 0" /><path d="M12 17.5V21" />
    </svg>
  ),
  stop: (
    <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2.5" fill="currentColor" /></svg>
  ),
  replay: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1" /><path d="M3.5 4v4.5H8" />
    </svg>
  ),
  eye: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />
    </svg>
  ),
  eyeOff: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M10.6 5.1A10.6 10.6 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-2.4 3.3M6.6 6.6C3.7 8.4 2 12 2 12s3.6 7 10 7a9.7 9.7 0 0 0 5.4-1.6" />
      <path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" /><path d="M3 3l18 18" />
    </svg>
  ),
  bulb: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M9 18h6" /><path d="M10 21h4" /><path d="M12 3a6 6 0 0 0-3.6 10.8c.7.6 1.1 1.3 1.1 2.2h5c0-.9.4-1.6 1.1-2.2A6 6 0 0 0 12 3Z" />
    </svg>
  ),
  close: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
      <path d="M6 6l12 12M18 6L6 18" />
    </svg>
  ),
  keyboard: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="2.5" y="6" width="19" height="12" rx="2.5" /><path d="M6.5 10h.01M10 10h.01M13.5 10h.01M17 10h.01M8 14h8" />
    </svg>
  ),
};

// 힌트 3단계 — 글·말 두 화면이 같이 쓴다
function FreeTalkHintPanel({ stage, ko, hint, expr, say }) {
  return (
    <div className="ft-hint">
      <div className="ft-hint-row">
        <span className="ft-hint-k">질문 뜻</span>
        <span>{ko}</span>
      </div>
      {stage >= 2 && (
        <div className="ft-hint-row">
          <span className="ft-hint-k">이렇게</span>
          <span>{expr && <><b className="ft-hint-expr">{expr.phrase}</b> 활용 · </>}{hint.idea_ko}</span>
        </div>
      )}
      {stage >= 3 && hint.answer_en && (
        <div className="ft-hint-row">
          <span className="ft-hint-k">예시</span>
          <span className="ft-hint-en">{hint.answer_en}</span>
          <button className="play-btn" onClick={() => say(hint.answer_en)} title="듣기">🔊</button>
        </div>
      )}
    </div>
  );
}

const FREETALK_V_STATUS = {
  think:  '생각하는 중',
  speak:  'AI가 말하고 있어요',
  listen: '듣고 있어요',
  stt:    '알아듣는 중',
};
const FREETALK_V_MICLBL = {
  idle:   '눌러서 말하기',
  speak:  '눌러서 끊고 말하기',
  listen: '다 말했으면 누르기 · 잘못 들렸으면 ✕',
  stt:    '보내는 중…',
  think:  '잠시만요',
};

// vstate: think(AI 답 기다림) | speak(AI 말하는 중) | listen(내가 말하는 중) | stt(받아쓰는 중) | idle(내 차례)
function FreeTalkVoiceStage({
  cls, vstate, getLevel, ai, revealed, onReveal, showKo, onToggleKo, onReplay,
  recastOpen, onToggleRecast, meText, meLive, meUsed, hintBtn, hintPanel, notice, noticeErr,
  onMic, onCancel, micDisabled, levelName, onCycleLevel, turns, maxTurns, turnsFull, onSwitchText, engineLink,
}) {
  const rootRef = useRef(null);
  const glowRef = useRef(null);
  const waveRef = useRef(null);
  const barsRef = useRef([]);
  const lvlRef  = useRef(0);
  const getRef  = useRef(getLevel);
  getRef.current = getLevel;

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const reduce = !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
    const mid = (FREETALK_BARS - 1) / 2;
    // 가운데가 높은 종 모양 — 양 끝 막대도 조금은 움직이게 0.28을 깐다
    const env = Array.from({ length: FREETALK_BARS }, (_, i) => 0.28 + 0.72 * Math.pow(Math.cos((i - mid) / mid * Math.PI / 2), 1.4));
    const live = vstate === 'speak' || vstate === 'listen';
    const multi = vstate === 'listen';
    const aiGlow = vstate === 'speak' || vstate === 'think';
    const prog = /\bpower\b/.test(cls) ? 'power' : 'easy';
    const cols = FREETALK_WAVE_COLORS[prog].map(freetalkRgb);
    const aiRgb = freetalkRgb(FREETALK_AI_COLOR[prog]).join(',');
    const glow = glowRef.current;
    const wave = waveRef.current;
    // 뒤 빛은 막대 줄 높이에 맞춘다 (한 번만 잰다)
    const gy = wave && root.offsetHeight ? (wave.offsetTop + wave.offsetHeight / 2) / root.offsetHeight * 100 : 30;
    if (!multi) barsRef.current.forEach(b => { if (b) b.style.background = ''; });   // CSS 색(AI 단색·회색)으로
    const t0 = performance.now();
    let raf = 0;
    const tick = (now) => {
      const t = (now - t0) / 1000;
      const target = live ? getRef.current() : vstate === 'think' ? 0.22 : 0;
      let lvl = lvlRef.current;
      lvl += (target - lvl) * (target > lvl ? 0.5 : 0.14);   // 빨리 튀고 천천히 가라앉는다
      lvlRef.current = lvl;
      barsRef.current.forEach((b, i) => {
        if (!b) return;
        const w = reduce ? 1
          : vstate === 'think' ? 0.55 + 0.45 * Math.sin(t * 3.2 - i * 0.45)
          : 0.45 + 0.55 * Math.abs(Math.sin(t * 7.3 + i * 1.9) * Math.cos(t * 3.1 - i * 0.7));
        b.style.transform = `scaleY(${Math.min(1, 0.07 + lvl * env[i] * w * 1.15).toFixed(3)})`;
      });
      root.style.setProperty('--lv', lvl.toFixed(3));
      if (aiGlow && glow) {
        glow.style.background = `radial-gradient(260px 170px at 50% ${gy.toFixed(1)}%, rgba(${aiRgb},${(0.05 + lvl * 0.3).toFixed(3)}), transparent)`;
      }
      if (multi) {
        // 흐름은 절대 시각으로 잰다 — 다시 말할 때 색이 처음 자리로 튀지 않게
        const ft = reduce ? 0 : now / 1000 * FREETALK_FLOW;
        barsRef.current.forEach((b, i) => {
          if (b) b.style.background = freetalkRingColor(cols, (((i / FREETALK_BARS - ft) % 1) + 1) % 1);
        });
        if (glow) {
          const a = (0.08 + lvl * 0.34).toFixed(3);
          // 색 k가 지금 있는 가로 자리에 그 색 빛을 둔다. 오른쪽 끝에서 끊기지 않게 한 칸 왼쪽 복사본도 그린다.
          glow.style.background = cols.flatMap((c, k) => {
            const x = ((k / cols.length + ft) % 1) * 100;
            const y = gy + Math.sin(now / 1250 + k * 1.3) * 6;
            return [x, x - 100].map(px => `radial-gradient(170px 120px at ${px.toFixed(1)}% ${y.toFixed(1)}%, rgba(${c.join(',')},${a}), transparent)`);
          }).join(',');
        }
      }
      if (!live && vstate !== 'think' && lvl < 0.004) return;   // 멈춘 상태면 루프를 끝낸다
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [vstate]);

  const status = FREETALK_V_STATUS[vstate]
    ?? (turnsFull ? '대화를 다 채웠어요 — 피드백을 받아 보세요' : ai ? '내 차례예요' : '');
  const canSee = !!ai && vstate !== 'think';

  return (
    <div className={`ft-v ${cls}`} data-s={vstate} ref={rootRef}>
      <div className="ft-v-glow" ref={glowRef} aria-hidden="true" />

      <div className="ft-v-top">
        <div className="ft-v-top-l">
          <button className="ft-v-pill" onClick={onCycleLevel} title="AI 수준 바꾸기 — 다음 답부터 적용">{levelName}</button>
          <span className="ft-v-turns">{turns}<i>/{maxTurns}</i></span>
        </div>
        <button className="ft-v-pill ghost" onClick={onSwitchText} title="글로 대화하기">
          {FtIcon.keyboard}<span>글로</span>
        </button>
      </div>

      <div className="ft-v-center">
        <div className="ft-v-wave" ref={waveRef} aria-hidden="true">
          {Array.from({ length: FREETALK_BARS }, (_, i) => <i key={i} ref={el => { barsRef.current[i] = el; }} />)}
        </div>
        <div className="ft-v-status" aria-live="polite">
          {status && <><span className="ft-v-dot" />{status}</>}
        </div>

        <div className="ft-v-acts">
          <button className={`ft-v-chip ${revealed ? 'on' : ''}`} onClick={onReveal} disabled={!canSee}>
            {revealed ? FtIcon.eyeOff : FtIcon.eye}<span>{revealed ? '문장 숨기기' : '문장 보기'}</span>
          </button>
          <button className="ft-v-chip" onClick={onReplay} disabled={!canSee}>
            {FtIcon.replay}<span>다시 듣기</span>
          </button>
        </div>

        {revealed && canSee && (
          <div className="ft-v-say">
            <FreeTalkAiText text={ai.content} recast={ai.recast} open={recastOpen} onToggle={onToggleRecast} />
            {recastOpen && ai.recast && (
              <div className="ft-recast-note">✎ 내가 쓴 말 <s>{ai.recast.from}</s> → <b>{ai.recast.to}</b></div>
            )}
            {showKo && ai.ko && <div className="ft-v-ko">{ai.ko}</div>}
            {ai.ko && (
              <button className="ft-v-kobtn" onClick={onToggleKo}>{showKo ? '번역 숨기기' : '번역 보기'}</button>
            )}
          </div>
        )}
      </div>

      <div className="ft-v-bottom">
        {meText && (
          <div className={`ft-v-me ${meLive ? 'live' : ''}`}>
            <span className="ft-v-me-k">나</span>
            <span className="ft-v-me-t">{meText}</span>
          </div>
        )}
        {meUsed?.length > 0 && !meLive && (
          <div className="ft-v-used">
            {meUsed.map(u => <span key={u} className="ft-used-chip">✓ {u} 사용</span>)}
          </div>
        )}
        {hintPanel}
        {notice && <div className={`ft-v-notice ${noticeErr ? 'err' : ''}`}>{notice}</div>}

        <div className="ft-v-controls">
          <div className="ft-v-side">{hintBtn}</div>
          <div className="ft-v-micwrap">
            <span className="ft-v-ring r1" aria-hidden="true" />
            <span className="ft-v-ring r2" aria-hidden="true" />
            {vstate === 'stt' && <span className="ft-v-spin" aria-hidden="true" />}
            <button className="ft-v-mic" onClick={onMic} disabled={micDisabled}
              aria-label={vstate === 'listen' ? '말하기 끝내기' : '말하기'}>
              {vstate === 'listen' ? FtIcon.stop : FtIcon.mic}
            </button>
          </div>
          <div className="ft-v-side">
            {vstate === 'listen' && (
              <button className="ft-v-side-btn ft-v-cancel" onClick={onCancel} title="취소 — 보내지 않고 다시 말하기" aria-label="취소">
                {FtIcon.close}
              </button>
            )}
          </div>
        </div>
        <div className="ft-v-miclbl">{turnsFull ? '최대 턴에 도달했어요' : FREETALK_V_MICLBL[vstate]}</div>
        {engineLink}
      </div>
    </div>
  );
}

function FreeTalkTab({ episode, isPower, isAdmin = false }) {
  const exprs = episode.expressions ?? [];
  const cls   = isPower ? 'power' : '';
  const [level, setLevel]       = useState(isPower ? 2 : 1);
  const [msgs, setMsgs]         = useState([]);          // { role, content, ko?, used?, hint? }
  const [used, setUsed]         = useState(() => new Set());
  const [input, setInput]       = useState('');
  const [typedAt, setTypedAt]   = useState(0);
  const [busy, setBusy]         = useState(null);        // null | 'reply' | 'feedback'
  const [error, setError]       = useState(null);
  const [feedback, setFeedback] = useState(null);
  const [showKo, setShowKo]     = useState(false);
  const [autoSpeak, setAutoSpeak] = useState(false);
  const [openChip, setOpenChip] = useState(null);
  const [hintStage, setHintStage] = useState(0);         // 지금 AI 질문에 대해 펼친 힌트 단계 (0~3)
  const [hintNudge, setHintNudge] = useState(false);     // 오래 입력이 없으면 💡를 깜빡인다
  const [review, setReview]     = useState([]);          // 서버가 고른 지난 회차 표현 (간격 반복)
  const [stats, setStats]       = useState([]);          // items 순서의 누적 기록 { used, hint, offered }
  const [mastered, setMastered] = useState(3);
  const sttSup = useRef(freetalkSttSupport()).current;
  const [engine, setEngineState] = useState(() => freetalkDefaultEngine(sttSup, isAdmin));
  const [mode, setModeState]    = useState(() => freetalkDefaultMode(!!engine));   // voice | text
  const [micErr, setMicErr]     = useState(null);
  const [micNote, setMicNote]   = useState(null);     // 취소 안내처럼 오류가 아닌 한 줄
  const [autoSpeakTouched, setAutoSpeakTouched] = useState(false);
  const [recast, setRecastState] = useState(freetalkDefaultRecast);
  const [openRecast, setOpenRecast] = useState(null);   // 밑줄을 눌러 펼친 AI 말풍선 번호
  const [speaking, setSpeaking] = useState(false);      // AI 목소리(TTS)가 나오는 중
  const [revealAt, setRevealAt] = useState(-1);         // 음성 모드에서 영어 문장을 펼친 AI 말 번호
  const [showLog, setShowLog]   = useState(false);      // 음성 모드의 대화 기록 펼치기
  const [quota, setQuota]       = useState(null);       // { admin, used, limit? } — 오늘 사용량
  const micBase = useRef('');                          // 녹음 시작 전 입력칸 내용 — 들린 말을 그 뒤에 붙인다
  const tts     = useRef({ id: 0, level: 0, timer: null });
  const rootRef = useRef(null);
  const stageRef = useRef(null);
  const fbRef   = useRef(null);

  const voice      = mode === 'voice' && !!engine;
  const lv         = FREETALK_LEVELS[level];
  // 대화 목표 = 오늘 표현 + 복습 표현. 서버의 used·hint.expr 번호가 이 순서를 따른다.
  const items = [
    ...exprs.map(e => ({ phrase: e.phrase, meaning_ko: e.meaning_ko, review: false })),
    ...review.map(r => ({ phrase: r.phrase, meaning_ko: r.meaning_ko, review: true, date: r.date })),
  ];
  const reviewIds = review.map(r => r.id);
  const userTurns  = msgs.filter(m => m.role === 'user').length;
  const last       = msgs[msgs.length - 1];
  const lastIsUser = last?.role === 'user';
  const turnsFull  = userTurns >= FREETALK_MAX_TURNS;
  const curHint    = !lastIsUser && last?.hint ? last.hint : null;   // 지금 답해야 할 AI 말의 힌트
  const waiting    = !!curHint && !busy && !feedback && !turnsFull;

  // AI 말 읽기 — 음성 모드 화면이 말하는 동안 움직이도록 시작·끝·단어 경계를 받는다.
  // 앞 발화를 cancel()하면 그 끝 이벤트가 늦게 올 수 있어 id가 같을 때만 끝낸다.
  // onstart가 안 오는 브라우저도 있어 바로 "말하는 중"으로 두고, onend가 안 오면 글자 수로 잡은 시간 뒤 끝낸다.
  const say = (text) => {
    const t = tts.current;
    const id = ++t.id;
    const end = () => { if (t.id !== id) return; clearTimeout(t.timer); setSpeaking(false); };
    const guard = () => { clearTimeout(t.timer); t.timer = setTimeout(end, text.length * 85 / lv.rate + 4000); };
    t.level = 0.8;
    setSpeaking(true);
    guard();
    speakAs(text, 'B', 'en-US', lv.rate, {
      onstart: () => { if (t.id === id) { t.level = 0.8; guard(); } },
      onboundary: (e) => { if (t.id === id) t.level = Math.min(1, 0.5 + (e.charLength || 4) / 14 + Math.random() * 0.2); },
      onend: end,
    });
  };
  const stopSpeaking = () => { tts.current.id++; clearTimeout(tts.current.timer); window.speechSynthesis?.cancel(); setSpeaking(false); };
  const ttsLevel = () => {
    const t = tts.current;
    t.level = Math.max(0.32, t.level * 0.95);
    return t.level * (0.7 + Math.random() * 0.3);
  };

  const apiMessages = (list) => list.map(m => ({ role: m.role, content: m.content, hint: m.hint && m.role === 'user' ? m.hint : 0 }));

  const requestReply = async (list) => {
    setBusy('reply'); setError(null);
    try {
      // 첫 호출(list 비어 있음)은 review_ids 없이 보내 서버가 복습 표현을 고르게 한다
      const d = await freetalkPost('/api/freetalk/reply', {
        episode_id: episode.id, level, recast, messages: apiMessages(list),
        ...(list.length ? { review_ids: reviewIds } : {}),
      });
      if (d.quota) setQuota(d.quota);
      if (!list.length) {
        setReview(d.review ?? []);
        setStats(d.stats ?? []);
        if (d.mastered) setMastered(d.mastered);
      }
      let next = list;
      // 서버의 used는 방금 보낸 내 문장에서 쓴 표현 — 그 말풍선에 표시하고 전체 진행도에 더한다
      if (d.used?.length && list.length) {
        next = list.map((m, i) => (i === list.length - 1 ? { ...m, used: d.used } : m));
        setUsed(prev => new Set([...prev, ...d.used]));
      }
      setMsgs([...next, { role: 'assistant', content: d.reply, ko: d.reply_ko, hint: d.hint, recast: d.recast ?? null }]);
      setHintStage(0);
      if ((autoSpeak || voice) && d.reply) say(d.reply);
    } catch (e) {
      setError(freetalkErr(e));
      setMsgs(list);
      loadQuota();
    }
    setBusy(null);
  };

  const start = () => {
    stopSpeaking();
    // iOS는 사용자가 누른 순간이 아니면 TTS를 막는다 — 시작 버튼에서 빈 발화로 먼저 풀어 둔다
    if (voice && window.speechSynthesis) {
      try { window.speechSynthesis.speak(new SpeechSynthesisUtterance(' ')); } catch {}
    }
    setFeedback(null); setUsed(new Set()); setMsgs([]); setInput(''); setHintStage(0);
    setReview([]); setMicErr(null); setMicNote(null); setRevealAt(-1); setShowLog(false);
    requestReply([]);
  };

  const send = (textArg) => {
    const t = (typeof textArg === 'string' ? textArg : input).trim();
    if (!t || busy || turnsFull) return;
    // 이 답을 쓰기 전에 본 힌트 단계를 같이 남긴다 — 피드백이 "힌트 보고 쓴 표현"을 구분한다
    const list = [...msgs, { role: 'user', content: t, hint: hintStage }];
    setMsgs(list);
    setInput('');
    setHintStage(0);
    requestReply(list);
  };

  const finish = async () => {
    stopSpeaking();
    setBusy('feedback'); setError(null);
    try {
      const fb = await freetalkPost('/api/freetalk/feedback', { episode_id: episode.id, level, messages: apiMessages(msgs), review_ids: reviewIds });
      setFeedback(fb);
      if (fb.stats) setStats(fb.stats);
      if (fb.quota) setQuota(fb.quota);
    } catch (e) { setError(freetalkErr(e)); }
    setBusy(null);
  };

  const reset = () => {
    stopSpeaking();
    setMsgs([]); setUsed(new Set()); setFeedback(null); setError(null); setInput(''); setHintStage(0);
    setReview([]); setStats([]); setMicErr(null); setMicNote(null); setRevealAt(-1); setShowLog(false);
  };

  const cycleLevel = () => setLevel(l => (l % 3) + 1);

  const loadQuota = () => apiFetch('/api/freetalk/quota').then(setQuota).catch(() => {});
  useEffect(() => { loadQuota(); }, []);
  // 일반 계정의 오늘 남은 몫(%) — 관리자·아직 모름이면 null
  const quotaLeft = quota && !quota.admin && quota.limit
    ? Math.max(0, Math.round((1 - quota.used / quota.limit) * 100)) : null;
  const quotaOut = quotaLeft === 0;

  const setRecast = (on) => {
    setRecastState(on);
    try { localStorage.setItem(FREETALK_RECAST_KEY, on ? 'on' : 'off'); } catch {}
  };

  const setEngine = (e) => {
    setEngineState(e); setMicErr(null);
    try { localStorage.setItem(FREETALK_STT_KEY, e); } catch {}
  };

  const setMode = (m) => {
    setModeState(m); setMicErr(null);
    try { localStorage.setItem(FREETALK_MODE_KEY, m); } catch {}
  };

  const mic = useFreeTalkMic({
    engine, episodeId: episode.id,
    onText: (t) => {
      const base = micBase.current;
      setInput((base && t ? base + ' ' : base) + t);
      setTypedAt(Date.now());
    },
    // 음성 모드는 말을 끝내면 바로 보낸다. 글 모드는 입력칸에 채워 두고 사용자가 보낸다.
    onDone: (t) => {
      if (!voice) return;
      const text = String(t ?? '').trim();
      if (text) { setMicErr(null); setMicNote(null); send(text); }
      else { setInput(''); setMicErr('잘 들리지 않았어요. 다시 눌러 말해 보세요.'); }
    },
    onError: (msg) => setMicErr(msg),
  });
  const toggleMic = () => {
    if (mic.state === 'idle') {
      micBase.current = voice ? '' : input.trim();
      if (voice) setInput('');
      setMicErr(null); setMicNote(null);
      stopSpeaking();
      // 말로 대화하기 시작하면 AI 답도 소리로 듣는 게 자연스럽다 — 직접 끈 적이 없으면 자동 읽기를 켠다
      if (!autoSpeakTouched) setAutoSpeak(true);
    }
    mic.toggle();
  };
  const cancelMic = () => {
    mic.cancel();
    setInput(voice ? '' : micBase.current);
    setMicErr(null);
    setMicNote('취소했어요. 마이크를 눌러 다시 말해 보세요.');
  };
  const listening = mic.state === 'listening';

  // 새 말풍선·입력 대기 점이 생기면 아래로, 피드백이 오면 피드백 머리로.
  // 음성 모드는 화면 한 장에서 대화가 이어지므로 첫 질문 때 그 화면 머리로만 맞춘다.
  useEffect(() => {
    if (!msgs.length) return;
    if (feedback) freetalkScrollMain(rootRef.current, fbRef.current);
    else if (voice) { if (msgs.length === 1 && !showLog) freetalkScrollMain(rootRef.current, stageRef.current); }
    else freetalkScrollMain(rootRef.current);
  }, [msgs.length, busy, feedback]);

  // 힌트를 펼치면 입력칸 위 힌트가 보이게
  useEffect(() => { if (hintStage && !voice) freetalkScrollMain(rootRef.current); }, [hintStage]);

  // AI 말 이후(또는 마지막 타이핑 이후) 수준별 시간 동안 입력이 없으면 💡를 깜빡인다.
  // 음성 모드는 AI가 말을 마친 뒤부터 센다.
  useEffect(() => {
    setHintNudge(false);
    if (!waiting || hintStage >= 3 || speaking || listening) return;
    const t = setTimeout(() => setHintNudge(true), lv.hintSec * 1000);
    return () => clearTimeout(t);
  }, [waiting, msgs.length, typedAt, hintStage, level, speaking, listening]);

  useEffect(() => () => { tts.current.id++; clearTimeout(tts.current.timer); window.speechSynthesis?.cancel(); }, []);

  // PC는 Enter로 보내고 Shift+Enter는 줄바꿈. 모바일 자판의 Enter는 줄바꿈으로 둔다.
  // 한글 입력 조합 중의 Enter(isComposing)는 글자 확정이라 보내지 않는다.
  const onKeyDown = (e) => {
    if (e.key !== 'Enter' || e.shiftKey || e.nativeEvent.isComposing) return;
    if (window.matchMedia?.('(pointer: coarse)').matches) return;
    e.preventDefault();
    send();
  };

  const hintExpr = curHint?.expr != null ? items[curHint.expr] : null;
  const hintPanel = waiting && hintStage > 0 && (
    <FreeTalkHintPanel stage={hintStage} ko={last.ko} hint={curHint} expr={hintExpr} say={say} />
  );
  const openHint = () => { setHintStage(s => Math.min(3, s + 1)); setHintNudge(false); };
  const hintTitle = ['질문 뜻 보기', '답 아이디어 보기', '예시 답 보기'][hintStage];

  const canPickEngine = isAdmin && sttSup.browser && sttSup.whisper;
  const engineLink = engine && canPickEngine && (
    <button className="ft-link" onClick={() => setEngine(engine === 'browser' ? 'whisper' : 'browser')}
      disabled={mic.state !== 'idle'} title="음성 인식 방식 바꾸기">
      🎤 {engine === 'browser' ? '브라우저' : 'Whisper'} 인식
    </button>
  );

  const renderLog = () => (
    <div className="ft-log">
      {msgs.map((m, i) => m.role === 'assistant' ? (
        <div key={i} className="ft-msg ai">
          <div className="ft-bubble">
            <FreeTalkAiText text={m.content} recast={m.recast} open={openRecast === i}
              onToggle={() => setOpenRecast(openRecast === i ? null : i)} />
            {showKo && m.ko && <div className="ft-ko">{m.ko}</div>}
            {openRecast === i && m.recast && (
              <div className="ft-recast-note">
                ✎ 내가 쓴 말 <s>{m.recast.from}</s> → <b>{m.recast.to}</b>
              </div>
            )}
          </div>
          <button className="play-btn" onClick={() => say(m.content)} title="듣기">🔊</button>
        </div>
      ) : (
        <div key={i} className="ft-msg me">
          <div className="ft-bubble">
            <div className="ft-en">{m.content}</div>
            {(m.used?.length > 0 || m.hint > 0) && (
              <div className="ft-used">
                {m.used?.map(u => <span key={u} className="ft-used-chip">✓ {items[u]?.phrase}</span>)}
                {m.hint > 0 && <span className="ft-hint-mark" title="이 답을 쓰기 전에 본 힌트 단계">💡{m.hint}</span>}
              </div>
            )}
          </div>
        </div>
      ))}
      {busy === 'reply' && (
        <div className="ft-msg ai">
          <div className="ft-bubble ft-typing" aria-label="답장 작성 중"><span /><span /><span /></div>
        </div>
      )}
    </div>
  );

  const renderVoice = () => {
    const aiIndex = (() => { for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === 'assistant') return i; return -1; })();
    const ai = aiIndex >= 0 ? msgs[aiIndex] : null;
    const lastMe = [...msgs].reverse().find(m => m.role === 'user');
    const vstate = listening ? 'listen'
      : mic.state === 'transcribing' ? 'stt'
      : busy === 'reply' ? 'think'
      : speaking ? 'speak' : 'idle';
    const meLive = vstate === 'listen' || vstate === 'stt';
    const meText = meLive ? (input || (vstate === 'listen' ? '…' : '')) : lastMe?.content ?? '';
    const hintBtn = waiting && hintStage < 3 && (
      <button className={`ft-v-side-btn ${hintNudge ? 'nudge' : ''}`} onClick={openHint} title={hintTitle} aria-label={hintTitle}>
        {FtIcon.bulb}<span className="ft-v-badge">{hintStage + 1}/3</span>
      </button>
    );
    const notice = micErr
      ? <>{micErr}{engine === 'browser' && canPickEngine && <button className="ft-link" onClick={() => setEngine('whisper')}>Whisper로 바꾸기</button>}</>
      : micNote && !listening ? micNote
      : hintNudge && hintStage === 0 && !listening ? '막히셨나요? 왼쪽 💡로 힌트를 볼 수 있어요.' : null;
    return (
      <div ref={stageRef}>
        <FreeTalkVoiceStage
          cls={cls} vstate={vstate} getLevel={vstate === 'speak' ? ttsLevel : mic.getLevel}
          ai={ai}
          revealed={revealAt === aiIndex && aiIndex >= 0}
          onReveal={() => setRevealAt(revealAt === aiIndex ? -1 : aiIndex)}
          showKo={showKo} onToggleKo={() => setShowKo(v => !v)}
          onReplay={() => ai && say(ai.content)}
          recastOpen={openRecast === aiIndex} onToggleRecast={() => setOpenRecast(openRecast === aiIndex ? null : aiIndex)}
          meText={meText} meLive={meLive}
          meUsed={!meLive && lastMe?.used ? lastMe.used.map(u => items[u]?.phrase).filter(Boolean) : []}
          hintBtn={hintBtn} hintPanel={hintPanel}
          notice={notice} noticeErr={!!micErr}
          onMic={toggleMic}
          onCancel={cancelMic}
          micDisabled={turnsFull || busy === 'reply' || busy === 'feedback' || vstate === 'stt'}
          levelName={lv.name} onCycleLevel={cycleLevel}
          turns={userTurns} maxTurns={FREETALK_MAX_TURNS} turnsFull={turnsFull}
          onSwitchText={() => setMode('text')}
          engineLink={engineLink && <div className="ft-v-engine">{engineLink}</div>}
        />
      </div>
    );
  };

  const canVoice = sttSup.browser || sttSup.whisper;

  return (
    <div className={`ft ${cls}`} ref={rootRef}>
      <style>{FREETALK_CSS}</style>

      <div className="ft-goal">
        <div className="ft-goal-hd">
          <span className="t">{review.length ? '오늘의 표현 + 복습' : '오늘의 표현'}</span>
          <span className="c">{used.size} / {items.length} 사용</span>
        </div>
        <div className="ft-chips">
          {items.map((it, i) => {
            const st = stats[i];
            const done = st && st.used >= mastered;
            return (
              <button key={i} className={`ft-chip ${used.has(i) ? 'on' : ''} ${openChip === i ? 'open' : ''} ${it.review ? 'rev' : ''}`}
                onClick={() => setOpenChip(openChip === i ? null : i)}>
                {used.has(i) && <span className="ft-chip-ck">✓</span>}
                {it.review && <span className="ft-chip-rev">복습</span>}
                {it.phrase}
                {st && (done
                  ? <span className="ft-chip-done">익힘</span>
                  : <span className="ft-dots" aria-label={`익힘 ${st.used}/${mastered}`}>
                      {Array.from({ length: mastered }, (_, k) => <i key={k} className={k < st.used ? 'f' : ''} />)}
                    </span>)}
              </button>
            );
          })}
        </div>
        {openChip != null && items[openChip] && (
          <div className="ft-chip-mean">
            {items[openChip].meaning_ko}
            {items[openChip].review && <span className="ft-chip-from"> · {items[openChip].date} 회차</span>}
            {stats[openChip] && (
              <span className="ft-chip-from"> · 힌트 없이 {stats[openChip].used}회 사용{stats[openChip].hint ? `, 힌트 보고 ${stats[openChip].hint}회` : ''}</span>
            )}
          </div>
        )}
      </div>

      {msgs.length === 0 && !busy ? (
        <div className="ft-intro">
          <div className="ft-intro-t">오늘 배운 표현으로 대화해 보세요</div>
          <div className="ft-intro-d">
            AI가 표현을 꺼내 쓰기 좋은 상황으로 대화를 이끌어 줍니다.
            막히면 💡 힌트를 단계별로 열어 볼 수 있고, 마치면 쓴 표현과 고칠 문장을 알려 드려요.
          </div>
          <div className="ft-intro-lbl">대화 방식</div>
          <div className="ft-modes">
            <button className={`ft-mode ${voice ? 'on' : ''}`} onClick={() => setMode('voice')} disabled={!canVoice}>
              <span className="ft-mode-ic">{FtIcon.mic}</span>
              <span className="ft-level-n">말로 대화</span>
              <span className="ft-level-d">AI가 소리로 묻고 마이크로 답해요</span>
            </button>
            <button className={`ft-mode ${!voice ? 'on' : ''}`} onClick={() => setMode('text')}>
              <span className="ft-mode-ic">{FtIcon.keyboard}</span>
              <span className="ft-level-n">글로 대화</span>
              <span className="ft-level-d">영어 문장을 보며 입력해요</span>
            </button>
          </div>
          {!canVoice && (
            <div className="ft-note ft-note-top">
              {sttSup.secure
                ? '이 브라우저에서는 마이크를 쓸 수 없어 글로만 대화할 수 있어요. 입력칸을 누르고 키보드의 🎤(음성 입력)로 말해도 돼요.'
                : 'http 접속에서는 브라우저가 마이크를 막아 글로만 대화할 수 있어요. 입력칸을 누르고 키보드의 🎤(음성 입력)로 말해도 돼요 — 키보드 언어를 영어로 두세요.'}
            </div>
          )}
          <div className="ft-intro-lbl">AI 수준</div>
          <FreeTalkLevelPicker level={level} onChange={setLevel} />
          <div className="ft-intro-lbl">대화 중 교정</div>
          <div className="ft-engines">
            <button className={`ft-engine ${recast ? 'on' : ''}`} onClick={() => setRecast(true)}>
              <span className="ft-level-n">되받아 주기</span><span className="ft-level-d">틀린 말을 AI가 맞게 다시 말해 줌</span>
            </button>
            <button className={`ft-engine ${!recast ? 'on' : ''}`} onClick={() => setRecast(false)}>
              <span className="ft-level-n">끄기</span><span className="ft-level-d">끝나고 피드백에서만</span>
            </button>
          </div>
          {voice && canPickEngine && (
            <>
              <div className="ft-intro-lbl">음성 인식 <span className="ft-intro-adm">관리자</span></div>
              <div className="ft-engines">
                {sttSup.browser && (
                  <button className={`ft-engine ${engine === 'browser' ? 'on' : ''}`} onClick={() => setEngine('browser')}>
                    <span className="ft-level-n">브라우저</span><span className="ft-level-d">무료 · 기기마다 인식 차이</span>
                  </button>
                )}
                {sttSup.whisper && (
                  <button className={`ft-engine ${engine === 'whisper' ? 'on' : ''}`} onClick={() => setEngine('whisper')}>
                    <span className="ft-level-n">Whisper</span><span className="ft-level-d">정확 · 1분 말하면 약 8원</span>
                  </button>
                )}
              </div>
            </>
          )}
          <button className={`btn fill ${cls}`} onClick={start} disabled={quotaOut}>{voice ? '🎙 말로 대화 시작' : '💬 대화 시작'}</button>
          {error && <div className="ft-err">{error}</div>}
          {quota && (
            <div className={`ft-quota ${quotaOut ? 'out' : ''}`}>
              {quota.admin ? (
                <span className="ft-quota-l">관리자 · 사용량 제한 없음</span>
              ) : quotaOut ? (
                <span className="ft-quota-l">오늘 사용량을 다 썼어요 · 내일 다시 이용해 주세요</span>
              ) : (
                <>
                  <span className="ft-quota-l">오늘 남은 사용량</span>
                  <span className="ft-quota-bar" aria-hidden="true"><i style={{ width: `${quotaLeft}%` }} /></span>
                  <span className="ft-quota-n">{quotaLeft}%</span>
                </>
              )}
            </div>
          )}
          <div className="ft-note">한 번에 최대 {FREETALK_MAX_TURNS}턴</div>
        </div>
      ) : voice ? (
        <>
          {!feedback && renderVoice()}

          {error && (
            <div className="dialog-warn ft-errbar">
              <span>{error}</span>
              {lastIsUser && !busy && <button className={`btn ${cls}`} onClick={() => requestReply(msgs)}>다시 시도</button>}
            </div>
          )}

          <div className="ft-acts">
            {!feedback && (
              <>
                <button className={`btn ${cls} ${busy === 'feedback' ? 'generating' : ''}`} onClick={finish}
                  disabled={!userTurns || !!busy || listening}>
                  {busy === 'feedback' ? '피드백 만드는 중...' : '🏁 마치고 피드백 받기'}
                </button>
                <button className={`btn ${cls}`} onClick={reset} disabled={!!busy}>↺ 처음부터</button>
              </>
            )}
            <button className={`btn ${cls} ${showLog ? 'fill' : ''}`} onClick={() => setShowLog(v => !v)} disabled={!msgs.length}>
              {showLog ? '대화 기록 닫기' : '대화 기록 보기'}
            </button>
          </div>

          {showLog && (
            <div className="script ft-chat">
              <div className="script-hd">
                <span className="t">대화 기록</span>
                <span className="ft-bar">
                  <button className={`ft-tog ${showKo ? 'on' : ''}`} onClick={() => setShowKo(v => !v)}>번역</button>
                </span>
              </div>
              {renderLog()}
            </div>
          )}

          {feedback && (
            <div ref={fbRef}>
              <FreeTalkFeedback fb={feedback} cls={cls} say={say} onRestart={start} stats={stats} mastered={mastered} />
            </div>
          )}
        </>
      ) : (
        <>
          <div className="script ft-chat">
            <div className="script-hd">
              <span className="t">💬 AI 프리토킹</span>
              <span className="ft-bar">
                {engine && !feedback && (
                  <button className="ft-tog ft-tog-ic" onClick={() => setMode('voice')} title="말로 대화하기"
                    disabled={mic.state !== 'idle'}>{FtIcon.mic}말로</button>
                )}
                <button className="ft-tog ft-lv" onClick={cycleLevel} title="AI 수준 바꾸기 — 다음 답부터 적용">{lv.name}</button>
                <button className={`ft-tog ${showKo ? 'on' : ''}`} onClick={() => setShowKo(v => !v)}>번역</button>
                <button className={`ft-tog ${autoSpeak ? 'on' : ''}`} onClick={() => { setAutoSpeak(v => !v); setAutoSpeakTouched(true); }}>자동 읽기</button>
                <span className="c">{userTurns}/{FREETALK_MAX_TURNS}</span>
              </span>
            </div>

            {renderLog()}

            {hintPanel}

            {!feedback && (
              <div className="ft-input">
                {waiting && hintStage < 3 && (
                  <button className={`ft-hint-btn ${hintNudge ? 'nudge' : ''}`} onClick={openHint} title={hintTitle}>
                    💡<span className="ft-hint-n">{hintStage + 1}/3</span>
                  </button>
                )}
                <textarea
                  value={input}
                  onChange={e => { setInput(e.target.value); setTypedAt(Date.now()); }}
                  onKeyDown={onKeyDown}
                  rows={2}
                  maxLength={500}
                  disabled={turnsFull || listening || mic.state === 'transcribing'}
                  placeholder={turnsFull ? '최대 턴에 도달했어요 — 피드백을 받아 보세요'
                    : listening ? '듣고 있어요… 다 말했으면 ■를 누르세요'
                    : mic.state === 'transcribing' ? '받아쓰는 중…'
                    : engine ? '영어로 답해 보세요 (🎤 말로도 가능)' : '영어로 답해 보세요 (키보드 🎤로 말해도 돼요)'}
                />
                {engine && (
                  <button className={`ft-mic ${listening ? 'rec' : ''} ${mic.state === 'transcribing' ? 'busy' : ''}`}
                    onClick={toggleMic} disabled={turnsFull || mic.state === 'transcribing'}
                    title={listening ? '말하기 끝내기' : '말로 답하기'} aria-label={listening ? '말하기 끝내기' : '말로 답하기'}>
                    {listening ? '■' : mic.state === 'transcribing' ? '…' : '🎤'}
                  </button>
                )}
                <button className={`btn fill ${cls}`} onClick={() => send()}
                  disabled={!input.trim() || !!busy || turnsFull || listening || mic.state === 'transcribing'}>보내기</button>
              </div>
            )}
            {!feedback && (micErr || (hintNudge && hintStage === 0) || engine) && (
              <div className="ft-foot">
                {micErr ? (
                  <span className="ft-foot-err">
                    {micErr}
                    {engine === 'browser' && canPickEngine && (
                      <button className="ft-link" onClick={() => setEngine('whisper')}>Whisper로 바꾸기</button>
                    )}
                  </span>
                ) : hintNudge && hintStage === 0 ? (
                  <span className="ft-foot-nudge">막히셨나요? 💡를 누르면 힌트를 단계별로 볼 수 있어요.</span>
                ) : <span />}
                {engineLink}
              </div>
            )}
          </div>

          {error && (
            <div className="dialog-warn ft-errbar">
              <span>{error}</span>
              {lastIsUser && !busy && <button className={`btn ${cls}`} onClick={() => requestReply(msgs)}>다시 시도</button>}
            </div>
          )}

          {!feedback && (
            <div className="ft-acts">
              <button className={`btn ${cls} ${busy === 'feedback' ? 'generating' : ''}`} onClick={finish}
                disabled={!userTurns || !!busy}>
                {busy === 'feedback' ? '피드백 만드는 중...' : '🏁 마치고 피드백 받기'}
              </button>
              <button className={`btn ${cls}`} onClick={reset} disabled={!!busy}>↺ 처음부터</button>
            </div>
          )}

          {feedback && (
            <div ref={fbRef}>
              <FreeTalkFeedback fb={feedback} cls={cls} say={say} onRestart={start} stats={stats} mastered={mastered} />
            </div>
          )}
        </>
      )}
    </div>
  );
}

function FreeTalkFeedback({ fb, cls, say, onRestart, stats = [], mastered = 3 }) {
  const exps  = Array.isArray(fb.expressions) ? fb.expressions : [];
  const cors  = Array.isArray(fb.corrections) ? fb.corrections : [];
  const nUsed = exps.filter(x => x?.used).length;
  const sayBtn = (text) => text && (
    <button className="play-btn" onClick={() => say(String(text))} title="듣기">🔊</button>
  );

  return (
    <div className="ft-fb">
      <div className="ft-fb-hd">🏁 오늘의 피드백</div>
      {fb.summary_ko && <div className="ft-fb-sum">{String(fb.summary_ko)}</div>}

      <div className="ft-fb-sec">표현 사용 <span>{nUsed} / {exps.length}</span></div>
      {exps.map((x, i) => (
        <div key={i} className={`ft-fb-ex ${x?.used ? 'on' : ''}`}>
          <div className="ft-fb-ph">
            <span className="ft-fb-mark">{x?.used ? '✓' : '○'}</span>{String(x?.phrase ?? '')}
            {x?.review && <span className="ft-fb-tag">복습</span>}
            {x?.used && x?.with_hint && <span className="ft-fb-tag">힌트 보고 사용</span>}
            {stats[i] && (
              <span className={`ft-fb-prog ${stats[i].used >= mastered ? 'done' : ''}`}>
                {stats[i].used >= mastered ? '익힘 ✓' : `익힘 ${stats[i].used}/${mastered}`}
              </span>
            )}
          </div>
          {x?.sentence && <div className="ft-fb-quote">“{String(x.sentence)}”</div>}
          {x?.comment_ko && <div className="ft-fb-cm">{String(x.comment_ko)}</div>}
          {x?.suggestion && (
            <div className="ft-fb-sug"><span>💡 {String(x.suggestion)}</span>{sayBtn(x.suggestion)}</div>
          )}
        </div>
      ))}

      <div className="ft-fb-sec">고쳐서 다시 써 보기 {cors.length > 0 && <span>{cors.length}문장</span>}</div>
      {cors.length === 0 ? (
        <div className="ft-fb-cm ft-fb-none">고칠 문장이 없었어요 👍</div>
      ) : cors.map((c, i) => <FreeTalkRetry key={i} c={c} cls={cls} sayBtn={sayBtn} />)}

      <div className="ft-fb-acts">
        <button className={`btn fill ${cls}`} onClick={onRestart}>💬 새 대화 시작</button>
      </div>
    </div>
  );
}

// 교정 문장은 바로 보여 주지 않고, 원래 문장과 문제점만 보고 직접 고쳐 쓰게 한다.
// 맞히거나 "모범 문장 보기"를 누르면 그때 모범 문장을 연다.
function FreeTalkRetry({ c, cls, sayBtn }) {
  const original = String(c?.original ?? '');
  const better   = String(c?.better ?? '');
  const [attempt, setAttempt] = useState('');
  const [status, setStatus]   = useState('idle');   // idle | checking | ok | wrong
  const [comment, setComment] = useState('');
  const [reveal, setReveal]   = useState(false);

  const check = async () => {
    const a = attempt.trim();
    if (!a || status === 'checking') return;
    setStatus('checking');
    try {
      const d = await freetalkPost('/api/freetalk/retry', { original, better, attempt: a });
      setComment(d.comment_ko ?? '');
      setStatus(d.ok ? 'ok' : 'wrong');
      if (d.ok) setReveal(true);
    } catch (e) {
      setComment(freetalkErr(e));
      setStatus('wrong');
    }
  };

  const onKeyDown = (e) => {
    if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); check(); }
  };

  return (
    <div className={`ft-fb-cor ${status === 'ok' ? 'ok' : ''}`}>
      <div className="ft-fb-orig">{original}</div>
      {c?.why_ko && <div className="ft-fb-cm">{String(c.why_ko)}</div>}

      {status !== 'ok' && (
        <div className="ft-retry">
          <input value={attempt} onChange={e => { setAttempt(e.target.value); if (status === 'wrong') setStatus('idle'); }}
            onKeyDown={onKeyDown} placeholder="고쳐서 다시 써 보세요" maxLength={500} />
          <button className={`btn fill ${cls} ${status === 'checking' ? 'generating' : ''}`} onClick={check}
            disabled={!attempt.trim() || status === 'checking'}>확인</button>
        </div>
      )}
      {comment && (status === 'ok' || status === 'wrong') && (
        <div className={`ft-retry-res ${status}`}>{status === 'ok' ? '✓ ' : '✗ '}{comment}</div>
      )}

      {reveal ? (
        <div className="ft-fb-better"><span>→ {better}</span>{sayBtn(better)}</div>
      ) : (
        <button className="ft-reveal" onClick={() => setReveal(true)}>모범 문장 보기</button>
      )}
    </div>
  );
}

// 색은 전부 --ac 토큰 — .ft.power가 Power 초록으로 바꾼다
const FREETALK_CSS = `
/* 오늘의 표현 */
.ft-goal{border:1px solid var(--border);border-radius:var(--r-lg);background:var(--surface);box-shadow:var(--sh-sm);
  padding:13px 16px 14px;margin-bottom:14px}
.ft-goal-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.ft-goal-hd .t{font-size:11px;font-weight:800;letter-spacing:.13em;text-transform:uppercase;color:var(--ac-ink)}
.ft-goal-hd .c{font-size:11px;font-weight:700;color:var(--ac-ink);font-variant-numeric:tabular-nums}
.ft-chips{display:flex;flex-wrap:wrap;gap:7px}
.ft-chip{display:inline-flex;align-items:center;gap:5px;padding:6px 12px;border-radius:var(--pill);
  border:1px solid var(--border-strong);background:var(--surface);color:var(--text);font-size:13px;font-weight:600;
  transition:background .15s,border-color .15s,color .15s}
.ft-chip:hover,.ft-chip.open{border-color:var(--ac)}
.ft-chip.on{background:var(--ac);border-color:var(--ac);color:#fff}
.ft-chip-ck{font-size:11px;font-weight:800}
.ft-chip.rev{border-style:dashed}
.ft-chip.rev.on{border-style:solid}
.ft-chip-rev{font-size:9.5px;font-weight:800;letter-spacing:.04em;color:var(--ac-ink);background:var(--ac-soft);
  border-radius:var(--pill);padding:1px 6px}
.ft-chip.on .ft-chip-rev{background:rgba(255,255,255,.22);color:#fff}
.ft-dots{display:inline-flex;gap:2px;margin-left:2px}
.ft-dots i{width:5px;height:5px;border-radius:50%;background:var(--border-strong)}
.ft-dots i.f{background:var(--ac)}
.ft-chip.on .ft-dots i{background:rgba(255,255,255,.4)}
.ft-chip.on .ft-dots i.f{background:#fff}
.ft-chip-done{font-size:9.5px;font-weight:800;color:var(--ac-ink);border:1px solid var(--ac-line);border-radius:var(--pill);padding:0 6px}
.ft-chip.on .ft-chip-done{color:#fff;border-color:rgba(255,255,255,.5)}
.ft-chip-from{color:var(--muted)}
.ft-chip-mean{margin-top:10px;padding:8px 12px;border-left:3px solid var(--ac);background:var(--ac-soft);
  border-radius:0 var(--r-sm) var(--r-sm) 0;font-size:13px;color:var(--ac-ink);line-height:1.5}

/* 시작 전 */
.ft-intro{text-align:center;padding:28px 20px 24px;border:1px dashed var(--border-strong);border-radius:var(--r-lg);background:var(--surface)}
.ft-intro-t{font-size:17px;font-weight:700;letter-spacing:-.01em;margin-bottom:8px}
.ft-intro-d{font-size:13.5px;color:var(--muted);line-height:1.65;max-width:420px;margin:0 auto 18px;text-wrap:pretty}
.ft-intro-adm{font-size:9.5px;letter-spacing:.04em;color:var(--ac-ink);background:var(--ac-soft);border-radius:var(--pill);padding:1px 6px;margin-left:4px}
.ft-intro-lbl{font-size:10.5px;font-weight:800;letter-spacing:.12em;color:var(--muted2);margin-bottom:8px}
.ft-levels{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;max-width:440px;margin:0 auto 18px}
.ft-level{display:flex;flex-direction:column;align-items:center;gap:3px;padding:10px 6px;border-radius:var(--r);
  border:1px solid var(--border-strong);background:var(--surface);transition:border-color .15s,background .15s}
.ft-level:hover{border-color:var(--ac)}
.ft-level.on{border-color:var(--ac);background:var(--ac-soft);box-shadow:inset 0 0 0 1px var(--ac)}
.ft-level-n{font-size:14px;font-weight:700;color:var(--text)}
.ft-level.on .ft-level-n{color:var(--ac-ink)}
.ft-level-d{font-size:10.5px;color:var(--muted2);line-height:1.35}
.ft-note{font-size:11px;color:var(--muted2);margin-top:14px}
.ft-note.ft-note-top{margin:0 0 18px}
.ft-engines{display:grid;grid-template-columns:repeat(auto-fit,minmax(0,1fr));gap:7px;max-width:440px;margin:0 auto 18px}
.ft-engine{display:flex;flex-direction:column;align-items:center;gap:3px;padding:10px 6px;border-radius:var(--r);
  border:1px solid var(--border-strong);background:var(--surface);transition:border-color .15s,background .15s}
.ft-engine:hover{border-color:var(--ac)}
.ft-engine.on{border-color:var(--ac);background:var(--ac-soft);box-shadow:inset 0 0 0 1px var(--ac)}
.ft-engine.on .ft-level-n{color:var(--ac-ink)}
.ft-err{font-size:12.5px;color:var(--danger);margin-top:12px}
.ft-quota{display:flex;align-items:center;justify-content:center;gap:9px;margin-top:14px;font-size:11.5px;font-weight:700;color:var(--muted)}
.ft-quota.out{color:var(--danger)}
.ft-quota-bar{position:relative;width:96px;height:6px;border-radius:var(--pill);background:var(--surface2);overflow:hidden}
.ft-quota-bar i{position:absolute;inset:0 auto 0 0;border-radius:inherit;background:var(--ac);transition:width .4s var(--ease)}
.ft-quota-n{font-variant-numeric:tabular-nums;color:var(--ac-ink);min-width:32px;text-align:left}
.ft-intro .btn:disabled{opacity:.45}

/* 대화 */
.ft-bar{display:flex;align-items:center;gap:5px}
.ft-tog{padding:2px 9px;border-radius:var(--pill);border:1px solid var(--ac-line);background:var(--surface);
  color:var(--ac-ink);font-size:10.5px;font-weight:700;white-space:nowrap}
.ft-tog.on{background:var(--ac);border-color:var(--ac);color:#fff}
.ft-tog.ft-lv{border-style:dashed}
.ft-log{display:flex;flex-direction:column;gap:12px;padding:16px}
.ft-msg{display:flex;align-items:flex-end;gap:4px;max-width:86%}
.ft-msg.me{align-self:flex-end}
.ft-bubble{padding:10px 13px;border-radius:16px;font-size:15px;line-height:1.5}
.ft-msg.ai .ft-bubble{background:var(--surface2);border-bottom-left-radius:5px}
.ft-msg.me .ft-bubble{background:var(--ac-soft);border:1px solid var(--ac-line);border-bottom-right-radius:5px}
.ft-en{white-space:pre-wrap;overflow-wrap:anywhere}
.ft-ko{font-size:12.5px;color:var(--muted);margin-top:5px;line-height:1.5}
.ft-recast{display:inline;background:none;border:none;padding:0;font:inherit;color:inherit;cursor:pointer;
  text-decoration:underline dotted var(--ac);text-decoration-thickness:2px;text-underline-offset:3px}
.ft-recast.open{background:var(--ac-soft);border-radius:4px}
.ft-recast-note{margin-top:7px;padding-top:7px;border-top:1px dashed var(--border-strong);font-size:12.5px;color:var(--muted);line-height:1.5}
.ft-recast-note s{text-decoration-color:var(--muted2)}
.ft-recast-note b{color:var(--ac-ink)}
.ft-used{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:7px}
.ft-used-chip{font-size:10.5px;font-weight:800;color:#fff;background:var(--ac);border-radius:var(--pill);padding:2px 8px}
.ft-hint-mark{font-size:10.5px;font-weight:700;color:var(--muted2)}
.ft-typing{display:flex;gap:4px;padding:14px 15px}
.ft-typing span{width:6px;height:6px;border-radius:50%;background:var(--muted2);animation:ft-dot 1.1s ease-in-out infinite}
.ft-typing span:nth-child(2){animation-delay:.15s}
.ft-typing span:nth-child(3){animation-delay:.3s}
@keyframes ft-dot{0%,60%,100%{opacity:.3;transform:none}30%{opacity:1;transform:translateY(-3px)}}

/* 힌트 */
.ft-hint{margin:0 14px 12px;padding:10px 13px;border-radius:var(--r);background:var(--ac-soft);border:1px solid var(--ac-line);
  display:flex;flex-direction:column;gap:7px;animation:ft-in .2s var(--ease)}
.ft-hint-row{display:flex;align-items:baseline;gap:9px;font-size:13.5px;line-height:1.5;color:var(--text)}
.ft-hint-k{flex-shrink:0;width:38px;font-size:10.5px;font-weight:800;letter-spacing:.04em;color:var(--ac-ink)}
.ft-hint-expr{color:var(--ac-ink)}
.ft-hint-en{flex:1;font-weight:600}
.ft-hint-row .play-btn{align-self:center;margin:-4px 0}
@keyframes ft-in{from{opacity:0;transform:translateY(4px)}}

.ft-input{display:flex;gap:8px;align-items:flex-end;padding:12px 14px;border-top:1px solid var(--border);background:var(--bg-sub)}
.ft-input textarea{flex:1;min-width:0;resize:none;border:1px solid var(--border-strong);border-radius:var(--r);
  background:var(--surface);color:var(--text);padding:9px 12px;font-size:16px;line-height:1.45;outline:none}
.ft-input textarea:focus{border-color:var(--ac);box-shadow:0 0 0 3px var(--ac-soft)}
.ft-input textarea:disabled{background:var(--surface2);color:var(--muted2)}
.ft-input .btn{flex-shrink:0}
.ft-hint-btn{flex-shrink:0;align-self:stretch;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1px;
  width:44px;border-radius:var(--r);border:1px solid var(--border-strong);background:var(--surface);font-size:17px;line-height:1;
  transition:border-color .15s,background .15s}
.ft-hint-btn:hover{border-color:var(--ac)}
.ft-hint-n{font-size:9.5px;font-weight:800;color:var(--muted2);font-variant-numeric:tabular-nums}
.ft-hint-btn.nudge{border-color:var(--ac);background:var(--ac-soft);animation:ft-nudge 1.4s ease-in-out infinite}
@keyframes ft-nudge{0%,100%{box-shadow:0 0 0 0 var(--ac-line)}50%{box-shadow:0 0 0 5px var(--ac-line)}}
.ft-mic{flex-shrink:0;align-self:stretch;width:44px;border-radius:var(--r);border:1px solid var(--border-strong);
  background:var(--surface);font-size:17px;line-height:1;transition:border-color .15s,background .15s,color .15s}
.ft-mic:hover{border-color:var(--ac)}
.ft-mic.rec{background:var(--danger);border-color:var(--danger);color:#fff;font-size:14px;animation:ft-rec 1.2s ease-in-out infinite}
.ft-mic.busy{color:var(--muted2);animation:pulse 1.3s ease-in-out infinite}
.ft-mic:disabled{opacity:.5}
@keyframes ft-rec{0%,100%{box-shadow:0 0 0 0 var(--danger-soft)}50%{box-shadow:0 0 0 6px var(--danger-soft)}}
.ft-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0 14px 11px;background:var(--bg-sub);
  font-size:12px;font-weight:600;min-height:0}
.ft-foot-nudge{color:var(--ac-ink)}
.ft-foot-err{color:var(--danger);display:flex;flex-wrap:wrap;gap:4px 8px;align-items:baseline}
.ft-link{background:none;border:none;padding:0;font-size:11.5px;font-weight:700;color:var(--muted);white-space:nowrap;
  text-decoration:underline;text-underline-offset:2px}
.ft-link:hover{color:var(--ac-ink)}
.ft-link:disabled{opacity:.5}
.ft-errbar{display:flex;align-items:center;justify-content:space-between;gap:10px}
.ft-acts{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px}

/* 피드백 */
.ft-fb{border:1px solid var(--ac-line);border-radius:var(--r-lg);background:var(--surface);box-shadow:var(--sh-sm);
  padding:16px 18px 18px;margin-bottom:14px}
.ft-fb-hd{font-size:16px;font-weight:800;color:var(--ac-ink);margin-bottom:8px}
.ft-fb-sum{font-size:14px;line-height:1.65;color:var(--text)}
.ft-fb-sec{display:flex;justify-content:space-between;align-items:center;margin:18px 0 9px;
  font-size:11px;font-weight:800;letter-spacing:.1em;color:var(--ac-ink)}
.ft-fb-sec span{font-variant-numeric:tabular-nums}
.ft-fb-ex,.ft-fb-cor{padding:11px 13px;border-radius:var(--r);background:var(--surface2)}
.ft-fb-ex + .ft-fb-ex,.ft-fb-cor + .ft-fb-cor{margin-top:8px}
.ft-fb-ex.on,.ft-fb-cor.ok{background:var(--ac-soft)}
.ft-fb-ph{font-size:14.5px;font-weight:700;display:flex;gap:7px;align-items:baseline;flex-wrap:wrap}
.ft-fb-mark{color:var(--ac);font-weight:800}
.ft-fb-ex:not(.on) .ft-fb-mark{color:var(--muted2)}
.ft-fb-prog{margin-left:auto;font-size:10.5px;font-weight:700;color:var(--muted2);font-variant-numeric:tabular-nums}
.ft-fb-prog.done{color:var(--ac-ink)}
.ft-fb-tag{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface);border:1px solid var(--border-strong);
  border-radius:var(--pill);padding:1px 7px}
.ft-fb-quote{font-size:13.5px;color:var(--text);font-style:italic;margin-top:5px}
.ft-fb-cm{font-size:12.5px;color:var(--muted);line-height:1.6;margin-top:5px}
.ft-fb-none{margin-top:0}
.ft-fb-sug,.ft-fb-better{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-top:6px;
  font-size:13.5px;font-weight:600;color:var(--ac-ink)}
.ft-fb-orig{font-size:14px;font-weight:600;color:var(--text)}
.ft-retry{display:flex;gap:7px;margin-top:9px}
.ft-retry input{flex:1;min-width:0;border:1px solid var(--border-strong);border-radius:var(--r-sm);background:var(--surface);
  color:var(--text);padding:8px 11px;font-size:16px;outline:none}
.ft-retry input:focus{border-color:var(--ac);box-shadow:0 0 0 3px var(--ac-soft)}
.ft-retry .btn{flex-shrink:0}
.ft-retry-res{font-size:12.5px;line-height:1.55;margin-top:7px;font-weight:600}
.ft-retry-res.ok{color:var(--ac-ink)}
.ft-retry-res.wrong{color:var(--danger)}
.ft-reveal{margin-top:8px;background:none;border:none;padding:0;font-size:12px;font-weight:600;color:var(--muted2);
  text-decoration:underline;text-underline-offset:2px}
.ft-reveal:hover{color:var(--ac-ink)}
.ft-fb-acts{display:flex;justify-content:center;margin-top:18px}

/* 대화 방식 선택 */
.ft-modes{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:440px;margin:0 auto 18px}
.ft-mode{display:flex;flex-direction:column;align-items:center;gap:4px;padding:14px 8px 12px;border-radius:var(--r-lg);
  border:1px solid var(--border-strong);background:var(--surface);transition:border-color .15s,background .15s,box-shadow .15s}
.ft-mode:hover:not(:disabled){border-color:var(--ac)}
.ft-mode.on{border-color:var(--ac);background:var(--ac-soft);box-shadow:inset 0 0 0 1px var(--ac)}
.ft-mode.on .ft-level-n{color:var(--ac-ink)}
.ft-mode:disabled{opacity:.45;cursor:not-allowed}
.ft-mode-ic{display:grid;place-items:center;width:38px;height:38px;border-radius:50%;margin-bottom:3px;
  background:var(--surface2);color:var(--muted)}
.ft-mode-ic svg{width:19px;height:19px}
.ft-mode.on .ft-mode-ic{background:var(--ac);color:#fff}
.ft-tog-ic{display:inline-flex;align-items:center;gap:3px}
.ft-tog-ic svg{width:12px;height:12px}
.ft-tog:disabled{opacity:.5}

/* 음성 대화 화면 — --lv(0~1)는 rAF가 매 프레임 넣는 소리 크기 */
.ft-v{--lv:0;position:relative;isolation:isolate;overflow:hidden;display:flex;flex-direction:column;
  min-height:clamp(430px,calc(100dvh - var(--header-h) - var(--player-total) - 60px),600px);
  margin-bottom:14px;border:1px solid var(--border);border-radius:var(--r-xl);
  background:linear-gradient(180deg,var(--surface) 0%,var(--surface) 55%,var(--bg-sub) 100%);box-shadow:var(--sh-md)}
.ft-v-glow{position:absolute;z-index:-1;inset:0;pointer-events:none;opacity:0;transition:opacity .6s var(--ease)}
.ft-v[data-s="speak"] .ft-v-glow,.ft-v[data-s="think"] .ft-v-glow,.ft-v[data-s="listen"] .ft-v-glow{opacity:1}

.ft-v-top{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px 0}
.ft-v-top-l{display:flex;align-items:center;gap:10px}
.ft-v-pill{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 12px;border-radius:var(--pill);
  border:1px solid var(--ac-line);background:var(--surface);color:var(--ac-ink);font-size:12px;font-weight:700;
  transition:border-color .15s,background .15s}
.ft-v-pill:hover{border-color:var(--ac)}
.ft-v-pill svg{width:14px;height:14px}
.ft-v-pill.ghost{border-color:var(--border-strong);color:var(--muted)}
.ft-v-pill.ghost:hover{color:var(--text);border-color:var(--muted2)}
.ft-v-turns{font-size:12.5px;font-weight:800;color:var(--text);font-variant-numeric:tabular-nums}
.ft-v-turns i{font-style:normal;color:var(--muted2);font-weight:600}

.ft-v-center{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:18px 18px 8px;text-align:center}
.ft-v-wave{display:flex;align-items:center;justify-content:center;gap:5px;height:84px;margin-bottom:14px}
.ft-v-wave i{display:block;width:4px;height:84px;border-radius:4px;background:var(--ac);transform:scaleY(.07);
  transform-origin:center;will-change:transform;transition:background .3s}
.ft-v[data-s="stt"] .ft-v-wave i{background:var(--muted2)}
.ft-v[data-s="idle"] .ft-v-wave i{background:var(--border-strong)}
.ft-v-status{display:flex;align-items:center;justify-content:center;gap:7px;min-height:20px;
  font-size:13.5px;font-weight:700;color:var(--muted);letter-spacing:-.005em}
.ft-v-dot{width:7px;height:7px;border-radius:50%;background:var(--muted2)}
.ft-v[data-s="speak"] .ft-v-dot{background:var(--ac);animation:pulse 1.2s ease-in-out infinite}
.ft-v[data-s="listen"] .ft-v-dot{background:var(--danger);animation:pulse 1s ease-in-out infinite}
.ft-v[data-s="think"] .ft-v-dot,.ft-v[data-s="stt"] .ft-v-dot{animation:pulse 1.3s ease-in-out infinite}
.ft-v[data-s="speak"] .ft-v-status{color:var(--ac-ink)}

.ft-v-acts{display:flex;gap:8px;justify-content:center;margin-top:16px}
.ft-v-chip{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 14px;border-radius:var(--pill);
  border:1px solid var(--border-strong);background:var(--surface);color:var(--text);font-size:12.5px;font-weight:700;
  box-shadow:var(--sh-xs);transition:border-color .15s,background .15s,color .15s,opacity .15s}
.ft-v-chip svg{width:15px;height:15px;color:var(--muted)}
.ft-v-chip:hover:not(:disabled){border-color:var(--ac)}
.ft-v-chip.on{background:var(--ac-soft);border-color:var(--ac-line);color:var(--ac-ink)}
.ft-v-chip.on svg{color:var(--ac-ink)}
.ft-v-chip:disabled{opacity:.4}

.ft-v-say{width:100%;max-width:520px;margin-top:14px;padding:14px 16px;border-radius:var(--r-lg);
  background:var(--surface);border:1px solid var(--border);box-shadow:var(--sh-sm);text-align:left;animation:ft-in .22s var(--ease)}
.ft-v-say .ft-en{font-family:var(--font-serif);font-size:18px;line-height:1.5;color:var(--text);letter-spacing:-.005em}
.ft-v-ko{font-size:13px;color:var(--muted);line-height:1.55;margin-top:8px}
.ft-v-kobtn{margin-top:8px;background:none;border:none;padding:0;font-size:11.5px;font-weight:700;color:var(--muted2);
  text-decoration:underline;text-underline-offset:2px}
.ft-v-kobtn:hover{color:var(--ac-ink)}

.ft-v-bottom{display:flex;flex-direction:column;align-items:center;padding:6px 16px 16px}
.ft-v-me{display:flex;align-items:baseline;gap:8px;max-width:520px;width:100%;justify-content:center;margin-bottom:8px;
  font-size:14px;line-height:1.5;color:var(--muted)}
.ft-v-me-k{flex-shrink:0;font-size:10px;font-weight:800;letter-spacing:.06em;color:var(--muted2);
  border:1px solid var(--border-strong);border-radius:var(--pill);padding:0 6px;line-height:16px}
.ft-v-me-t{overflow-wrap:anywhere;text-align:left}
.ft-v-me.live .ft-v-me-t{color:var(--text);font-weight:600}
.ft-v-me.live .ft-v-me-t::after{content:'';display:inline-block;width:2px;height:1em;margin-left:3px;vertical-align:-2px;
  background:var(--danger);animation:ft-caret 1s steps(1) infinite}
@keyframes ft-caret{50%{opacity:0}}
.ft-v-used{display:flex;flex-wrap:wrap;gap:5px;justify-content:center;margin-bottom:8px;animation:ft-in .25s var(--ease)}
.ft-v-bottom .ft-hint{width:100%;max-width:520px;margin:0 0 10px;text-align:left}
.ft-v-notice{max-width:520px;margin-bottom:8px;font-size:12.5px;font-weight:600;color:var(--ac-ink);
  display:flex;flex-wrap:wrap;gap:4px 8px;justify-content:center;align-items:baseline}
.ft-v-notice.err{color:var(--danger)}

.ft-v-controls{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;width:100%;max-width:340px;margin-top:6px}
.ft-v-side{display:flex;justify-content:center}
.ft-v-side-btn{position:relative;display:grid;place-items:center;width:50px;height:50px;border-radius:50%;
  border:1px solid var(--border-strong);background:var(--surface);color:var(--muted);box-shadow:var(--sh-xs);
  transition:border-color .15s,color .15s,background .15s}
.ft-v-side-btn svg{width:21px;height:21px}
.ft-v-side-btn:hover{border-color:var(--ac);color:var(--ac-ink)}
.ft-v-side-btn.nudge{border-color:var(--ac);background:var(--ac-soft);color:var(--ac-ink);animation:ft-nudge 1.4s ease-in-out infinite}
.ft-v-cancel{animation:ft-in .2s var(--ease)}
.ft-v-cancel:hover{border-color:var(--danger);color:var(--danger)}
.ft-v-badge{position:absolute;right:-5px;top:-4px;min-width:26px;padding:1px 5px;border-radius:var(--pill);
  background:var(--surface);border:1px solid var(--border-strong);font-size:9.5px;font-weight:800;color:var(--muted);
  font-variant-numeric:tabular-nums;line-height:13px}

.ft-v-micwrap{position:relative;width:88px;height:88px;display:grid;place-items:center}
.ft-v-mic{position:relative;z-index:1;width:88px;height:88px;border-radius:50%;border:none;display:grid;place-items:center;
  background:var(--ac);color:#fff;-webkit-tap-highlight-color:transparent;
  box-shadow:0 12px 28px -10px var(--ac),0 2px 6px -2px rgba(66,50,32,.25),inset 0 1px 0 rgba(255,255,255,.18);
  transition:transform .18s var(--ease),background .2s,box-shadow .2s,opacity .2s}
.ft-v-mic svg{width:34px;height:34px}
.ft-v-mic:hover:not(:disabled){background:var(--ac-dark)}
.ft-v-mic:active:not(:disabled){transform:scale(.95)}
.ft-v-mic:disabled{opacity:.4;box-shadow:none}
.ft-v[data-s="listen"] .ft-v-mic{background:var(--ac-dark)}
.ft-v[data-s="listen"] .ft-v-mic svg{width:28px;height:28px}
.ft-v-ring{position:absolute;inset:0;border-radius:50%;background:var(--ac);opacity:0;pointer-events:none;
  transition:opacity .3s}
.ft-v[data-s="listen"] .ft-v-ring.r1{opacity:.2;transform:scale(calc(1.08 + var(--lv) * .45))}
.ft-v[data-s="listen"] .ft-v-ring.r2{opacity:.1;transform:scale(calc(1.16 + var(--lv) * .85))}
.ft-v[data-s="idle"] .ft-v-mic:not(:disabled){animation:ft-invite 2.6s ease-in-out infinite}
@keyframes ft-invite{0%,100%{box-shadow:0 12px 28px -10px var(--ac),0 0 0 0 var(--ac-line)}
  50%{box-shadow:0 12px 28px -10px var(--ac),0 0 0 9px var(--ac-line)}}
.ft-v-spin{position:absolute;inset:-7px;z-index:2;border-radius:50%;border:3px solid var(--ac-line);border-top-color:var(--ac);
  animation:ft-spin .8s linear infinite;pointer-events:none}
@keyframes ft-spin{to{transform:rotate(360deg)}}
.ft-v-miclbl{margin-top:12px;min-height:18px;font-size:12.5px;font-weight:600;color:var(--muted2)}
.ft-v[data-s="listen"] .ft-v-miclbl{color:var(--ac-ink)}
.ft-v-engine{margin-top:8px}

@media(prefers-reduced-motion:reduce){
  .ft-v-mic,.ft-v-side-btn.nudge{animation:none!important}
}

@media(max-width:768px){
  .ft-msg{max-width:92%}
  .ft-log{padding:14px 12px}
  .ft-input{padding:10px 12px}
  .ft-hint{margin:0 12px 10px}
  .ft-fb{padding:14px 14px 16px}
  .ft-level-d{font-size:10px}
  .ft-v-center{padding:14px 14px 6px}
  .ft-v-wave{gap:4px}
  .ft-v-say .ft-en{font-size:17px}
  .ft-v-bottom{padding:4px 12px 14px}
}
`;
