/* ============================================================================
 * 서명전에 — Web App Controller
 * 모바일 우선. 900px 이상에서 사이드바 + 센터 컬럼.
 * 기능: 랜딩/로그인/가입 + 앱 쉘(홈/기록/도움말/계정) + 분석 · 결과 · 원문 뷰어
 * ========================================================================== */

const { useState: useStateW, useEffect: useEffectW, useMemo: useMemoW, useRef: useRefW } = React;

/* ----------------------------------------------------------------------------
 * 분석 응답(ContractAnalysisResponse)을 기존 UI(ResultScreen)의 목업 data 구조로
 * 변환. window.SCENARIOS와 동일한 shape을 돌려준다.
 * -------------------------------------------------------------------------- */

/* 백엔드 _expand_missing_reason이 붙이는 공통 prefix/suffix를 걷어내 원래 사유만 남긴다. */
const MISSING_REASON_PREFIX_RE =
  /^이 항목은 계약서에 없거나 너무 약하게 적혀 있어서 나중에 다툼이 생기기 쉽습니다\.?\s*/;
const MISSING_REASON_SUFFIX_RE =
  /\s*특히\s+(?:보증금[^.]*?실제 돈과 직결되는 문제가 생길 수 있습니다|임금[^.]*?실제 불이익으로 이어질 수 있습니다|대금[^.]*?(?:분쟁이 커질 수 있습니다|서로 다르게 해석할 위험이 있습니다))\.?$/;
function trimMissingReason(reason) {
  if (!reason) return "";
  let r = reason.trim().replace(MISSING_REASON_PREFIX_RE, "");
  r = r.replace(MISSING_REASON_SUFFIX_RE, "").trim();
  return r;
}

/* 이슈 카드 미리보기로 쓸 짧은 한 줄. 첫 문장 위주.
   회귀 ("[[K]]별도 협의 없이[[/K]]" 가 카드에 raw 로 렌더됨): 하이라이트
   마커 ([[K]]...[[/K]] 와 **...**) 를 먼저 벗긴 뒤 slice. 카드에선 하이라이트
   없이 평문이 보이고, 하이라이트는 IssueDetail 시트에서만 살아남는다. */
function _stripMarkers(s) {
  return String(s || "")
    .replace(/\[\[K\]\]/g, "")
    .replace(/\[\[\/K\]\]/g, "")
    .replace(/\*\*([^*]+)\*\*/g, "$1");
}
function issuePreview(iss) {
  const src = _stripMarkers((iss.explanation || iss.why_it_matters || iss.reason || "").trim());
  if (!src) return "";
  const firstSentence = src.split(/(?<=[.。])\s+/)[0] || src;
  return firstSentence.length > 110 ? firstSentence.slice(0, 108) + "…" : firstSentence;
}

/* key_facts 표시 순서 — 사용자가 자연스럽게 읽는 흐름:
   "보증금이 얼마? → 계약금/중도금/잔금 어떻게? → 월세는? → 어디? → 언제?"
   백엔드/LLM 출력 순서가 들쭉날쭉이라 프런트에서 정렬해 일관된 경험 제공. */
const KEY_FACT_ORDER = [
  "보증금/전세금", "보증금", "전세금",
  "계약금", "중도금", "잔금",
  "월 차임", "월세", "월 임대료", "임대료", "관리비",
  "소재지", "임대부분", "임대 부분", "면적",
  "계약기간", "계약 기간", "계약 시작일", "계약 종료일",
];
function sortKeyFacts(kfs) {
  const arr = Array.isArray(kfs) ? [...kfs] : [];
  return arr.sort((a, b) => {
    const ai = KEY_FACT_ORDER.indexOf(a?.label);
    const bi = KEY_FACT_ORDER.indexOf(b?.label);
    if (ai === -1 && bi === -1) return 0;
    if (ai === -1) return 1;   // 알려지지 않은 라벨은 뒤로
    if (bi === -1) return -1;
    return ai - bi;
  });
}

/* IssueDetail 시트가 기대하는 목업 키(reason/quote/suggestion)를 호환 노출.
   suggestion 에는 **해요체 안내** (recommendation + negotiation_tip) 만 합쳐 step
   분할이 자연스럽게 되도록 한다.
   ⚠️ suggested_replacement 는 의도적으로 제외 — 이 필드는 RiskPattern 에 하드
   코딩된 **평서 어미 모범조항** ("임대인은 ... 지급한다" 같은) 이라 "이렇게
   고쳐 달라고 말해 봐요" 리스트에 섞여 들어가면 톤이 튐. 같은 시나리오
   회귀가 여러 번 보고됨.
   대신 IssueDetail 쪽에서 suggested_replacement 전용 "바로 넣어볼 문구"
   카드로 별도 렌더 — 사용자가 복붙용으로 쓸 수 있게 보존. */
/* evidence_quote 가 "1. … 2. … 3. …" 처럼 여러 번호 조항을 걸치면 issue
   title 의 한글 키워드와 가장 매칭되는 조항만 남기고 나머지 trim.
   백엔드에도 동일 로직(_trim_evidence_to_relevant_clause)이 있지만, **저장된
   history (옛 데이터) 또는 캐시된 분석** 은 backend 가 trim 하지 않은 상태로
   응답이 묶여있을 수 있어 frontend 에서 한 번 더 보호.
   회귀: "결로·곰팡이·누수" issue 인데 quote 가 3번+4번 다 담고 [[K]] 마커가
   4번에 잘못 찍혀 사용자가 "quote 와 issue 가 어긋남" 으로 인지하던 케이스. */
function _trimQuoteToRelevantClause(quote, title) {
  if (!quote || !title) return quote;
  const parts = String(quote).split(/(?=\d+\.\s)/);
  if (parts.length <= 1) return quote;
  const titleWords = new Set((title.match(/[가-힣]{2,}/g) || []));
  if (titleWords.size === 0) return quote;
  let bestPart = null;
  let bestScore = -1;
  for (const p of parts) {
    const ps = p.trim();
    if (!ps) continue;
    const partWords = new Set((ps.match(/[가-힣]{2,}/g) || []));
    let score = 0;
    for (const w of titleWords) if (partWords.has(w)) score += 1;
    if (score > bestScore) {
      bestScore = score;
      bestPart = ps;
    }
  }
  // 매칭 0점이면 (어떤 part 도 title 키워드 없음) 잘못 자르는 위험 회피 → 원본 유지
  if (bestScore <= 0 || !bestPart) return quote;
  return bestPart;
}

function withIssueCompat(iss, idx) {
  const reason = iss.explanation || iss.why_it_matters || iss.reason || "";
  const rawQuote = iss.evidence_quote || iss.quote || null;
  const quote = _trimQuoteToRelevantClause(rawQuote, iss.title || "");
  const suggestionParts = [iss.recommendation, iss.negotiation_tip]
    .map((s) => (s || "").trim())
    .filter(Boolean);
  const suggestion = suggestionParts.length ? suggestionParts.join(" ") : iss.suggestion || "";
  return {
    id: iss.id || `srv-i${idx}`,
    ...iss,
    reason,
    quote,
    suggestion,
    preview: issuePreview({ ...iss, reason }),
  };
}

/* ─── 프론트엔드 관리비 safety net (사이클 1, 2026-04) ─────────────────────
 * 백엔드에서 관리비가 LLM 환상값(예: "1만원")으로 들어오는 회귀가 반복적으로
 * 보고되어 (서버 재시작/캐시/이전 분석 history 등 다양한 원인), 프론트엔드
 * 에서도 한 번 더 검증한다. extracted_text 안에서 관리비 anchor 직후 120자
 * 안 가장 큰 만원/원 단위 금액을 뽑아서, 백엔드가 보낸 관리비 값과 다르면
 * 본문 추출값을 채택한다 (본문이 항상 정답).
 *
 * 경쟁 라벨 (청소비/주차비/공과금/사용료 등) 직전이면 그 금액은 다른 항목
 * 소속이라 제외 — 백엔드 _COMPETING_LABEL_RE 와 동일 set.
 * 본문에서도 못 찾으면 백엔드 값 그대로 둠 (override 안 함).
 * ──────────────────────────────────────────────────────────────────────── */
/* ────────────────────────────────────────────────────────────────────
 * 관리비 추출 — backend `_extract_management_fee_v2` 와 동일 알고리즘
 * (2026-04-24 사용자 요청 "처음부터 다시 설계").
 *
 * 4-stage priority:
 *   1000 — 표 구조 ("관리비 | 12만원" / "관리비\t12만원")
 *    800 — 콜론 라벨 ("관리비: 12만원")
 *    500 — 자유 forward ("관리비는 월 12만원으로")
 *    200 — 자유 reverse ("12만원의 관리비")
 *
 * 모든 stage 공통:
 *   • sentence boundary 안에서만 (마침표 / \n / 다음 번호 항목)
 *   • 경쟁 라벨 (청소비/주차비/위약금/배상금/공과금/…) 직전 금액 제외
 *   • 부정 표현 ("없음/0원/해당 없음") 즉시 "없음" 반환
 *
 * Backend 가 정답을 보내는 게 1차이고, 이 frontend safety net 은 backend
 * 추출이 실패한 경우/네트워크 문제로 LLM 환각만 도착한 경우의 last line of
 * defense. backend 와 같은 결과를 내야 일관성 확보.
 * ──────────────────────────────────────────────────────────────────── */
const _MGMT_LABEL_RE_SRC = "(?:관\\s*리\\s*비\\s*용|관\\s*리\\s*비|관\\s*리\\s*료)";
const _MGMT_FRONT_COMPETING_RE = /(?:청\s*소\s*비|주\s*차\s*비|사\s*용\s*료|공\s*과\s*금|잡\s*비|세\s*탁\s*비|인\s*터\s*넷|TV\s*수신료?|보\s*험\s*료|식\s*대|수\s*도\s*료|전\s*기\s*료|가\s*스\s*료|관\s*리\s*위\s*탁|수\s*리\s*비|배\s*상\s*금?|위\s*약\s*금|벌\s*금|손\s*해\s*배\s*상)\s*[:：()]?\s*$/;
const _MGMT_NEGATIVE_RE = /(?:없\s*(?:음|다|어요)|0\s*원|영\s*원\s*정?|해당\s*없\s*음?|미\s*적\s*용|미\s*설\s*정|별\s*도\s*협\s*의|미\s*정)/;

function _trimToSentence(t) {
  let cut = t.length;
  const mNext = t.match(/\n\s*\d+\s*[\.\)]/);
  if (mNext && mNext.index < cut) cut = mNext.index;
  const mDot = t.match(/[.。](?:\s|$)/);
  if (mDot && mDot.index + mDot[0].length < cut) cut = mDot.index + mDot[0].length;
  const mNl = t.indexOf("\n");
  if (mNl >= 0 && mNl < cut) cut = mNl;
  return t.slice(0, cut);
}
function _hasMgmtCompetingBefore(s, pos, window) {
  window = window || 12;
  const before = s.slice(Math.max(0, pos - window), pos);
  return _MGMT_FRONT_COMPETING_RE.test(before);
}
/* segment 안의 첫 금액을 찾되 경쟁 라벨 직전이면 skip. 없으면 null. */
function _firstMgmtAmount(segment) {
  // ₩ / \\ 표기 우선 검사 — 더 명시적
  const wonRe = /([₩\\])\s*([\d,\.]{2,15})/g;
  const hanRe = /금\s*([영공일이삼사오육칠팔구십백천만억\s]{2,12}\s*원\s*정?)/g;
  const numRe = /(\d[\d,]{0,12})\s*(만\s*원|원|만원)/g;
  // 모든 매치를 모아서 등장 순서로 첫 비-경쟁 금액 반환
  const allMatches = [];
  let m;
  while ((m = wonRe.exec(segment)) !== null) {
    allMatches.push({ start: m.index, label: m[1] + m[2].trim() });
  }
  while ((m = hanRe.exec(segment)) !== null) {
    allMatches.push({ start: m.index, label: "금 " + m[1].trim() });
  }
  while ((m = numRe.exec(segment)) !== null) {
    allMatches.push({ start: m.index, label: m[1].trim() + m[2].replace(/\s/g, "") });
  }
  allMatches.sort((a, b) => a.start - b.start);
  for (const x of allMatches) {
    if (_hasMgmtCompetingBefore(segment, x.start)) continue;
    return x.label;
  }
  return null;
}

function _extractMgmtFromText(text) {
  if (!text) return null;
  const candidates = []; // {priority, pos, value}

  // === Stage 1: 표 구조 (priority 1000) ===
  const tableRe = new RegExp(_MGMT_LABEL_RE_SRC + "\\s*[|│┃\\t]\\s*([^\\n|│┃\\t]{1,40})", "g");
  let m;
  while ((m = tableRe.exec(text)) !== null) {
    const cell = m[1].trim();
    if (_MGMT_NEGATIVE_RE.test(cell)) {
      candidates.push({ priority: 1000, pos: m.index, value: "없음" });
      continue;
    }
    const amt = _firstMgmtAmount(cell);
    if (amt) candidates.push({ priority: 1000, pos: m.index, value: amt });
  }

  // === Stage 2: 콜론 라벨 (priority 800) ===
  const colonRe = new RegExp(_MGMT_LABEL_RE_SRC + "\\s*[:：]\\s*", "g");
  while ((m = colonRe.exec(text)) !== null) {
    const tail = _trimToSentence(text.slice(m.index + m[0].length, m.index + m[0].length + 80));
    if (_MGMT_NEGATIVE_RE.test(tail.slice(0, 30))) {
      candidates.push({ priority: 800, pos: m.index, value: "없음" });
      continue;
    }
    const amt = _firstMgmtAmount(tail);
    if (amt) candidates.push({ priority: 800, pos: m.index, value: amt });
  }

  // === Stage 3: 자유 forward (priority 500) ===
  const fwdRe = new RegExp(_MGMT_LABEL_RE_SRC + "(?:는|은|을|를|이|가|로|과|와)?\\s*", "g");
  const seen = new Set();
  while ((m = fwdRe.exec(text)) !== null) {
    if (seen.has(m.index)) continue;
    seen.add(m.index);
    const tail = _trimToSentence(text.slice(m.index + m[0].length, m.index + m[0].length + 100));
    if (_MGMT_NEGATIVE_RE.test(tail.slice(0, 25))) {
      candidates.push({ priority: 500, pos: m.index, value: "없음" });
      continue;
    }
    const amt = _firstMgmtAmount(tail);
    if (amt) candidates.push({ priority: 500, pos: m.index, value: amt });
  }

  // === Stage 4: 자유 reverse (priority 200) ===
  const revRe = new RegExp(
    "(\\d[\\d,]{0,12})\\s*(만\\s*원|원|만원)\\s*(?:을|를|의|으로|이|\\s)*\\s*" + _MGMT_LABEL_RE_SRC,
    "g"
  );
  while ((m = revRe.exec(text)) !== null) {
    if (_hasMgmtCompetingBefore(text, m.index)) continue;
    candidates.push({
      priority: 200,
      pos: m.index,
      value: m[1].trim() + m[2].replace(/\s/g, ""),
    });
  }

  if (candidates.length === 0) return null;
  candidates.sort((a, b) => (b.priority - a.priority) || (a.pos - b.pos));
  return candidates[0].value;
}
/* 월세/차임 safety net — 관리비와 동일 철학.
   LLM 이 "보증금" 값을 "월세" field 에 잘못 복사하는 회귀 다수 보고됨
   (예: 월세 2,000만원으로 표시되는데 실제 계약서엔 85만원).
   본문의 "차임/월세/월 임대료" 라벨 직후 금액을 직접 추출해서 backend 값과
   다르면 override. 본문에서 못 찾으면 backend 값 유지. */
/* anchor regex —
   ⚠️ 콜론(:) 을 **필수** 로 한다. 이전엔 [:：]? 로 optional 이라, 계약서
   본문의 prose mention ("임차보증금 및 차임을 아래와 같이 지불한다") 까지
   anchor 로 잡혀서 tail 이 다음 줄의 보증금 ₩20,000,000 으로 흘러들어가
   월세=2,000만원 오인 추출 회귀가 있었음.

   field 표기 ("차임 : 금 850,000원") 만 잡도록 콜론 강제 + "금 " 프리픽스
   optional. (?:\s*\([^)]{1,10}\))? 로 "차임(월세)" 같은 alias 괄호 허용. */
const _RENT_ANCHOR_RE = /(?:차\s*임|월\s*세|월\s*임대료|월\s*임\s*차료)(?:\s*\([^)]{1,10}\))?\s*[:：]\s*(?:금\s*)?/g;
function _extractRentFromText(text) {
  if (!text) return null;
  let bestVal = -1;
  let bestLabel = null;
  let m;
  // 명시적 "월세 없음" 표현 — 차임 anchor 뒤 50자 안에 "영원정/₩0/해당 없음/없음"
  // 이 나오면 확실히 없음. 이게 없으면 아래 금액 추출 로직으로.
  // 전세 계약서에서 차임이 0 으로 표기되는 정상 케이스 — "확인 필요" 가 아니라 "없음" 이 맞음.
  const NONE_PAT = /(?:영\s*원\s*정|[₩\\]\s*0\b|금\s*0\s*원|해당\s*없음|없음)/;
  _RENT_ANCHOR_RE.lastIndex = 0;
  let foundNone = false;
  while ((m = _RENT_ANCHOR_RE.exec(text)) !== null) {
    const tail = text.slice(m.index + m[0].length, m.index + m[0].length + 80);
    if (NONE_PAT.test(tail)) {
      foundNone = true;
    }
    // ₩ 표기 우선
    const wonM = tail.match(/[₩\\]\s*([\d,]{3,15})/);
    if (wonM) {
      const v = parseInt(wonM[1].replace(/,/g, ""), 10);
      if (!isNaN(v) && v >= 1000 && v > bestVal) {
        bestVal = v;
        bestLabel = "₩" + wonM[1];
      }
    }
    // "금 X원정" / "X만원" 형태
    const numM = tail.match(/(\d[\d,]{0,12})\s*(만\s*원|원)/);
    if (numM) {
      const numStr = numM[1].replace(/,/g, "");
      const unit = numM[2];
      let v = parseInt(numStr, 10);
      if (!isNaN(v)) {
        if (unit.indexOf("만") >= 0) v *= 10000;
        if (v > bestVal) {
          bestVal = v;
          bestLabel = numM[1].trim() + unit.replace(/\s/g, "");
        }
      }
    }
  }
  // 유효 금액이 안 잡혔고 명시적 none 패턴은 잡혔으면 "없음" 반환.
  // 전세 계약서의 "차임: 금 영원정 (₩0)" 같은 정상 케이스 커버.
  if (!bestLabel && foundNone) {
    return "없음";
  }
  return bestLabel;
}
/* 값에서 숫자만 뽑아 정규화 (₩, 콤마, 만원 변환) — 두 KeyFact 가 같은 금액
   인지 비교할 때 사용. */
function _normRentNumeric(val) {
  if (!val) return 0;
  const s = String(val).trim();
  let best = 0;
  // ₩ 표기
  const wonRe = /[₩\\]\s*([\d,]{3,15})/g;
  let m;
  while ((m = wonRe.exec(s)) !== null) {
    const v = parseInt(m[1].replace(/,/g, ""), 10);
    if (!isNaN(v) && v > best) best = v;
  }
  // X만원 / X원
  const numRe = /(\d[\d,]{0,12})\s*(만\s*원|원)/g;
  while ((m = numRe.exec(s)) !== null) {
    let v = parseInt(m[1].replace(/,/g, ""), 10);
    if (isNaN(v)) continue;
    if (m[2].indexOf("만") >= 0) v *= 10000;
    if (v > best) best = v;
  }
  return best;
}
function _overrideRentFromText(keyFacts, extractedText) {
  if (!Array.isArray(keyFacts)) return keyFacts;
  const isRent = (lbl) => {
    const l = (lbl || "").trim();
    return l === "월세" || l === "차임" || l === "월 차임" || l === "월 임대료" || l === "임대료";
  };
  const isDeposit = (lbl) => {
    const l = (lbl || "").trim();
    return l === "보증금/전세금" || l === "보증금" || l === "전세금";
  };
  /* 1) 본문에서 직접 추출 시도 — 성공하면 무조건 그걸로 교체. */
  if (extractedText) {
    const fromText = _extractRentFromText(extractedText);
    if (fromText) {
      try { console.log("[월세 safety net] text→", fromText); } catch (_) {}
      const cleaned = keyFacts.filter((kf) => !isRent(kf?.label));
      cleaned.push({ label: "월세", value: fromText, meta: "" });
      return cleaned;
    }
  }
  /* 2) 본문 추출 실패 — LLM 값 검증.
        월세 == 보증금 같은 명백한 LLM hallucination 패턴을 잡아낸다.
        실제 임대차에서 월세가 보증금과 같은 액수일 일은 없음 (월 1,500만원
        월세는 비현실적). 이런 케이스는 LLM 이 보증금 값을 잘못 복사한 것
        으로 보고 "확인 필요" 로 교체. */
  const rentEntry = keyFacts.find((kf) => isRent(kf?.label));
  if (!rentEntry) return keyFacts; // 월세 자체가 없으면 그대로
  const rentVal = _normRentNumeric(rentEntry.value);
  if (rentVal <= 0) return keyFacts; // 숫자 없는 값 ("없음" 등) 은 그대로
  const depositEntry = keyFacts.find((kf) => isDeposit(kf?.label));
  if (depositEntry) {
    const depVal = _normRentNumeric(depositEntry.value);
    if (depVal > 0 && rentVal === depVal) {
      try { console.log("[월세 safety net] LLM 월세=보증금 동일→ hallucination, 확인 필요"); } catch (_) {}
      const cleaned = keyFacts.filter((kf) => !isRent(kf?.label));
      cleaned.push({ label: "월세", value: "확인 필요", meta: "" });
      return cleaned;
    }
  }
  /* 3) 추가 sanity: 월세가 비현실적으로 큰 경우 (500만원 이상) 도 의심.
        실주거 월세는 200만원 이하가 대부분. 500만원 넘으면 보증금/잔금 계열
        수치를 LLM 이 잘못 가져왔을 가능성이 큼. */
  if (rentVal >= 5000000) {
    try { console.log("[월세 safety net] 비현실적으로 큰 월세→ 확인 필요:", rentVal); } catch (_) {}
    const cleaned = keyFacts.filter((kf) => !isRent(kf?.label));
    cleaned.push({ label: "월세", value: "확인 필요", meta: "" });
    return cleaned;
  }
  return keyFacts;
}

function _overrideMgmtFromText(keyFacts, extractedText) {
  if (!Array.isArray(keyFacts)) return keyFacts;
  const isMgmt = (lbl) => {
    const l = (lbl || "").trim();
    return l.indexOf("관리비") >= 0 || l.indexOf("관리료") >= 0
      || l.indexOf("관리 비용") >= 0 || l.indexOf("관리비용") >= 0;
  };
  /* 1) 본문에서 직접 추출 시도. 성공하면 무조건 그걸로 교체. */
  if (extractedText) {
    const fromText = _extractMgmtFromText(extractedText);
    if (fromText) {
      // 디버깅용 마커 — devtools 에서 safety net 이 작동했는지 확인 가능
      try { console.log("[관리비 safety net] text→", fromText); } catch (_) {}
      const cleaned = keyFacts.filter((kf) => !isMgmt(kf?.label));
      cleaned.push({ label: "관리비", value: fromText, meta: "" });
      return cleaned;
    }
  }
  /* 2) 본문 추출 실패 케이스 — LLM 값의 숫자 토큰이 실제 본문에 등장하는지
        verify. 없으면 LLM hallucination 으로 보고 "확인 필요" 로 교체.
        특히 "1만원" 같이 본문에 절대 없는 환상값을 잡아내기 위함. */
  if (!extractedText) return keyFacts; // 본문 자체가 없으면 override 불가
  const mgmtEntries = keyFacts.filter((kf) => isMgmt(kf?.label));
  if (mgmtEntries.length === 0) return keyFacts; // 관리비 자체가 없으면 그대로
  const verifiable = (val) => {
    if (!val) return true; // 빈 값은 수정 안 함
    const v = String(val).trim();
    if (["없음", "확인 필요", "확인필요", "미정", "해당없음", "해당 없음", "별도 협의"].indexOf(v) >= 0) {
      return true; // placeholder 는 OK
    }
    // 값 안의 숫자+단위 토큰 추출 (예: "1만원" → "1만원" 자체를 본문에서 찾기)
    const tokens = [];
    const tokRe = /(\d[\d,]{0,12})\s*(만\s*원|원|만원)/g;
    let tm;
    while ((tm = tokRe.exec(v)) !== null) {
      const raw = tm[0].replace(/\s+/g, "");
      tokens.push(raw);
    }
    if (tokens.length === 0) return true; // 숫자 토큰 없으면 수정 안 함
    // 본문에서 관리비 anchor 주변 ±100자 안에 tokens 중 하나라도 있는지 확인
    const anchorRe = /관\s*리\s*비(?:용)?|관\s*리\s*료/g;
    anchorRe.lastIndex = 0;
    let am;
    while ((am = anchorRe.exec(extractedText)) !== null) {
      const win = extractedText
        .slice(Math.max(0, am.index - 100), am.index + 220)
        .replace(/\s+/g, "");
      for (const tk of tokens) {
        if (win.indexOf(tk) >= 0) return true;
      }
    }
    return false; // 본문 어디에서도 이 숫자를 찾을 수 없음 → hallucination
  };
  const allOk = mgmtEntries.every((kf) => verifiable(kf.value));
  if (!allOk) {
    try { console.log("[관리비 safety net] unverifiable→ 확인 필요"); } catch (_) {}
    const cleaned = keyFacts.filter((kf) => !isMgmt(kf?.label));
    cleaned.push({ label: "관리비", value: "확인 필요", meta: "" });
    return cleaned;
  }
  return keyFacts;
}

function toResultData(analysis) {
  if (!analysis) return null;
  const issues = (analysis.issues || []).map((iss, i) => withIssueCompat(iss, i));

  const counts = issues.reduce((acc, iss) => {
    acc[iss.severity] = (acc[iss.severity] || 0) + 1;
    return acc;
  }, { high: 0, medium: 0, low: 0 });

  const legalCounts = issues.reduce((acc, iss) => {
    const k = iss.legal_status || "negotiable";
    acc[k] = (acc[k] || 0) + 1;
    return acc;
  }, { void: 0, unfair: 0, negotiable: 0 });

  const tone = counts.high > 0 ? "alert" : counts.medium > 0 ? "warn" : "safe";
  const verdictTitle = analysis.summary
    ? analysis.summary.trim()
    : (counts.high > 0 ? "위험한 조항이 보여요." : counts.medium > 0 ? "주의할 조항이 있어요." : "크게 걸리는 부분은 없어요.");

  /* 빠진 조항(missing_items) 은 importance("중요" > "권장") 순으로 정렬해서
     사용자가 위에서부터 읽어도 가장 우선순위 높은 항목을 먼저 보게 한다.
     원본 LLM 출력 순서가 들쭉날쭉이라 같은 importance 안에서는 원래 순서 보존. */
  const MISSING_IMPORTANCE_RANK = { "중요": 0, "필수": 0, "권장": 1, "선택": 2 };
  const cleanedMissing = (analysis.missing_items || [])
    .map((m, i) => ({
      ...m,
      reason: trimMissingReason(m.reason || ""),
      _idx: i,  // stable sort 폴백
    }))
    .sort((a, b) => {
      const ar = MISSING_IMPORTANCE_RANK[a.importance] ?? 1;
      const br = MISSING_IMPORTANCE_RANK[b.importance] ?? 1;
      if (ar !== br) return ar - br;
      return a._idx - b._idx;
    })
    .map(({ _idx, ...rest }) => rest);

  return {
    contract_type: analysis.contract_type,
    contract_type_label: analysis.contract_type_label,
    verdict_tone: tone,
    verdict_title: verdictTitle,
    risk_totals: counts,
    legal_totals: legalCounts,
    summary_meta: {
      pages: analysis.page_count || 1,
      char_count: analysis.char_count || 0,
    },
    issues,
    key_facts: sortKeyFacts(_overrideMgmtFromText(
      _overrideRentFromText(
        (analysis.key_facts || []).map((kf) => ({ label: kf.label, value: kf.value, meta: "" })),
        analysis.extracted_text || analysis.text_preview || ""
      ),
      analysis.extracted_text || analysis.text_preview || ""
    )),
    /* 백엔드 numeric_audits — "임대차보호법 2년 보장권" 같은 자동 안내 항목.
       Result 화면에서 별도 섹션으로 노출. */
    numeric_audits: (analysis.numeric_audits || []).map((na) => ({
      check: na.check || "",
      status: na.status || "warning",
      detail: na.detail || "",
      severity: na.severity || "medium",
    })),
    redflags: [], // 서버 엔드포인트 없음 — 빈 배열로 섹션 숨김
    missing_items: cleanedMissing,
    revision_suggestions: analysis.revision_suggestions || [],
    recommended_questions: analysis.recommended_questions || [],
    next_steps: analysis.next_steps || [],
    warnings: analysis.warnings || [],
    disclaimer: analysis.disclaimer || "",
    text_preview: analysis.text_preview || "",
    extracted_text: analysis.extracted_text || analysis.text_preview || "",
    is_real: true,
  };
}
window.toResultData = toResultData;

/* ----------------------------------------------------------------------------
 * AnalysisHistoryItem → 홈/기록 탭의 '최근 분석' 카드 shape으로 변환.
 * 기존 mock: { id, type, title, sev, high, when } 를 그대로 유지하되
 * analysis(원본 ContractAnalysisResponse)를 함께 담아 상세 진입 시 재사용한다.
 * -------------------------------------------------------------------------- */
function historyItemType(item) {
  const ct = item?.result?.contract_type || "lease";
  if (ct === "employment" || ct === "freelancer") return "labor";
  const hay = `${item.filename || ""} ${item.result?.contract_type_label || ""} ${item.summary || ""}`.toLowerCase();
  if (hay.includes("월세") || hay.includes("wolsae")) return "wolsae";
  return "jeonse";
}

function historyItemHighCount(item) {
  const issues = item?.result?.issues || [];
  return issues.filter((i) => i.severity === "high").length;
}

/* 카드 라벨·색은 **순수 legal_status 분류** 로만 결정.
   이전엔 numericMismatch (잔금일≠인도일, 위약금 연환산 등 숫자 audit mismatch)
   를 voidCnt 에 합쳐서 라벨이 "법 위반 N건" + 썸네일 alert(빨강) 로 올라갔지만,
   숫자 불일치는 법 위반이 아니라 "확인 필요" 수준이라 카테고리가 잘못 섞임.
   회귀 사례: "불공정 1건" 이슈 + 잔금일 mismatch 1건 → 라벨 "법 위반 2건"
   으로 왜곡되고 썸네일 빨강. 홈 목록 전체가 빨강으로 보이는 원인.

   새 우선순위 (Result hero 의 heroKind 와 일치):
     ① void > 0             → 법 위반 (alert 빨강)
     ② unfair > 0           → 불공정 (warn 주황)
     ③ negotiable > 0       → 협상 대상 (safe 초록)
     ④ numericMismatch > 0  → 확인할 항목 (warn 주황, 빨강 아님)
     ⑤ high severity 폴백   → 위험 (alert)
     ⑥ medium severity 폴백 → 주의 (warn)
     ⑦ missing items        → 체크해야 할 항목 (warn)
     ⑧ 모두 0              → 이슈 없음 (safe)
*/
function _historyLegalCounts(item) {
  const issues = item?.result?.issues || [];
  return {
    voidCnt: issues.filter((i) => i.legal_status === "void").length,
    unfairCnt: issues.filter((i) => i.legal_status === "unfair").length,
    negotiableCnt: issues.filter((i) => i.legal_status === "negotiable").length,
    numericMismatch: (item?.result?.numeric_audits || [])
      .filter((na) => (na?.status || "").toLowerCase() === "mismatch").length,
    high: issues.filter((i) => i.severity === "high").length,
    med: issues.filter((i) => i.severity === "medium").length,
    missing: (item?.result?.missing_items || []).length,
  };
}
function historyItemKind(item) {
  const c = _historyLegalCounts(item);
  if (c.voidCnt > 0) return { label: "법 위반", count: c.voidCnt };
  if (c.unfairCnt > 0) return { label: "불공정", count: c.unfairCnt };
  if (c.negotiableCnt > 0) return { label: "협상 대상", count: c.negotiableCnt };
  if (c.numericMismatch > 0) return { label: "확인할 항목", count: c.numericMismatch };
  if (c.high > 0) return { label: "위험", count: c.high };
  if (c.med > 0) return { label: "주의", count: c.med };
  if (c.missing > 0) return { label: "참고하면 좋은 항목", count: c.missing };
  return { label: null, count: 0 };
}

function historyItemSev(item) {
  const c = _historyLegalCounts(item);
  if (c.voidCnt > 0) return "alert";
  if (c.unfairCnt > 0) return "warn";
  if (c.negotiableCnt > 0) return "safe";
  if (c.numericMismatch > 0) return "warn"; // 숫자 불일치는 주황 (빨강 아님)
  // legal 분류가 모두 0 인 heuristic-only 케이스 — severity 폴백
  return c.high > 0 ? "alert" : c.med > 0 ? "warn" : "safe";
}

function formatHistoryWhen(iso, { relative = false } = {}) {
  if (!iso) return "";
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return "";
    const now = new Date();
    const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
    const startOfYesterday = new Date(startOfToday.getTime() - 24 * 3600 * 1000);
    if (relative && d >= startOfToday) {
      const h = d.getHours();
      const m = d.getMinutes();
      const ap = h < 12 ? "오전" : "오후";
      const hh = ((h + 11) % 12) + 1;
      const mm = m.toString().padStart(2, "0");
      return `오늘 ${ap} ${hh}:${mm}`;
    }
    if (relative && d >= startOfYesterday) {
      return "어제";
    }
    return `${d.getMonth() + 1}월 ${d.getDate()}일`;
  } catch (_) {
    return "";
  }
}

/* 홈 카드의 제목은 파일명을 그대로 쓰면 "KakaoTalk_Photo_2026-04-15-11-05-19.jpeg"
 * 같이 의미 없는 꼬리가 길게 붙거나 "r5_jeonse_redflag_pack" 같이 영문 픽스처
 * 이름이 그대로 노출돼 한국 사용자에게 어색하다. 다음 규칙으로 다듬는다:
 *   - KakaoTalk_Photo_YYYY-MM-DD-HH-MM-SS.ext → "카톡 사진 (M월 D일)"
 *   - Screenshot_YYYY-MM-DD...                → "스크린샷 (M월 D일)"
 *   - IMG_xxxx.*                              → "사진 IMG_xxxx"
 *   - 한글이 한 글자도 없는 영문/숫자 fixture 이름은 키워드로 한글 라벨 매핑
 *     (jeonse → 전세 계약서, oneroom → 원룸 전세 계약서, wolsae → 월세 계약서, …)
 *   - 나머지는 확장자 제거. 24자 넘으면 앞부분 + …
 */
function prettifyFilename(raw) {
  const name = (raw || "").trim();
  if (!name) return "계약서";
  // 확장자 분리 (마지막 점만)
  const dot = name.lastIndexOf(".");
  const stem = dot > 0 ? name.slice(0, dot) : name;

  // 카카오톡 자동 파일명
  const kakao = stem.match(/^KakaoTalk(?:_Photo|_Moim|_\w+)?_(\d{4})-(\d{2})-(\d{2})/i);
  if (kakao) {
    const [, , mm, dd] = kakao;
    return `카톡 사진 (${parseInt(mm, 10)}월 ${parseInt(dd, 10)}일)`;
  }
  // iOS / Android 스크린샷
  const shot = stem.match(/^(?:Screenshot|스크린샷|화면\s*캡처)[_\- ]?(\d{4})[-_]?(\d{2})[-_]?(\d{2})/i);
  if (shot) {
    const [, , mm, dd] = shot;
    return `스크린샷 (${parseInt(mm, 10)}월 ${parseInt(dd, 10)}일)`;
  }
  // IMG_1234
  if (/^IMG_\d+$/i.test(stem)) return `사진 ${stem}`;

  /* 영문 fixture 이름 한글화 — r5_jeonse_redflag_pack, r1_oneroom_v2 같은 테스트
   * 픽스처가 운영 데이터에 섞여 들어와도 한국어 화면 톤이 깨지지 않게 한다. */
  const hasHangul = /[\uAC00-\uD7A3]/.test(stem);
  if (!hasHangul) {
    const lower = stem.toLowerCase();
    /* 키워드 우선순위: 더 구체적인 라벨 먼저. oneroom → 원룸, jeonse → 전세, wolsae → 월세, employment/labor → 근로 */
    if (/oneroom|원룸|studio/.test(lower))            return "원룸 전세 계약서";
    if (/tworoom|투룸|two[\-_ ]?room/.test(lower))    return "투룸 계약서";
    if (/wolsae|월세|monthly[\-_ ]?lease/.test(lower)) return "월세 계약서";
    if (/jeonse|전세|key[\-_ ]?money/.test(lower))    return "전세 계약서";
    if (/employ|labor|근로|worker/.test(lower))       return "근로 계약서";
    if (/freelance|프리랜서/.test(lower))              return "프리랜서 계약서";
    if (/lease|rental|rent|임대/.test(lower))         return "임대차 계약서";
    if (/contract|agreement|계약/.test(lower))        return "계약서";
    /* 그래도 매칭이 없으면 — 영문 그대로보단 일반 라벨이 낫다 */
    return "계약서";
  }

  // 기본: 확장자 뗀 stem. 너무 길면 앞 22자 + …
  if (stem.length <= 24) return stem;
  return `${stem.slice(0, 22)}…`;
}

/* 카드 제목 결정 — 파일명이 generic("계약서") 이거나 비어있으면 분석 결과에서
   더 구체적인 라벨("월세 계약서", "전세 계약서", "근로계약서") 추론.
   backend 의 contract_type 이 "lease" 한 가지라 전세/월세 구분이 안 됨 →
   summary/key_facts/파일명 키워드로 프런트에서 분리. */
const _GENERIC_TITLE_RE = /^(계약서|lease|rental|contract|agreement|file|document|문서|.{0,2})$/i;
function deriveContractKindLabel(item) {
  const ct = item?.result?.contract_type;
  if (ct === "employment") return "근로계약서";
  if (ct === "freelancer") return "프리랜서 계약서";
  if (ct === "general") return "일반 계약서";
  // lease — summary/key_facts/filename 키워드로 월세/전세 분리
  const summary = item?.result?.summary || "";
  const keyFacts = (item?.result?.key_facts || []).map((kf) => `${kf?.label || ""}:${kf?.value || ""}`).join(" ");
  const filename = item?.filename || "";
  const hay = `${summary} ${keyFacts} ${filename}`.toLowerCase();
  if (hay.includes("월세") || hay.includes("월 차임") || hay.includes("월차임") || hay.includes("monthly")) return "월세 계약서";
  if (hay.includes("전세")) return "전세 계약서";
  return "임대차 계약서";
}
function smartTitle(item) {
  const filename = prettifyFilename(item.filename || "");
  if (!filename || _GENERIC_TITLE_RE.test(filename)) {
    return deriveContractKindLabel(item);
  }
  return filename;
}

function historyToRecent(item, opts) {
  const kind = historyItemKind(item);
  return {
    id: item.history_id || item.local_id || `h-${Math.random().toString(36).slice(2, 8)}`,
    type: historyItemType(item),
    title: smartTitle(item),
    /* 카드 라벨용 — legal_status 우선순위 ("불공정 1건" 등). high 만 보던
       기존 r.high 보다 정확. Home/HistoryAccount 카드에서 사용. */
    kindLabel: kind.label,
    kindCount: kind.count,
    // 홈 위젯의 '파일명당 최신 1개' 접기 로직이 raw 파일명 기준으로 돌도록
    // 원본 파일명을 함께 실어둔다. prettifyFilename 은 '카톡 사진 (4월 21일)'
    // 처럼 서로 다른 파일을 같은 라벨로 접을 수 있어 접기 기준으로는 부적절.
    filename: item.filename || "",
    sev: historyItemSev(item),
    high: historyItemHighCount(item),
    when: formatHistoryWhen(item.created_at, opts),
    analysis: item.result || null,
    history_id: item.history_id || null,
    // 로컬 전용 기록은 history_id 가 없음. 삭제 시 로컬 캐시에서 빼낼 수 있도록
    // local_id 도 별도 필드로 보존한다 (편집 모드에서 사용).
    local_id: item.local_id || null,
  };
}

function historyToRecents(items, opts = {}) {
  return (items || []).map((it) => historyToRecent(it, opts));
}
window.historyToRecents = historyToRecents;

/* -------------------- dataUri / 이미지 → File 변환 --------------------
 * 토스 카메라/앨범 API 는 { id, dataUri, mimeType? } 형태로 응답한다.
 * /analyze 는 multipart File 을 기대하므로 중간 변환 함수가 필요하다.
 *   - base64: true 로 요청했으면 dataUri 는 "iVBOR..." (raw base64) 이므로
 *     mimeType 접두사를 붙여 Blob 으로 디코딩한다.
 *   - base64: false 로 요청했으면 dataUri 는 file:// 또는 data: URL. Blob 변환은
 *     이 경로에서는 지원하지 않는다 (프레임워크가 실제 파일 경로를 돌려주기 때문).
 */
function dataUriToFile(dataUri, fileName) {
  try {
    if (!dataUri) return null;
    // Full data URL
    const m = /^data:([^;]+);base64,(.+)$/.exec(dataUri);
    let mime = "image/jpeg";
    let b64;
    if (m) {
      mime = m[1];
      b64 = m[2];
    } else if (/^[A-Za-z0-9+/=\s]+$/.test(dataUri.slice(0, 200))) {
      // Raw base64 (no prefix)
      b64 = dataUri.replace(/\s/g, "");
    } else {
      // 실제 파일 경로(file://, content://)가 반환된 경우 → 블롭 변환 불가
      return null;
    }
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return new File([bytes], fileName || suggestCameraFilename({ mimeType: mime }), { type: mime });
  } catch (err) {
    console.warn("[dataUriToFile] failed:", err);
    return null;
  }
}

function suggestCameraFilename(image) {
  const mime = image?.mimeType || "image/jpeg";
  const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg";
  const ts = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
  return `계약서_${ts}.${ext}`;
}
window.dataUriToFile = dataUriToFile;

/* -------------------- Landing -------------------- */
function Landing({ onLogin, onSignup, onEnterApp }) {
  return (
    <div className="web-landing">
      <header className="web-landing__nav">
        <BrandLockup size={22} color="var(--brand)" gap={8} />
        <div className="web-landing__nav-actions">
          <button className="web-landing__nav-link" onClick={onLogin}>로그인</button>
          <button className="web-landing__nav-link web-landing__nav-link--primary" onClick={onSignup}>
            무료로 시작하기
          </button>
        </div>
      </header>

      <section className="web-landing__hero">
        <div>
          <div className="web-landing__eyebrow">
            <IOSIcon name="sparkle" size={14} /> AI가 먼저 읽어보는 계약서
          </div>
          <h1 className="web-landing__title">
            서명 전에,<br />
            계약서 위험을 먼저 봐요.
          </h1>
          <p className="web-landing__sub">
            전세·월세·근로 계약서를 올리면 AI가 위험한 조항을 찾고,
            법적 근거와 함께 쉬운 말로 풀어드려요.
          </p>
          <div className="web-landing__cta-row">
            <button className="web-landing__cta" onClick={onSignup}>
              무료로 한 건 분석하기
              <IOSIcon name="arrow-right" size={15} weight={2.2} />
            </button>
            <button className="web-landing__cta web-landing__cta--ghost" onClick={onEnterApp}>
              데모 둘러보기
            </button>
          </div>
          <div className="web-landing__trust">
            <span>개인정보 암호화</span>
            <span className="web-landing__trust-dot" />
            <span>분석 끝나면 파일은 바로 지워요</span>
            <span className="web-landing__trust-dot" />
            <span>가입 즉시 1회 무료</span>
          </div>
        </div>

        <div className="web-landing__art">
          <div className="web-landing__art-bg" />
          <div className="web-landing__art-card">
            <div style={{ fontSize: 12, color: "var(--label-secondary)", opacity: 0.6, fontWeight: 600, marginBottom: 4 }}>
              AI 분석 결과
            </div>
            <div className="web-landing__art-line" style={{ width: "70%", height: 12, background: "var(--label)" }} />
            <div className="web-landing__art-line" style={{ width: "40%" }} />
            <div style={{ height: 10 }} />
            <div className="web-landing__art-line web-landing__art-line--alert" style={{ width: "85%" }} />
            <div className="web-landing__art-line web-landing__art-line--warn" style={{ width: "65%" }} />
            <div className="web-landing__art-line" style={{ width: "90%" }} />
            <div className="web-landing__art-line" style={{ width: "55%" }} />
          </div>
          <div className="web-landing__art-seal">
            <svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="11" cy="11" r="6.5" />
              <path d="m20 20-4-4" />
            </svg>
          </div>
        </div>
      </section>

      <section className="web-landing__section">
        <div className="web-landing__section-inner">
          <h2 className="web-landing__section-title">계약서를 넘겨주세요. 제가 먼저 훑어볼게요.</h2>
          <p className="web-landing__section-sub">
            AI가 조항 하나하나를 법령과 판례에 비춰 위험도를 매기고, 고치면 좋은 포인트까지 정리해 드려요.
          </p>
          <div className="web-landing__features">
            <div className="web-landing__feature">
              <div className="web-landing__feature-ic">
                <IOSIcon name="shield-exclam" size={20} weight={2} />
              </div>
              <h3 className="web-landing__feature-h">위험 조항을 자동으로 찾아요</h3>
              <p className="web-landing__feature-p">
                갱신청구권 포기, 보증금 반환 거부, 수선 의무 떠넘기기 같은 6개 큰 항목에서 100여 개 규칙을 살펴봐요.
              </p>
            </div>
            <div className="web-landing__feature">
              <div className="web-landing__feature-ic">
                <IOSIcon name="doc" size={20} weight={2} />
              </div>
              <h3 className="web-landing__feature-h">원문과 바로 비교해서 볼 수 있어요</h3>
              <p className="web-landing__feature-p">
                조항 카드를 누르면 원본 계약서의 해당 위치로 바로 넘어가요. 어떤 문장이 문제인지 한눈에 보여요.
              </p>
            </div>
            <div className="web-landing__feature">
              <div className="web-landing__feature-ic">
                <IOSIcon name="shield-check" size={20} weight={2} />
              </div>
              <h3 className="web-landing__feature-h">법 근거와 바꿀 말까지 알려드려요</h3>
              <p className="web-landing__feature-p">
                주택임대차보호법이나 근로기준법 같은 법 조문을 보여주고, 상대에게 어떻게 바꿔 달라고 할지 문구까지 제안해 드려요.
              </p>
            </div>
          </div>
        </div>
      </section>

      <footer className="web-landing__footer">
        © 2026 서명전에 · 이 서비스는 법률 자문을 대신하지 않아요.
      </footer>
    </div>
  );
}

/* -------------------- Auth -------------------- */
/* 앱인토스 미니앱 정책 6.1: 외부 소셜 로그인(카카오/구글 등) 미지원.
   이메일 로그인만 렌더하고, 향후 토스 로그인 SDK 연동은 별도 슬롯으로 추가한다.
   `emailAuthOnly` prop은 하위호환용으로 남겨 두되 값과 무관하게 소셜 버튼은 제거한다. */
function Auth({ mode, onSwitch, onSubmit, onClose, errorMessage, busy, emailAuthOnly }) {
  void emailAuthOnly;
  const [email, setEmail] = useStateW("");
  const [pw, setPw] = useStateW("");
  const isSignup = mode === "signup";
  return (
    <div className="web-auth">
      <div className="web-auth__card">
        <div className="web-auth__brand">
          <BrandLockup size={22} color="var(--brand)" gap={8} />
        </div>
        <h2 className="web-auth__title">{isSignup ? "무료로 시작하기" : "다시 만나 반가워요"}</h2>
        <p className="web-auth__sub">
          {isSignup ? "가입하면 바로 1회 무료 분석 크레딧을 드려요." : "이메일로 로그인해 주세요."}
        </p>

        {errorMessage && (
          <div style={{
            padding: "10px 12px",
            borderRadius: 10,
            background: "var(--alert-soft, rgba(255,59,48,0.08))",
            color: "var(--alert)",
            fontSize: 13,
            lineHeight: 1.45,
            marginBottom: 10,
            border: "0.5px solid rgba(255,59,48,0.2)",
          }}>
            {errorMessage}
          </div>
        )}

        <form onSubmit={(e) => { e.preventDefault(); if (!busy) onSubmit({ email, pw }); }}>
          <div className="web-auth__field">
            <label className="web-auth__label">이메일</label>
            <input
              className="web-auth__input"
              type="email"
              placeholder="you@example.com"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              disabled={busy}
              autoComplete="email"
              required
            />
          </div>
          <div className="web-auth__field">
            <label className="web-auth__label">비밀번호</label>
            <input
              className="web-auth__input"
              type="password"
              placeholder={isSignup ? "최소 8자로 만들어 주세요" : "비밀번호를 입력해 주세요"}
              value={pw}
              onChange={(e) => setPw(e.target.value)}
              disabled={busy}
              autoComplete={isSignup ? "new-password" : "current-password"}
              minLength={isSignup ? 8 : undefined}
              required
            />
          </div>
          <button
            className="web-auth__submit"
            type="submit"
            disabled={busy}
            style={busy ? { opacity: 0.7, cursor: "wait" } : undefined}
          >
            {busy ? "잠시만요..." : (isSignup ? "계정 만들기" : "로그인")}
          </button>
        </form>

        {/* 앱인토스 미니앱은 토스 로그인만 허용. 외부 OAuth(카카오/구글) 버튼은 정책 6.1에 따라 제거됨.
            미니앱 토스 로그인 SDK가 붙으면 이 자리에 단일 "토스로 계속하기" 버튼을 추가한다. */}

        <div className="web-auth__foot">
          {isSignup ? (
            <>이미 계정이 있어요? <button className="web-auth__foot-link" onClick={() => onSwitch("login")}>로그인</button></>
          ) : (
            <>처음이에요? <button className="web-auth__foot-link" onClick={() => onSwitch("signup")}>무료로 시작하기</button></>
          )}
        </div>
        <div style={{ textAlign: "center", marginTop: 10 }}>
          <button className="web-auth__foot-link" onClick={onClose} style={{ fontSize: 13, fontWeight: 500, opacity: 0.7 }}>
            홈으로
          </button>
        </div>
      </div>
    </div>
  );
}

/* -------------------- Tweaks -------------------- */
function WebTweaks({ open, onClose, scenario, onScenario, theme, onTheme, onJump }) {
  /* 앱인토스 비게임 정책: 프로덕션 번들에는 노출 금지. 로컬·preview·staging 또는
     명시적 window.__DEV_TWEAKS__ 플래그가 있을 때만 렌더한다. */
  const _allowed = (() => {
    if (typeof window === "undefined") return false;
    const host = (window.location?.hostname || "").toLowerCase();
    if (!host) return false;
    if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0") return true;
    if (host.endsWith(".local")) return true;
    if (host.includes("preview") || host.includes("staging") || host.includes("vercel.app")) return true;
    return !!(window).__DEV_TWEAKS__;
  })();
  if (!_allowed) return null;
  if (!open) return null;
  return (
    <div style={{
      position: "fixed", right: 20, bottom: 20, width: 300,
      background: "var(--surface)", color: "var(--label)",
      borderRadius: 18, boxShadow: "0 20px 60px rgba(0,0,0,0.22)",
      zIndex: 9999, overflow: "hidden", fontFamily: "var(--font)",
      border: "1px solid var(--separator)",
    }}>
      <div style={{ padding: "12px 14px 10px", borderBottom: "0.5px solid var(--separator)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <div style={{ fontSize: 15, fontWeight: 600 }}>Tweaks</div>
        <button onClick={onClose} style={{ width: 26, height: 26, border: 0, borderRadius: 13, background: "var(--fill-4)", cursor: "pointer", display: "grid", placeItems: "center" }}>
          <IOSIcon name="x" size={13} weight={2.2} />
        </button>
      </div>
      <div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 14 }}>
        <section>
          <div style={{ fontSize: 11, fontWeight: 600, color: "var(--label-secondary)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 6, opacity: 0.7 }}>계약서 종류</div>
          <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 6 }}>
            {[["jeonse","전세"],["wolsae","월세"],["labor","근로"]].map(([id, label]) => {
              const active = scenario === id;
              return (
                <button key={id} onClick={() => onScenario(id)}
                  style={{ padding: "8px 0", borderRadius: 9, background: active ? "var(--brand)" : "var(--fill-4)", color: active ? "#fff" : "var(--label)", border: 0, cursor: "pointer", fontSize: 13, fontWeight: 600, fontFamily: "inherit" }}>
                  {label}
                </button>
              );
            })}
          </div>
        </section>
        <section>
          <div style={{ fontSize: 11, fontWeight: 600, color: "var(--label-secondary)", textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 6, opacity: 0.7 }}>바로가기</div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
            {[
              ["landing","랜딩"], ["login","로그인"], ["signup","가입"],
              ["home","홈"], ["history","기록"], ["result","결과"],
              ["preview","원문"], ["account","계정"], ["help","도움말"],
            ].map(([id, label]) => (
              <button key={id} onClick={() => onJump(id)}
                style={{ padding: "8px 6px", borderRadius: 8, background: "var(--fill-4)", color: "var(--label)", border: 0, cursor: "pointer", fontSize: 12.5, fontWeight: 500, fontFamily: "inherit" }}>
                {label}
              </button>
            ))}
          </div>
        </section>
        {/* 앱인토스 비게임 정책: 라이트 모드 전용 → 테마 선택 섹션 제거 */}
      </div>
    </div>
  );
}

/* -------------------- Main Controller -------------------- */
function WebApp() {
  const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
    "scenario": "jeonse",
    "theme": "light",
    "route": "landing",
    "credits": 1,
    "sidebarCollapsed": false
  }/*EDITMODE-END*/;

  const [route, setRoute] = useStateW(TWEAK_DEFAULTS.route); // landing | login | signup | app
  const [tab, setTab] = useStateW("home");
  const [stack, setStack] = useStateW([]); // ["capture"|"picker"|"result"|"preview"]
  const [scenario, setScenario] = useStateW(TWEAK_DEFAULTS.scenario);
  /* 앱인토스 비게임 정책(디자인): 라이트 모드 전용. theme 상태를 "light"로 고정.
     기존 setTheme 호출부 호환을 위해 no-op 함수로 대체한다. */
  const theme = "light";
  const setTheme = () => {};
  const [credits, setCredits] = useStateW(TWEAK_DEFAULTS.credits);
  const [sidebarCollapsed, setSidebarCollapsed] = useStateW(!!TWEAK_DEFAULTS.sidebarCollapsed);
  const [analyzing, setAnalyzing] = useStateW(false);
  /* 파일 업로드 확인 modal — 사용자가 파일 선택 직후 "[서명전에] 알림"
     스타일의 우리만의 다이얼로그를 띄운다. native window.confirm 은
     "127.0.0.1 says:" 같은 origin label 을 강제로 보여주는 한계가 있어
     UX/브랜드 측면에서 부적합. 이 modal 은 self-contained inline JSX 로
     렌더해서 외부 컴포넌트 의존성 (ConfirmDialog) 표시 회귀 위험 회피.
     단일 파일(pendingFile) 과 여러 장 이미지(pendingFiles) 두 상태를 두어
     — 단일은 기존 /analyze 플로우, 다수는 /extract-multi → /analyze-text. */
  const [pendingFile, setPendingFile] = useStateW(null);
  const [pendingFiles, setPendingFiles] = useStateW(null);  // File[] — 여러 장 이미지 모드
  const [issueSheet, setIssueSheet] = useStateW(null);
  const [billingSheet, setBillingSheet] = useStateW(false);
  const [uploadSheet, setUploadSheet] = useStateW(false);
  const [focusIssueId, setFocusIssueId] = useStateW(null);
  const [tweaksOpen, setTweaksOpen] = useStateW(false);
  /* G-8: 토스 익명 hash 모드 첫 진입 안내 다이얼로그.
   *  null  = 아직 storage 체크 전 (다이얼로그 안 띄움 — 깜빡임 방지)
   *  true  = 처음 들어온 사용자 → 안내 띄움
   *  false = 이미 봤거나 닫음 → 다시 안 띄움
   * "베타 데이터가 비워졌고, 이제는 토스 로그인으로 자동 인증된다" 한 화면 정리. */
  const [tossIntroOpen, setTossIntroOpen] = useStateW(null);

  // useToast — 분석 결과를 못 불러올 때 사용자에게 사일런트 폴백 대신 알림.
  const _toast = (typeof window !== "undefined" && typeof window.useToast === "function")
    ? window.useToast()
    : null;

  /* 앱인토스 비게임 정책: 개발자 도구(WebTweaks)는 프로덕션 번들에 노출 금지.
     로컬·preview·staging 호스트에서만 활성화하고, 그 외 환경에서는 메시지 브리지가
     `__activate_edit_mode`를 보내도 패널이 떠선 안 된다. */
  const _tweaksAllowed = (() => {
    if (typeof window === "undefined") return false;
    const host = (window.location?.hostname || "").toLowerCase();
    if (!host) return false;
    if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0") return true;
    if (host.endsWith(".local")) return true;
    if (host.includes("preview") || host.includes("staging") || host.includes("vercel.app")) return true;
    // 명시적 dev 플래그 (window.__DEV_TWEAKS__ = true) 가 있을 때만 노출
    return !!(window).__DEV_TWEAKS__;
  })();

  /* 실제 분석 결과 (없으면 scenario 목업으로 폴백) */
  const [analysis, setAnalysis] = useStateW(null);
  const [analysisError, setAnalysisError] = useStateW(null);
  const [analysisPending, setAnalysisPending] = useStateW(false);

  /* 숨김 파일 input — 액션시트에서 '파일 선택' 누르면 여기로 위임 */
  const fileInputRef = useRefW(null);

  /* 실제 Supabase 인증 + 계정 상태 (auth.jsx에서 제공) */
  const auth = (typeof useAuth === "function") ? useAuth() : null;

  /* 인증 모드.
   * toss-anonymous : 앱인토스 표준 — 랜딩/로그인 화면 자체가 없어요. 부트스트랩이
   *                  끝나면 바로 app 쉘로 진입. 사용자 입장에선 "로그인" 개념이
   *                  존재하지 않음.
   * supabase-jwt   : 레거시 — 기존 랜딩 → 로그인/가입 → app 흐름 유지. 마이그레이션
   *                  유예 기간만 사용. */
  const isTossMode = (auth?.authMode || "toss-anonymous") === "toss-anonymous";

  /* 로그인 성공 시 자동으로 앱 쉘로 이동.
     단, 랜딩/로그인/가입 화면에서만 점프 — 이미 app 안이면 그대로 유지.
     toss-anonymous 모드에서는 부트스트랩이 끝나는 순간 자동으로 app 으로 이동. */
  useEffectW(() => {
    if (!auth) return;
    if (auth.ready && auth.session && (route === "landing" || route === "login" || route === "signup")) {
      setRoute("app");
    }
  }, [auth?.ready, auth?.session, route]);

  /* 로그아웃 시 랜딩으로 — supabase-jwt 모드 한정.
     toss-anonymous 모드는 signOut 이 새 hash 로 즉시 재부트스트랩하므로 route 유지. */
  useEffectW(() => {
    if (!auth) return;
    if (isTossMode) return;
    if (auth.ready && !auth.session && route === "app") {
      setRoute("landing");
      setStack([]);
      setTab("home");
    }
  }, [auth?.ready, auth?.session, isTossMode]);

  /* G-8: 토스 hash 모드 첫 진입 안내 다이얼로그 게이팅.
   *  베타 기간(이메일 가입) 사용자들은 마이그레이션으로 데이터가 비워졌다는 걸
   *  명확히 알아야 하고, 신규 사용자는 "별도 가입 없이 자동 인증" 컨셉을 한 번은
   *  안내받아야 동선이 안 흔들린다. 둘 다 동일한 한 화면으로 처리.
   *
   *  타이밍: 부트스트랩이 완전히 끝나고(=auth.ready + auth.session) 토스 모드일 때만.
   *  중복 노출 방지: TossAdapter.storage 의 "toss_intro_seen_v1" 키로 영구 기록.
   *  Storage 비활성 환경(시크릿 모드 등)이면 isUsable() 이 false 일 수 있으므로
   *  그 경우엔 안내를 한 번 보여주고 끝(getItem 이 null 을 돌려주므로 자연스레 표시).
   */
  useEffectW(() => {
    if (!isTossMode) return;
    if (!auth?.ready || !auth?.session) return;
    if (tossIntroOpen !== null) return; // 이미 한 번 결정됨
    let cancelled = false;
    (async () => {
      try {
        const seen = await (window.TossAdapter && window.TossAdapter.storage
          ? window.TossAdapter.storage.getItem("toss_intro_seen_v1")
          : Promise.resolve(null));
        if (cancelled) return;
        // null/undefined/"" 모두 미열람으로 간주.
        setTossIntroOpen(seen ? false : true);
      } catch (_) {
        if (cancelled) return;
        // 스토리지 오류는 무시하고 안내만 한 번 띄움 — 닫으면 메모리에서 false 로 바뀜.
        setTossIntroOpen(true);
      }
    })();
    return () => { cancelled = true; };
  }, [isTossMode, auth?.ready, auth?.session, tossIntroOpen]);

  /* G-8: 안내 다이얼로그 닫기 — storage 에 영구 기록. 실패해도 메모리 상태는 false 로. */
  const closeTossIntro = () => {
    setTossIntroOpen(false);
    try {
      if (window.TossAdapter && window.TossAdapter.storage) {
        // setItem 은 Promise — await 안 해도 OK. 실패는 console 만.
        window.TossAdapter.storage.setItem("toss_intro_seen_v1", "1").catch(() => {});
      }
    } catch (_) {}
  };

  /* 서버 크레딧 동기화.
     - unlimited 계정: UI에서는 '무제한' 배지로 표시 (숫자 대신 ∞)
     - 일반 계정: free_uses_remaining + credits 합산 */
  const isUnlimited = !!auth?.account?.unlimited;
  const resolvedCredits = (() => {
    if (!auth || !auth.account) return credits;
    const a = auth.account;
    return (a.free_uses_remaining || 0) + (a.credits || 0);
  })();

  /* 최근 분석.
   * 홈 위젯은 "파일명당 최신 1개" 로 접어 같은 계약서를 여러 번 분석해도 슬롯을
   * 낭비하지 않는다. (사용자 입장에선 "이 파일" 한 줄만 보이면 충분.)
   * 전체보기(기록 탭) 는 의도대로 모든 분석을 다 보여준다.
   * 홈도 상대시간(`relative: true`) 으로 "오늘 오전 HH:MM" 을 찍어서 오늘
   * 분석이 여러 건이어도 '4월 21일' 로 모두 똑같이 찍히는 일은 없게 한다.
   * auth?.history 는 이미 refreshHistory 에서 created_at 내림차순이라
   * 여기서는 "처음 보이는 파일명만 남기기" 한 번이면 최신본이 유지된다. */
  const historyRecentsHome = useMemoW(() => {
    const recents = historyToRecents(auth?.history || [], { relative: true });
    const seen = new Set();
    const uniq = [];
    for (const r of recents) {
      // raw 파일명 기준 접기. 파일명 없는 경우(로컬 임시 항목) 는 접지 않는다.
      const key = (r.filename || "").trim().toLowerCase();
      if (key && seen.has(key)) continue;
      if (key) seen.add(key);
      uniq.push(r);
      if (uniq.length >= 3) break;
    }
    return uniq;
  }, [auth?.history]);
  const historyRecentsAll = useMemoW(
    () => historyToRecents(auth?.history || [], { relative: true }),
    [auth?.history]
  );
  const hasSession = !!auth?.session;

  /* theme: 라이트 모드 강제 고정 (앱인토스 비게임 체크리스트 준수) */
  useEffectW(() => {
    document.documentElement.setAttribute("data-theme", "light");
  }, []);

  /* Tweaks bridge — 개발 환경에서만 활성화 (프로덕션 번들은 postMessage 무시) */
  useEffectW(() => {
    if (!_tweaksAllowed) return;
    const onMsg = (ev) => {
      const m = ev.data;
      if (!m || typeof m !== "object") return;
      if (m.type === "__activate_edit_mode")   setTweaksOpen(true);
      if (m.type === "__deactivate_edit_mode") setTweaksOpen(false);
    };
    window.addEventListener("message", onMsg);
    window.parent.postMessage({ type: "__edit_mode_available" }, "*");
    return () => window.removeEventListener("message", onMsg);
  }, []);
  const persist = (patch) => window.parent.postMessage({ type: "__edit_mode_set_keys", edits: patch }, "*");

  /* Actions */
  const toLanding = () => setRoute("landing");
  const toLogin = () => setRoute("login");
  const toSignup = () => setRoute("signup");
  const toApp = () => { setRoute("app"); persist({ route: "app" }); };

  /* 실제 로그인/가입 핸들러 — auth.jsx의 Supabase 클라이언트를 경유.
     앱인토스 미니앱 정책 6.1: 외부 OAuth(provider) 경로는 제거. 토스 로그인 SDK가
     연동되면 별도 핸들러로 분기 추가 예정이며, 그때까지는 provider 페이로드를
     무시해서 어떤 소셜 경로도 열리지 않도록 한다. */
  const onAuthSubmit = async (payload) => {
    if (payload?.provider) {
      // 정책상 허용되지 않는 경로 — 조용히 무시 (버튼 자체가 제거됐으므로 도달 불가)
      return;
    }
    const { email, pw } = payload || {};
    if (!email || !pw) return;
    if (!auth || !auth.ready) {
      alert("서버 설정을 불러오는 중이에요. 잠시 뒤 다시 시도해 주세요.");
      return;
    }
    if (auth.configError) {
      alert(`지금은 로그인할 수 없어요: ${auth.configError}`);
      return;
    }
    const fn = route === "signup" ? auth.signUp : auth.signIn;
    const result = await fn(email, pw);
    if (result?.ok) {
      // 가입 시 이메일 확인이 필요한 경우 session이 null일 수 있음 — onAuthStateChange가 처리
      // 로그인 성공 시에는 useEffect가 감지해 route를 app으로 전환
      if (route === "signup" && !result.data?.session) {
        alert("가입 확인 메일을 보냈어요. 메일함에서 확인 링크를 눌러 주세요.");
      }
    }
    // 실패 시 auth.authError가 자동으로 채워짐 → Auth 컴포넌트에서 렌더
  };

  /* "새 계약서 분석하기" / FAB 핸들러.
     uploadSheet 의 open 가드가 result/preview/capture/picker/paste 화면에선
     sheet 를 강제로 hidden 으로 만들기 때문에, 이런 화면에 있을 때 사용자가
     사이드바 CTA 나 FAB 를 눌러도 아무 반응이 없는 회귀가 있었음.
     해결: 시트 열기 전에 stack 을 비워 home 으로 돌아간 뒤 setUploadSheet(true).
     issueSheet / billingSheet 같은 다른 sheet 도 함께 닫음. */
  const onFab = () => {
    setStack([]);
    setIssueSheet(null);
    setBillingSheet(false);
    setTab("home");
    // 다음 tick 에 시트 열기 (위의 state 들이 먼저 반영되도록)
    setUploadSheet(true);
  };

  /* 업로드 소스 분기. 실제 파일 선택은 숨김 input으로, 카메라는 데모 플로우로.
     kind === "paste" 는 /analyze-text 엔드포인트 경로 — PasteScreen 으로 진입. */
  const onPickSource = (kind) => {
    setUploadSheet(false);
    if (kind === "camera") {
      setStack(["capture"]);
    } else if (kind === "upload") {
      openFilePicker();
    } else if (kind === "paste") {
      if (!auth?.session) {
        // toss-anonymous 모드에서는 부트스트랩이 끝났다는 게 곧 세션이 있다는 뜻 —
        // 이 분기는 부트스트랩 실패 시에만 도달. 로그인 화면 대신 안내만 띄우고 멈춤.
        alert(isTossMode
          ? "사용자 인증이 아직 끝나지 않았어요. 잠시 뒤 다시 시도해 주세요."
          : "먼저 로그인해야 계약서를 분석할 수 있어요.");
        if (!isTossMode) setRoute("login");
        return;
      }
      setStack(["paste"]);
    } else {
      setStack(["picker"]);
    }
  };

  const openFilePicker = () => {
    if (!auth?.session) {
      alert(isTossMode
        ? "사용자 인증이 아직 끝나지 않았어요. 잠시 뒤 다시 시도해 주세요."
        : "먼저 로그인해야 계약서를 올릴 수 있어요.");
      if (!isTossMode) setRoute("login");
      return;
    }
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
      fileInputRef.current.click();
    }
  };

  /* 실제 /analyze 호출 — 파일을 multipart로 올리고 ContractAnalysisResponse를 받는다. */
  const runAnalyze = async (file) => {
    if (!file) return;
    if (typeof window.apiFetch !== "function") {
      alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요.");
      return;
    }
    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    setAnalysisPending(true);
    setAnalyzing(true);

    const form = new FormData();
    form.append("file", file);
    form.append("contract_type", "auto");

    try {
      const res = await window.apiFetch("/analyze", { method: "POST", body: form });
      if (!res.ok) {
        let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`;
        try {
          const j = await res.json();
          if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
        } catch (_) { /* 본문 없음 */ }
        if (res.status === 401) detail = "로그인이 풀렸어요. 다시 로그인해 주세요.";
        else if (res.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요.";
        throw new Error(detail);
      }
      const data = await res.json();
      setAnalysis(data);
      /* 기존 UI 호환을 위해 scenario key 매핑 (아이콘/라벨 fallback용) */
      const scenarioKey =
        data.contract_type === "lease" ? (file.name?.toLowerCase().includes("월세") ? "wolsae" : "jeonse") :
        data.contract_type === "employment" ? "labor" :
        data.contract_type === "freelancer" ? "labor" :
        "jeonse";
      setScenario(scenarioKey);
      setAnalysisPending(false);
      /* onAnalyzeDone(=AnalyzingScreen이 100%까지 찬 뒤 콜백)이 result로 전환 */
      if (auth?.refreshAccount) auth.refreshAccount();
      // Supabase에 analysis_history 테이블이 없어도 기록 탭에 반드시 뜨도록
      // 로컬 캐시에 먼저 박아 넣고, 그 다음 서버 refresh로 merge 시킨다.
      if (auth?.rememberAnalysis) auth.rememberAnalysis(data);
      if (auth?.refreshHistory) auth.refreshHistory();
    } catch (err) {
      console.error("[analyze] failed:", err);
      setAnalysisError(err?.message || String(err));
      setAnalysisPending(false);
      setAnalyzing(false);
      alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`);
    }
  };

  /* 파일 선택 → 즉시 분석 시작이 아니라 **확인 단계** 한 번 거쳐서
     "잘못 올렸나?" 사용자가 점검할 기회 제공.
     native window.confirm 은 "127.0.0.1 says:" origin label 을 강제로
     보여주는 한계가 있어, 우리 브랜드 헤더("[서명전에] 알림") 를 가진
     커스텀 modal 을 띄운다. modal 자체는 같은 파일 하단 inline JSX 로
     직접 렌더 (외부 ConfirmDialog 컴포넌트 의존성 X — 표시 회귀 방지).
     (붙여넣기/카메라 경로는 사용자가 명시적으로 텍스트/이미지를 만들고
     분석 버튼을 누르므로 별도 confirm 불필요.) */
  const onFileInputChange = (e) => {
    const fs = Array.from(e.target.files || []);
    if (fs.length === 0) return;
    // 여러 이미지 선택 → 멀티 파일 모드로 분기 (/extract-multi → /analyze-text).
    // 단일 또는 비이미지 → 기존 단일 /analyze 플로우.
    const isImage = (f) => /\.(jpg|jpeg|png|webp|heic)$/i.test(f.name || "");
    if (fs.length > 1 && fs.every(isImage)) {
      setPendingFiles(fs.slice(0, 8));  // 서버 제한 8장
    } else {
      setPendingFile(fs[0]);
    }
    try { e.target.value = ""; } catch (_) {}
  };

  /* 여러 이미지 업로드 플로우 (사이클 1-B, 2026-04-24).
     /extract-multi 로 각 이미지 OCR → 결과 text 를 /analyze-text 로 넘겨
     한 번에 분석. 단일 이미지 플로우는 기존 runAnalyze 유지. */
  const runAnalyzeMulti = async (files) => {
    if (!files || files.length === 0) return;
    if (typeof window.apiFetch !== "function") {
      alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요.");
      return;
    }
    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    setAnalysisPending(true);
    setAnalyzing(true);
    try {
      const extractForm = new FormData();
      files.forEach((f) => extractForm.append("files", f));
      extractForm.append("contract_type", "auto");
      const extractRes = await window.apiFetch("/extract-multi", { method: "POST", body: extractForm });
      if (!extractRes.ok) {
        let detail = `이미지 여러 장 처리가 실패했어요 (HTTP ${extractRes.status})`;
        try {
          const j = await extractRes.json();
          if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
        } catch (_) {}
        if (extractRes.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요.";
        throw new Error(detail);
      }
      const extracted = await extractRes.json();
      // /analyze-text 로 본문 분석
      const analyzeForm = new FormData();
      analyzeForm.append("filename", extracted.filename || `이미지 ${files.length}장`);
      analyzeForm.append("extension", extracted.extension || ".jpg");
      analyzeForm.append("contract_type", "auto");
      analyzeForm.append("page_count", String(extracted.page_count || files.length));
      analyzeForm.append("extracted_text", extracted.extracted_text || "");
      (extracted.warnings || []).forEach((w) => analyzeForm.append("warnings", w));
      const res = await window.apiFetch("/analyze-text", { method: "POST", body: analyzeForm });
      if (!res.ok) {
        let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`;
        try {
          const j = await res.json();
          if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
        } catch (_) {}
        throw new Error(detail);
      }
      const data = await res.json();
      setAnalysis(data);
      setScenario(
        data.contract_type === "lease" ? "jeonse" :
        data.contract_type === "employment" ? "labor" :
        data.contract_type === "freelancer" ? "labor" :
        "jeonse"
      );
      setAnalysisPending(false);
      if (auth?.refreshAccount) auth.refreshAccount();
      if (auth?.rememberAnalysis) auth.rememberAnalysis(data);
      if (auth?.refreshHistory) auth.refreshHistory();
    } catch (err) {
      console.error("[analyze-multi] failed:", err);
      setAnalysisError(err?.message || String(err));
      setAnalysisPending(false);
      setAnalyzing(false);
      alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`);
    }
  };

  /* 붙여 넣은 계약서 본문을 /analyze-text 로 POST. AnalyzeExtractedTextRequest 스펙:
   *   { filename, extension, contract_type, page_count, extracted_text, warnings }
   * runAnalyze 와 마찬가지로 분석 진행 오버레이 → Result 전환 플로우를 탄다. */
  const runAnalyzeText = async (text) => {
    const body = (text || "").trim();
    if (!body) {
      alert("붙여 넣은 내용이 비어 있어요.");
      return;
    }
    if (typeof window.apiFetch !== "function") {
      alert("연결이 아직 준비되지 않았어요. 새로고침하고 다시 시도해 주세요.");
      return;
    }

    // YYYYMMDD_HHMM 타임스탬프 파일명 (히스토리 목록에서 식별 가능하도록)
    const pad = (n) => String(n).padStart(2, "0");
    const d = new Date();
    const stamp =
      `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_` +
      `${pad(d.getHours())}${pad(d.getMinutes())}`;
    const filename = `클립보드_붙여넣기_${stamp}.txt`;

    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    setAnalysisPending(true);
    setAnalyzing(true);

    try {
      const res = await window.apiFetch("/analyze-text", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename,
          extension: ".txt",
          contract_type: "auto",
          page_count: 1,
          extracted_text: body,
          warnings: ["사용자가 붙여넣은 텍스트예요. 원문과 차이가 있을 수 있어요."],
        }),
      });
      if (!res.ok) {
        let detail = `분석이 끝나지 못했어요 (HTTP ${res.status})`;
        try {
          const j = await res.json();
          if (j?.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
        } catch (_) { /* 본문 없음 */ }
        if (res.status === 401) detail = "로그인이 풀렸어요. 다시 로그인해 주세요.";
        else if (res.status === 402) detail = "무료 크레딧을 다 썼어요. 크레딧을 충전하고 다시 시도해 주세요.";
        throw new Error(detail);
      }
      const data = await res.json();
      setAnalysis(data);
      const scenarioKey =
        data.contract_type === "lease" ? "jeonse" :
        data.contract_type === "employment" ? "labor" :
        data.contract_type === "freelancer" ? "labor" :
        "jeonse";
      setScenario(scenarioKey);
      setAnalysisPending(false);
      if (auth?.refreshAccount) auth.refreshAccount();
      if (auth?.rememberAnalysis) auth.rememberAnalysis(data);
      if (auth?.refreshHistory) auth.refreshHistory();
    } catch (err) {
      console.error("[analyze-text] failed:", err);
      setAnalysisError(err?.message || String(err));
      setAnalysisPending(false);
      setAnalyzing(false);
      alert(`분석하다가 오류가 생겼어요.\n\n${err?.message || String(err)}`);
    }
  };

  /* 카메라 촬영 결과 처리.
   *   - image 가 있으면 (TossAdapter.camera.open 성공): dataUri → File 변환해
   *     실제 /analyze 호출. 분석 결과로 Result 스크린 진입.
   *   - image 가 null 이면 (어댑터 미지원/폴백): 기존 목업 플로우. */
  const onCaptureShoot = (image) => {
    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    if (image && image.dataUri) {
      const file = dataUriToFile(image.dataUri, suggestCameraFilename(image));
      if (file) {
        // 실제 분석 경로 (/analyze 호출). runAnalyze 내부에서 analyzing=true 로 전환.
        runAnalyze(file);
        return;
      }
    }
    // 목업 경로 (데모)
    setAnalysisPending(false);
    setAnalyzing(true);
  };
  /* 앨범/파일 피커 확정. payload 가 객체이면 실제 ImageResponse 로 간주하고
   * /analyze 호출, 문자열이면 목업 플로우로 분기. */
  const onPickerConfirm = (payload) => {
    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    if (payload && typeof payload === "object" && payload.dataUri) {
      const file = dataUriToFile(payload.dataUri, suggestCameraFilename(payload));
      if (file) {
        runAnalyze(file);
        return;
      }
    }
    // 목업 경로 (문자열 id 또는 변환 실패)
    setAnalysisPending(false);
    setAnalyzing(true);
  };
  const onAnalyzeDone = () => {
    setAnalyzing(false);
    setStack(["result"]);
    /* 실제 /analyze 성공 시엔 auth.refreshAccount가 크레딧을 갱신.
       목업(카메라/picker) 경로에서만 로컬 카운터 감소. */
    if (!analysis) setCredits((c) => Math.max(0, c - 1));
  };
  const onOpenIssue = (iss) => {
    /* IssueDetail 시트를 열기 전에 다른 BottomSheet/ActionSheet 들을 강제로
       닫는다. 이전엔 사용자가 FAB → 업로드 메뉴(uploadSheet) 를 열어두고
       결과 화면으로 진입한 뒤 issue 카드를 누르면 두 sheet 가 z-index 상으로
       겹쳐서 화면이 깨졌음. 같은 시점에 한 sheet 만 노출되도록 보장. */
    setUploadSheet(false);
    setBillingSheet(false);
    setIssueSheet(iss);
  };
  /* 화면 stack 이 바뀔 때 (홈 → 결과, 결과 → 미리보기 등) ActionSheet/BottomSheet 도
     같이 닫는다. 이전 화면에서 열어둔 시트가 다음 화면에 leak 되는 회귀 방지. */
  useEffectW(() => {
    setUploadSheet(false);
  }, [stack.join("|")]);
  const goPreviewFromIssue = () => {
    const iss = issueSheet;
    setIssueSheet(null);
    setFocusIssueId(iss?.id || null);
    setStack((s) => s.includes("preview") ? s : [...s, "preview"]);
  };
  const onOpenPreview = () => {
    setFocusIssueId(null);
    setStack((s) => s.includes("preview") ? s : [...s, "preview"]);
  };
  const onBackPreview = () => { setFocusIssueId(null); setStack((s) => s.filter((x) => x !== "preview")); };
  const onBackResult = () => {
    setStack([]);
    setAnalysis(null);
    setAnalysisError(null);
    setFocusIssueId(null);
  };
  const onOpenRecent = (r) => {
    if (!r) return;
    /* 서버에 저장된 기록인데 result 가 비어 있는 케이스 방어.
     * (DB row 의 result_json 이 깨졌거나 마이그레이션 누락 등)
     * 사일런트 폴백으로 mock 시나리오를 띄우면 "어떤 걸 눌러도 같은 화면" 처럼
     * 보여 사용자가 클릭이 안 먹는다고 오해한다. 차라리 토스트로 못 불러왔다고
     * 알리고 이동을 막는다. mock 데이터(history_id 가 없고 r1/r3 형태) 는 예외로
     * 기존 폴백을 허용한다. */
    const isServerRecord = !!(r.history_id || r.local_id);
    if (isServerRecord && !r.analysis) {
      console.warn("[onOpenRecent] 분석 결과를 못 불러왔어요:", { id: r.id, history_id: r.history_id });
      if (_toast?.openToast) {
        _toast.openToast({ description: "이 기록은 다시 불러올 수 없어요. 새로 분석해 주세요." });
      }
      return;
    }
    setScenario(r.type);
    persist({ scenario: r.type });
    if (r.analysis) {
      setAnalysis(r.analysis);
      setAnalysisError(null);
      setAnalysisPending(false);
    } else {
      // mock 폴백 — 데모용 시나리오 화면
      setAnalysis(null);
    }
    setStack(["result"]);
  };
  /* 기록 한 건 삭제 — HistoryScreen 편집 모드에서 호출.
   *  r 은 historyToRecent 가 만든 모양({ history_id, local_id, ... }).
   *  auth.deleteHistory 가 서버 DELETE + 로컬 캐시 정리 + 스토어 갱신을 모두 처리한다.
   *  반환: { ok: bool, error? } — HistoryScreen 이 토스트로 결과를 알린다. */
  const onDeleteRecent = async (r) => {
    if (!r) return { ok: false, error: new Error("기록이 없어요") };
    if (typeof auth?.deleteHistory !== "function") {
      return { ok: false, error: new Error("삭제 기능을 쓸 수 없는 환경이에요") };
    }
    return auth.deleteHistory({
      history_id: r.history_id || "",
      local_id: r.local_id || "",
    });
  };
  /* 앱인토스 IAP 결제 흐름 (Task #197, 2026-04-24):
   *   1) TossAdapter.iap.purchase({ productId })
   *        → Toss SDK 가 결제 UI 표시, 사용자 승인 후 { orderId, receipt, productId } 반환
   *        → 웹 폴백: 스텁 receipt (백엔드가 검증 실패 → 사용자에게 에러 표시)
   *   2) POST /payments/iap/confirm { product_id, order_id, receipt, amount }
   *        → 백엔드가 mTLS 로 Toss 서버에 영수증 검증
   *        → 성공하면 iap_grants 에 멱등 INSERT + profiles.credits 증가
   *        → 응답에 idempotent=True 가 오면 "이미 처리된 주문" 으로 취급
   *
   * product_id 는 /client-config 에서 받아온 맵 사용. 콘솔 미등록이면 null →
   * BillingSheet 가 결제 버튼을 비활성화해야 함 (iapProductIds 상태로 전달). */
  const onPurchase = async (plan) => {
    const accessToken = auth?.session?.access_token;
    if (!accessToken) {
      return { ok: false, message: "로그인 세션이 만료됐어요. 다시 시도해 주세요." };
    }

    // 1) PurchaseKind → productId 해석 (auth store 가 /client-config 에서 받아온 값)
    const authState = auth || {};
    const iapMap = {
      single_text: authState.iapProductIdSingleText,
      single_ocr: authState.iapProductIdSingleOcr,
      pack: authState.iapProductIdPack,
    };
    const productId = iapMap[plan.kind];
    if (!productId) {
      return {
        ok: false,
        message: "이 상품은 아직 결제 준비 중이에요. 잠시 후 다시 시도해 주세요.",
      };
    }

    try {
      // 2) IAP 구매 (Toss SDK / 웹 폴백)
      let purchase = null;
      try {
        const adapter = (typeof window !== "undefined" && window.TossAdapter) || null;
        purchase = await adapter?.iap?.purchase?.({
          productId,
          orderName: plan.title || plan.sub,
        });
      } catch (err) {
        return { ok: false, message: err?.message || "결제 창을 여는 중에 문제가 생겼어요." };
      }
      if (!purchase || !purchase.orderId || !purchase.receipt) {
        return { ok: false, message: "결제 정보를 받지 못했어요. 잠시 후 다시 시도해 주세요." };
      }

      // 3) /payments/iap/confirm — 백엔드에서 영수증 검증 + 크레딧 지급
      const confRes = await fetch("/payments/iap/confirm", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${accessToken}`,
        },
        body: JSON.stringify({
          product_id: productId,
          order_id: purchase.orderId,
          receipt: purchase.receipt,
          amount: plan.price,
        }),
      });
      if (!confRes.ok) {
        const body = await confRes.json().catch(() => ({}));
        return {
          ok: false,
          message: body?.detail || `결제 승인에 실패했어요 (HTTP ${confRes.status})`,
        };
      }
      const conf = await confRes.json();

      // 성공: 최신 계정 스냅샷으로 갱신
      await auth?.refreshAccount?.();
      return {
        ok: true,
        creditsAdded: conf.credits_added || 0,
        total: (conf.free_uses_remaining || 0) + (conf.credits || 0),
        idempotent: !!conf.idempotent,  // 재시도로 이미 처리된 주문이면 true
      };
    } catch (err) {
      return { ok: false, message: err?.message || "네트워크 오류로 결제를 마치지 못했어요." };
    }
  };

  /* Tweaks jumps */
  const onJump = (id) => {
    if (id === "landing") setRoute("landing");
    else if (id === "login") setRoute("login");
    else if (id === "signup") setRoute("signup");
    else {
      setRoute("app");
      if (id === "result") setStack(["result"]);
      else if (id === "preview") setStack(["result", "preview"]);
      else { setStack([]); setTab(id === "home" ? "home" : id); }
    }
  };

  // Debug nav: expose on window for screenshot tooling
  useEffectW(() => {
    window.__webnav = onJump;
    return () => { delete window.__webnav; };
  });

  /* ====== toss-anonymous 모드: 랜딩/로그인 자체가 없음 ======
   * 부트스트랩 끝나기 전 → 무자극 스플래시 (앱이 멈춰 보이지 않게 페이드 인 정도).
   * 부트스트랩 실패 → 사용자 안내 화면 (앱 업데이트 / 카테고리 거절 / 일시 오류).
   * 부트스트랩 성공 → 곧장 app 쉘로 폴 스루. */
  if (isTossMode) {
    if (auth?.tossBootstrapError) {
      return (
        <div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "24px" }}>
          <div style={{ maxWidth: 320, textAlign: "center" }}>
            <div style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>잠시 기다려 주세요</div>
            <div style={{ fontSize: 15, color: "var(--label-secondary)", lineHeight: 1.5 }}>
              {auth.tossBootstrapError}
            </div>
            <button
              className="acct-wallet__cta"
              style={{ marginTop: 24 }}
              onClick={() => { try { window.location.reload(); } catch (_) {} }}
            >
              다시 시도
            </button>
          </div>
        </div>
      );
    }
    if (!auth?.ready || !auth?.session) {
      // 부트스트랩 중 — 짧게 스치는 무자극 화면. 보통 100~300ms 안에 끝남.
      return (
        <div className="app" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
          <div style={{ width: 28, height: 28, border: "3px solid var(--label-quaternary)", borderTopColor: "var(--brand)", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
          <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
        </div>
      );
    }
    // ready + session 있음 → app 쉘로 폴 스루.
  }

  /* ====== supabase-jwt (레거시) 모드 한정 랜딩/로그인 화면 ======
   * G-9 검증 끝나면 .env 의 AUTH_MODE 가 toss-anonymous 로 굳어지면서
   * 이 두 분기는 영원히 도달하지 않게 됨 → G-10 정리 단계에서 통째 삭제. */
  if (!isTossMode && route === "landing") {
    return (
      <>
        <Landing onLogin={toLogin} onSignup={toSignup} onEnterApp={toApp} />
        <WebTweaks open={tweaksOpen} onClose={() => setTweaksOpen(false)}
          scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }}
          theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }}
          onJump={onJump} />
      </>
    );
  }
  if (!isTossMode && (route === "login" || route === "signup")) {
    return (
      <>
        <Auth
          mode={route}
          onSwitch={(m) => { if (auth?.clearAuthError) auth.clearAuthError(); setRoute(m); }}
          onSubmit={onAuthSubmit}
          onClose={() => { if (auth?.clearAuthError) auth.clearAuthError(); toLanding(); }}
          errorMessage={auth?.authError}
          busy={!!auth?.authBusy}
          emailAuthOnly={true}
        />
        <WebTweaks open={tweaksOpen} onClose={() => setTweaksOpen(false)}
          scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }}
          theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }}
          onJump={onJump} />
      </>
    );
  }

  /* App shell */
  const hideNav = analyzing || stack.includes("capture") || stack.includes("picker") || stack.includes("paste");

  return (
    <>
      <WebShell
        tab={tab}
        onTab={(t) => { setTab(t); setStack([]); }}
        onFab={onFab}
        onOpenBilling={() => setBillingSheet(true)}
        credits={resolvedCredits}
        unlimited={isUnlimited}
        hideNav={hideNav}
        sidebarCollapsed={sidebarCollapsed}
        onToggleSidebar={() => setSidebarCollapsed((v) => { persist({ sidebarCollapsed: !v }); return !v; })}
      >
        <>
          {tab === "home" && (
            <HomeScreen
              credits={resolvedCredits}
              unlimited={isUnlimited}
              /* HomeScreen 은 "사진 찍기 / 파일·사진 올리기 / 텍스트 붙여넣기" 3개
                 CTA 가 각각 onNewAnalysis("camera"|"upload"|"paste") 로 kind 를
                 넘긴다. 과거엔 onFab 으로 연결돼 kind 가 무시되고 공용 시트가 떠서,
                 어떤 버튼을 눌러도 똑같은 업로드 시트만 다시 열리는 버그였다.
                 onPickSource 로 직결 — 각 quick 버튼이 자기 경로로 바로 이동.

                 Hero "지금 시작하기" CTA 는 onOpenChooser 를 받으면 공용 업로드
                 시트를 연다 (FAB 과 동일). 사용자 요청: 카메라 직결이 아닌 "카메라/
                 파일/텍스트" 선택 창으로 유도. */
              onNewAnalysis={onPickSource}
              onOpenChooser={onFab}
              onOpenRecent={onOpenRecent}
              onOpenBilling={() => setBillingSheet(true)}
              onSeeAllRecents={() => { setTab("history"); setStack([]); }}
              recents={historyRecentsHome}
              historyLoading={!!auth?.historyLoading}
              hasSession={hasSession}
              userName={auth?.account?.email ? auth.account.email.split("@")[0] : null}
            />
          )}
          {tab === "history" && (
            <HistoryScreen
              onOpen={onOpenRecent}
              onDelete={onDeleteRecent}
              items={historyRecentsAll}
              historyLoading={!!auth?.historyLoading}
              hasSession={hasSession}
            />
          )}
          {tab === "help" && <HelpScreen />}
          {tab === "account" && (
            <AccountScreen
              credits={resolvedCredits}
              unlimited={isUnlimited}
              theme={theme}
              onToggleTheme={() => {}} /* 라이트 모드 강제 — 토글 비활성화 */
              onOpenBilling={() => setBillingSheet(true)}
              /* 계정 화면의 "도움말과 문의" 행 → 하단 탭 도움말 화면으로 이동.
                 setStack([])로 모달/오버레이 스택을 초기화해서 깔끔하게 전환. */
              onOpenHelp={() => { setTab("help"); setStack([]); }}
              /* toss-anonymous 모드에서 화면 카피("로그아웃하기" → "내 기록 초기화") 분기. */
              authMode={auth?.authMode || "toss-anonymous"}
              userEmail={auth?.account?.email || auth?.user?.email || null}
              onLogout={() => {
                if (auth?.session) {
                  // toss-anonymous : signOut() 이 새 hash 로 즉시 재부트스트랩 → route 유지.
                  // supabase-jwt   : onAuthStateChange + useEffect 가 route 를 landing 으로.
                  auth.signOut();
                } else if (!isTossMode) {
                  setRoute("login");
                }
                // toss-anonymous + 세션 없음 = 부트스트랩 실패 화면이 떠 있음 → no-op.
              }}
            />
          )}

          {stack.includes("capture") && (
            <CaptureScreen onBack={() => setStack([])} onShoot={onCaptureShoot} />
          )}
          {stack.includes("picker") && (
            <PickerScreen onBack={() => setStack([])} onPick={onPickerConfirm} />
          )}
          {stack.includes("paste") && (
            <PasteScreen
              onBack={() => setStack([])}
              onSubmit={(text) => runAnalyzeText(text)}
            />
          )}

          {stack.includes("result") && !stack.includes("preview") && (
            <div style={{ position: "absolute", inset: 0, zIndex: 30, background: "var(--bg)", display: "flex", flexDirection: "column" }}>
              <ResultScreen
                scenario={scenario}
                analysis={analysis}
                onBack={onBackResult}
                onOpenIssue={onOpenIssue}
                onOpenPreview={onOpenPreview}
              />
            </div>
          )}
          {stack.includes("preview") && (
            <div style={{ position: "absolute", inset: 0, zIndex: 31, background: "var(--bg)", display: "flex", flexDirection: "column" }}>
              <PreviewScreen
                scenario={scenario}
                analysis={analysis}
                focusIssueId={focusIssueId}
                onBack={onBackPreview}
                onOpenIssue={onOpenIssue}
              />
            </div>
          )}

          {analyzing && (
            <div style={{ position: "absolute", inset: 0, zIndex: 40, display: "flex", flexDirection: "column" }}>
              <AnalyzingScreen
                scenario={scenario}
                pending={analysisPending}
                onCancel={() => { setAnalyzing(false); setAnalysisPending(false); }}
                onDone={onAnalyzeDone}
              />
            </div>
          )}
        </>
      </WebShell>

      <ActionSheet
        /* uploadSheet 는 어떤 다른 sheet/화면 (issueSheet, billingSheet,
           result/preview/analyzing) 이 활성이면 자체 hidden. 두 sheet 가 동시
           표시되어 화면 깨지는 회귀 마지막 가드. */
        open={uploadSheet && !issueSheet && !billingSheet && !analyzing && !stack.includes("result") && !stack.includes("preview") && !stack.includes("capture") && !stack.includes("picker") && !stack.includes("paste")}
        onClose={() => setUploadSheet(false)}
        title={
          /* 큰 경고 배너 + 보조 안내. 홈 화면 배너와 동일한 톤·문구로 통일해서
             사용자가 어느 진입점에서든 같은 액션 가이드를 듣게 함:
             "공인중개사에게 PDF 파일로 받아 올려라". */
          <div style={{ padding: "16px 16px 14px" }}>
            <div style={{
              background: "var(--warn-soft, #FFF7E6)",
              borderLeft: "4px solid var(--warn, #FF9500)",
              borderRadius: 12,
              padding: "14px 16px",
              marginBottom: 12,
              textAlign: "left",
            }}>
              <div style={{
                fontSize: 16,
                fontWeight: 700,
                color: "var(--warn-ink, #B26600)",
                letterSpacing: "-0.01em",
                marginBottom: 6,
                lineHeight: 1.35,
              }}>
                💡 공인중개사에게 PDF 파일을 받아 올려주세요
              </div>
              <div style={{
                fontSize: 13,
                color: "var(--ink, #191F28)",
                lineHeight: 1.5,
              }}>
                중개사에게 "계약서 PDF로 보내주세요" 라고 요청하시면
                훨씬 정확하게 분석해 드릴 수 있어요.
              </div>
            </div>
            <div style={{
              fontSize: 13,
              color: "var(--label-secondary, #4e5968)",
              textAlign: "center",
              lineHeight: 1.5,
            }}>
              <div style={{ marginBottom: 8, fontWeight: 500 }}>
                지원 형식: PDF · DOCX · XLSX · 이미지 여러 장
              </div>
              <div style={{ opacity: 0.85 }}>
                📸 사진은 평평한 곳 · 밝은 조명 · 정면에서 · 페이지 순서대로
              </div>
            </div>
          </div>
        }
        actions={[
          { icon: "folder", label: "파일에서 고르기",            onClick: () => onPickSource("upload") },
          { icon: "photos", label: "이미지 여러 장 고르기",      onClick: () => onPickSource("upload") },
          { icon: "camera", label: "카메라로 찍기 (데모)",       onClick: () => onPickSource("camera") },
          { icon: "doc",    label: "텍스트 붙여넣기",            onClick: () => onPickSource("paste") },
        ]}
      />

      {/* 실제 파일 업로드용 숨김 input — multiple 속성으로 여러 이미지 허용.
          단일 파일(docx/pdf/xlsx) 을 골라도 기존 플로우 그대로 작동하고,
          이미지 여러 장 고르면 /extract-multi 를 거쳐 한 번에 분석. */}
      <input
        ref={fileInputRef}
        type="file"
        accept=".pdf,.docx,.xlsx,.txt,.md,.jpg,.jpeg,.png,.webp,.heic,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,text/plain,image/*"
        multiple
        style={{ display: "none" }}
        onChange={onFileInputChange}
      />

      <BottomSheet
        open={!!issueSheet}
        onClose={() => setIssueSheet(null)}
        header={<BottomSheet.Header>이 조항 자세히 보기</BottomSheet.Header>}
      >
        <IssueDetail issue={issueSheet} onGoPreview={goPreviewFromIssue} />
      </BottomSheet>

      <BottomSheet
        open={billingSheet}
        onClose={() => setBillingSheet(false)}
        header={<BottomSheet.Header>크레딧 충전하기</BottomSheet.Header>}
      >
        <BillingSheet onPurchase={onPurchase} onClose={() => setBillingSheet(false)} />
      </BottomSheet>

      <WebTweaks open={tweaksOpen} onClose={() => setTweaksOpen(false)}
        scenario={scenario} onScenario={(s) => { setScenario(s); persist({ scenario: s }); }}
        theme={theme} onTheme={(t) => { setTheme(t); persist({ theme: t }); }}
        onJump={onJump} />

      {/* G-8: 토스 hash 모드 첫 진입 안내. 하단 splash/landing 이 아니라 app 쉘 위에
         얹어서 "앱이 비어 있다" 는 인상 없이 메시지만 받고 닫으면 바로 사용 가능.
         AlertDialog 는 단일 CTA(확인) 만 — 결제·무료권 안내는 본문에서 산문으로 처리.
         storage 키 toss_intro_seen_v1 로 1회 노출 보장 (위 useEffect 참고). */}
      {/* 파일 업로드 확인 modal — "[서명전에] 알림" 브랜드 헤더로 나타나는
          커스텀 다이얼로그. native confirm 의 origin label ("127.0.0.1 says:")
          제거 + 외부 컴포넌트 의존성 없이 inline JSX 로 자체 렌더해서 표시
          누락 회귀도 함께 방어. open 가드: pendingFile 이 truthy 일 때만 mount. */}
      {pendingFile ? (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="confirm-upload-title"
          style={{
            position: "fixed", inset: 0, zIndex: 1000,
            display: "flex", alignItems: "center", justifyContent: "center",
            background: "rgba(0, 0, 0, 0.45)",
            padding: 20,
          }}
          onClick={(e) => { if (e.target === e.currentTarget) setPendingFile(null); }}
        >
          <div
            style={{
              background: "var(--surface, #fff)",
              borderRadius: 16,
              maxWidth: 360,
              width: "100%",
              padding: "20px 20px 16px",
              boxShadow: "0 20px 48px rgba(0,0,0,0.18)",
              fontFamily: "var(--font-text)",
            }}
          >
            <div
              id="confirm-upload-title"
              style={{
                fontSize: 13,
                fontWeight: 600,
                color: "var(--brand, #3182F6)",
                marginBottom: 8,
                letterSpacing: "-0.01em",
              }}
            >
              [서명전에] 알림
            </div>
            <h3 style={{
              margin: 0,
              fontSize: 17,
              fontWeight: 700,
              color: "var(--ink, #191F28)",
              letterSpacing: "-0.02em",
            }}>
              이 파일을 분석할까요?
            </h3>
            <div style={{
              marginTop: 12,
              padding: "12px 14px",
              background: "var(--fill-3, #F4F6F9)",
              borderRadius: 10,
              fontSize: 14,
              lineHeight: 1.5,
            }}>
              <div style={{
                fontWeight: 600,
                color: "var(--ink, #191F28)",
                wordBreak: "break-all",
              }}>
                파일명: {pendingFile.name}
              </div>
              <div style={{
                marginTop: 4,
                fontSize: 13,
                color: "var(--label-secondary, #4e5968)",
              }}>
                크기: {(pendingFile.size / 1024).toFixed(1)} KB
              </div>
            </div>
            {/* 이미지면 정확도 안내 배너 — 원문(docx) / PDF 로 올릴 수 있으면
                그게 훨씬 정확하다는 점을 고지. 사진 촬영이 유일한 선택지일
                수도 있으니 strict 하게 막지는 않고 부드럽게 안내만. */}
            {/\.(jpg|jpeg|png|webp|heic)$/i.test(pendingFile.name || "") && (
              <div style={{
                marginTop: 12,
                padding: "10px 12px",
                background: "var(--warn-soft, #FFF7E6)",
                borderLeft: "3px solid var(--warn, #FF9500)",
                borderRadius: 8,
                fontSize: 12,
                lineHeight: 1.5,
                color: "var(--ink, #191F28)",
              }}>
                <strong style={{ fontWeight: 600, color: "var(--warn-ink, #B26600)" }}>
                  사진은 글자 인식이 떨어질 수 있어요
                </strong>
                <div style={{ marginTop: 2, color: "var(--label-secondary, #4e5968)" }}>
                  중개사에게 "계약서 PDF로 보내주세요" 라고 요청하시는 게 가장 정확해요.
                  지금은 사진으로 그대로 진행해도 괜찮아요.
                </div>
              </div>
            )}
            <p style={{
              margin: "12px 0 0",
              fontSize: 13,
              color: "var(--label-secondary, #4e5968)",
              lineHeight: 1.5,
            }}>
              다른 파일을 올리려면 [취소] 를 누른 뒤 다시 골라 주세요.
            </p>
            <div style={{
              marginTop: 18,
              display: "flex",
              gap: 8,
            }}>
              <button
                type="button"
                onClick={() => setPendingFile(null)}
                style={{
                  flex: 1,
                  height: 48,
                  borderRadius: 10,
                  border: 0,
                  background: "var(--fill-3, #F4F6F9)",
                  color: "var(--ink, #191F28)",
                  fontSize: 15,
                  fontWeight: 600,
                  cursor: "pointer",
                }}
              >
                취소
              </button>
              <button
                type="button"
                onClick={() => {
                  const f = pendingFile;
                  setPendingFile(null);
                  if (f) runAnalyze(f);
                }}
                style={{
                  flex: 1,
                  height: 48,
                  borderRadius: 10,
                  border: 0,
                  background: "var(--brand, #3182F6)",
                  color: "#fff",
                  fontSize: 15,
                  fontWeight: 700,
                  cursor: "pointer",
                }}
              >
                분석 시작
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {/* 다중 파일 (이미지 여러 장) confirm modal — 위 단일 modal 과 거의 동일한
          구조지만 파일명을 list 로 보여주고 [분석 시작] 시 runAnalyzeMulti 호출. */}
      {pendingFiles && pendingFiles.length > 0 ? (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="confirm-upload-multi-title"
          style={{
            position: "fixed", inset: 0, zIndex: 1000,
            display: "flex", alignItems: "center", justifyContent: "center",
            background: "rgba(0, 0, 0, 0.45)",
            padding: 20,
          }}
          onClick={(e) => { if (e.target === e.currentTarget) setPendingFiles(null); }}
        >
          <div
            style={{
              background: "var(--surface, #fff)",
              borderRadius: 16,
              maxWidth: 380,
              width: "100%",
              padding: "20px 20px 16px",
              boxShadow: "0 20px 48px rgba(0,0,0,0.18)",
              fontFamily: "var(--font-text)",
            }}
          >
            <div style={{
              fontSize: 13, fontWeight: 600,
              color: "var(--brand, #3182F6)", marginBottom: 8,
              letterSpacing: "-0.01em",
            }}>
              [서명전에] 알림
            </div>
            <h3 id="confirm-upload-multi-title" style={{
              margin: 0, fontSize: 17, fontWeight: 700,
              color: "var(--ink, #191F28)", letterSpacing: "-0.02em",
            }}>
              이미지 {pendingFiles.length}장을 한꺼번에 분석할까요?
            </h3>
            <div style={{
              marginTop: 12,
              padding: "10px 14px",
              background: "var(--fill-3, #F4F6F9)",
              borderRadius: 10,
              fontSize: 13,
              lineHeight: 1.55,
              maxHeight: 200, overflowY: "auto",
            }}>
              {pendingFiles.map((f, i) => (
                <div key={i} style={{
                  display: "flex", justifyContent: "space-between", gap: 8,
                  padding: "4px 0",
                  borderBottom: i < pendingFiles.length - 1 ? "1px solid var(--separator, #EAECEF)" : "none",
                }}>
                  <span style={{
                    flex: 1, wordBreak: "break-all",
                    color: "var(--ink, #191F28)", fontWeight: 500,
                  }}>
                    {i + 1}. {f.name}
                  </span>
                  <span style={{
                    flexShrink: 0,
                    fontSize: 12, color: "var(--label-secondary, #4e5968)",
                  }}>
                    {(f.size / 1024).toFixed(0)} KB
                  </span>
                </div>
              ))}
            </div>
            {/* 이미지는 항상 사진이므로 조건 없이 배너 표시 */}
            <div style={{
              marginTop: 12,
              padding: "10px 12px",
              background: "var(--warn-soft, #FFF7E6)",
              borderLeft: "3px solid var(--warn, #FF9500)",
              borderRadius: 8,
              fontSize: 12,
              lineHeight: 1.5,
              color: "var(--ink, #191F28)",
            }}>
              <strong style={{ fontWeight: 600, color: "var(--warn-ink, #B26600)" }}>
                사진은 글자 인식이 떨어질 수 있어요
              </strong>
              <div style={{ marginTop: 2, color: "var(--label-secondary, #4e5968)" }}>
                원본 파일(DOCX · PDF)이 있다면 그걸 올리는 게 훨씬 정확해요.
                사진밖에 없다면 그대로 진행해도 괜찮아요.
              </div>
            </div>
            <p style={{
              margin: "12px 0 0", fontSize: 13,
              color: "var(--label-secondary, #4e5968)", lineHeight: 1.5,
            }}>
              순서대로 페이지로 이어붙여 한 번에 분석해요. 다시 고르려면 [취소].
            </p>
            <div style={{ marginTop: 18, display: "flex", gap: 8 }}>
              <button
                type="button"
                onClick={() => setPendingFiles(null)}
                style={{
                  flex: 1, height: 48, borderRadius: 10, border: 0,
                  background: "var(--fill-3, #F4F6F9)",
                  color: "var(--ink, #191F28)",
                  fontSize: 15, fontWeight: 600, cursor: "pointer",
                }}
              >
                취소
              </button>
              <button
                type="button"
                onClick={() => {
                  const fs = pendingFiles;
                  setPendingFiles(null);
                  if (fs && fs.length > 0) runAnalyzeMulti(fs);
                }}
                style={{
                  flex: 1, height: 48, borderRadius: 10, border: 0,
                  background: "var(--brand, #3182F6)", color: "#fff",
                  fontSize: 15, fontWeight: 700, cursor: "pointer",
                }}
              >
                {pendingFiles.length}장 분석
              </button>
            </div>
          </div>
        </div>
      ) : null}

      {typeof window !== "undefined" && window.AlertDialog ? (
        <window.AlertDialog
          open={tossIntroOpen === true}
          onClose={closeTossIntro}
          closeOnDimmerClick={false}
          title={
            <window.AlertDialog.Title>
              토스로 자동 로그인됐어요
            </window.AlertDialog.Title>
          }
          description={
            <window.AlertDialog.Description as="div" style={{ textAlign: "left", lineHeight: 1.55 }}>
              <p style={{ margin: 0 }}>
                별도의 회원가입 없이, 토스 계정으로 안전하게 인증됐어요. 처음 한 번은 무료로 계약서를 검토해 볼 수 있어요.
              </p>
              <p style={{ margin: "12px 0 0", color: "var(--label-secondary)" }}>
                베타 기간에 가입했던 분이라면, 보안 강화 작업으로 이전 분석 기록과 잔여 횟수가 초기화됐어요. 다시 한 번 무료권을 드리니 편하게 시작해 보세요.
              </p>
            </window.AlertDialog.Description>
          }
          alertButton={
            <window.AlertDialog.AlertButton onClick={closeTossIntro}>
              시작하기
            </window.AlertDialog.AlertButton>
          }
        />
      ) : null}
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<WebApp />);
