// app/i18n.jsx — 다국어(i18n) 딕셔너리 + 로케일 컨텍스트
// window.RibbonI18n 로 노출. data.jsx 다음, 나머지 앱(jsx) 이전에 로드된다(index.html 순서).
//
// 지원 언어: 한국어(기본) · 日本語 · English · 中文. 선택은 상단 셀렉트박스(main.jsx),
// 저장은 localStorage('ribbon.locale'), 전역 미러는 window.RibbonData.currentLocale
// (chat.jsx 가 /api/chat 에 locale 로 실어 보냄 → 백엔드가 프롬프트 언어를 맞춘다).

const RIBBON_LOCALES = [
  { id: 'ko', label: '한국어', short: 'KO' },
  { id: 'ja', label: '日本語', short: 'JA' },
  { id: 'en', label: 'English', short: 'EN' },
  { id: 'zh', label: '中文', short: 'ZH' },
];
const RIBBON_DEFAULT_LOCALE = 'ko';

// 채팅 표면(UI 크롬) 문자열 — 키별 4개 언어. 없으면 t() 가 ko 로 폴백.
const RIBBON_STRINGS = {
  ko: {
    adm_watch: '채널 조건 알림', adm_watch_hint: '아침 브리핑에서 채널 한도 확인',
    adm_watch_on: '켜짐', adm_watch_off: '꺼짐',
    adm_watch_note: '아침 브리핑은 이 설정과 무관하게 매일 만들어집니다. 매출 · 예약 · 취소 · 가동률 · 미배차 · 정비주기는 **항상** 확인하며, 이 설정은 여기에 **채널 한도 위반**을 더할지만 정합니다.\n챗봇에게 직접 물어보시면 이 설정과 상관없이 언제나 규칙의 기준으로 판정합니다.\n현재 판정은 \"한도를 넘었다\" 는 상태를 알립니다. 이미 넘어 있는 채널이 있으면 매일 같은 알림이 반복되므로, 그런 경우에는 꺼 두시는 편이 낫습니다.',
    adm_side_title: '업체 관리', adm_side_sub: '업체를 선택해 설정을 편집합니다',
    adm_key_snippet: '임베드 스니펫', adm_key_empty: '발급된 키가 없습니다',
    adm_key_revoked: '폐기', adm_key_active: '활성', adm_key_revoke: '폐기',
    // ── 관리자 화면(admin.jsx) ─────────────────────────────────────────
    adm_tab_tenant: '업체 설정', adm_tab_apikeys: 'API 키(위젯)', adm_tab_feedback: '문제제기 접수함',
    adm_checking: '확인 중…', adm_loading: '불러오는 중…', adm_fold_classes: '원장 클래스 목록', adm_fold_classes_hint: '규칙에 적을 코드 확인', adm_classes_note: '**여기서 이름을 적지 않습니다.** 클래스가 어떤 차인지는 규칙의 「클래스 정의」에 문장으로 적습니다.\n원장에는 코드만 있고 그것이 무슨 차인지는 들어 있지 않습니다. 규칙에 이름을 달아 주시면 「미니밴 가동률」처럼 차형 낱말로 물어보실 수 있고, **하나라도 비어 있으면** 챗봇은 추측하지 않고 코드를 되묻습니다.\n아래 버튼으로 코드 목록을 규칙 형식으로 복사한 뒤, 규칙에 붙여 넣고 이름만 채우시면 됩니다.', adm_classes_copy: '규칙 형식으로 복사', adm_classes_count: (n) => `클래스 ${n}개`, adm_classes_tpl_head: '■ 클래스 정의 — 원장 코드 = 우리가 부르는 차', adm_rx_class: '클래스', adm_rx_class_code: '코드:', adm_rx_class_missing: (c) => `이름이 없습니다: ${c}`, adm_rx_class_unknown: (c, l) => `이 업체에 없는 클래스 코드입니다: ${c} = ${l}`,
    adm_need_role_title: '관리자 권한이 필요합니다',
    adm_need_role_body: '업체 설정과 문제제기 접수함은 시스템 관리자 또는 업체 관리자만 열 수 있습니다.',
    adm_current_roles: '현재 역할', adm_none: '없음',
    adm_need_role_ask: '권한이 필요하면 관리자에게 문의하세요.',
    adm_save: '저장', adm_saving: '저장 중…', adm_saved: '저장됨',
    adm_static: '정적 열람(백엔드 미연결)',
    adm_key: '키', adm_mosaicar: '모자이카', adm_subscribed: '구독', adm_unsubscribed: '미구독', adm_pilot_badge: '파일럿',
    adm_sec_rules: '우리 회사 규칙', adm_sec_llm: 'LLM 설정', adm_sec_flags: '피처 플래그',
    adm_rules_label: '규칙',
    adm_rules_hint: '판단 기준 · 채널 한도 · 비교하지 않을 대상',
    adm_rules_ph: '■ 무엇을 보고 판단하는가\n- ...\n\n■ 채널 한도\n- 제주패스 예약 건수는 홈페이지의 1/3을 넘지 않는다\n- 제주패스 건당 단가는 홈페이지의 60% 이상이어야 한다\n\n■ 비교하지 않는 것\n- ...',
    adm_rules_count: (n) => `${n} / 4000자`,
    adm_rules_over: ' — 넘는 부분은 반영되지 않습니다',
    adm_rules_note: '한도도 문장으로 적습니다. 예를 들어 \"제주패스 예약 건수는 홈페이지의 1/3을 넘지 않는다\" 처럼 씁니다.\n**「규칙에서 판정 기준 뽑기」** 를 누르면 해석 결과를 먼저 보여 드립니다. 확인하신 값으로 서버가 판정하며, 챗봇이 문장의 숫자를 직접 계산하지는 않습니다.\n**거래처 코드는 적지 않습니다.** 평소 부르시는 이름만 쓰면 서버가 원장에서 찾습니다.\n규칙이 조회 결과와 어긋날 때는 조회 결과를 따릅니다.',
    adm_provider: '제공자', adm_provider_hint: '응답 생성 LLM 제공자',
    adm_model: '모델', adm_model_hint: '예: gpt-4o · gpt-5-mini (목록에서 고르거나 직접 입력)',
    adm_effort: '추론 강도', adm_effort_hint: 'gpt-5 계열(추론 모델) 전용 — 낮출수록 빠르고 쌉니다. gpt-4o 류에 설정하면 매 질문이 오류(400)가 되므로 그때는 미설정으로 두세요.', adm_effort_unset: '미설정 (모델 기본)',
    adm_flag_fleet: '플릿 최적화', adm_flag_fleet_hint: '차급 진단 · 증차·감차 · 차종별 가동률', adm_flag_fleet_note: '켜 두면 차급별 가동률 진단, 증차 · 감차 시뮬레이션, 차종별 가동률 조회를 사용할 수 있습니다.\n**끄면 차종 단위 질문도 함께 막힙니다.** 「모닝과 레이를 비교해줘」 처럼 개별 차종을 묻는 질문이 여기에 해당하므로, 차종으로 관리하시는 업체는 켜 두시는 편이 좋습니다.\n요금 추천과 원장 조회는 이 설정과 무관하게 동작합니다.',
    adm_rx_btn: '규칙에서 판정 기준 뽑기', adm_rx_busy: '해석하는 중…',
    adm_rx_hint: '규칙에서 한도와 기준 채널을 찾아 보여 드립니다 · 확인하시면 저장됩니다',
    adm_rx_title: '이렇게 이해했습니다', adm_rx_nothing: '이 규칙에서는 판정 기준을 찾지 못했습니다.',
    adm_rx_excluded: '한도 대상 제외',
    adm_rx_primary: '기준 채널', adm_rx_target: '대상 채널', adm_rx_ledger: '원장:', adm_rx_cnt: '건',
    adm_rx_wrote: (raw) => `해석값: ${raw}`,
    adm_rx_quote: (q) => `규칙: "${q}"`,
    adm_rx_missing: '규칙에 없어 판정하지 않습니다', adm_rx_not_written: '규칙 문장에 없습니다',
    adm_rx_n_count: '건수 상한', adm_rx_n_rate: '단가 하한', adm_rx_n_turns: '가동횟수 기준', adm_rx_turns_unit: '회/대·월',
    adm_rx_unusable: '기준 채널과 대상 채널이 둘 다 있어야 판정합니다 — 지금 상태로는 판정하지 않습니다.',
    adm_rx_unres: (n) => `해석하지 못한 것 (${n})`,
    adm_rx_none: (q) => `'${q}' 와 맞는 거래처가 원장에 없습니다.`,
    adm_rx_cands: '비슷한 곳:',
    adm_rx_ambig: (q) => `'${q}' 로 여러 곳이 걸려 고르지 않았습니다 — 아래 「판정 기준」에서 직접 골라 주세요.`,
    adm_rx_group_empty: (g) => `'${g}' 그룹은 거래처를 하나도 찾지 못해 만들지 않았습니다.`,
    adm_rx_num_rejected: (f, raw) => `${f} 값 "${raw}" 는 받을 수 없습니다 — 1/3 · 33% · 0.333 처럼 써 주세요.`,
    adm_rx_sentence: (t) => `조건 같지만 옮기지 못했습니다: "${t}"`,
    adm_rx_no_json: '해석 결과를 읽지 못했습니다. 규칙을 조금 더 분명하게 적은 뒤 다시 시도해 주세요.',
    adm_rx_apply: '이대로 반영하고 저장', adm_rx_cancel: '취소',
    adm_rx_stale: '규칙이 바뀌었습니다. 다시 해석해야 판정에 반영되며, 그전까지는 이전 규칙으로 판정합니다.',
    adm_rx_derived: '아래 「판정 기준」은 위 규칙 문장에서 해석해 저장한 값입니다.',
    adm_rx_err: (m) => `해석하지 못했습니다: ${m}`,
    adm_rx_ledger_off: '원장 거래처 목록을 읽지 못했습니다. 이름을 코드로 바꾸지 못한 것이며, 해당 채널이 없다는 뜻은 아닙니다.',
    adm_rx_none_ledger: (q) => `'${q}' — 원장 목록이 없어 확인하지 못했습니다.`,
    adm_fold_lexicon: '원장 거래처 목록', adm_fold_lexicon_hint: '규칙에 적을 정확한 표기 확인',
    adm_lexicon_note: '**이 목록에서 고른 값은 저장되지 않습니다.** 규칙에 적을 정확한 표기를 확인하는 용도입니다.\n건수와 매출을 보고 어느 줄이 찾으시는 채널인지 확인한 뒤, 그 이름을 규칙의 「채널 정의」에 적습니다.\n원장 표기가 평소 부르는 이름과 다른 경우가 많습니다. 예를 들어 「제주패스」는 원장에 「캐플릭스」로 들어 있습니다.',
    adm_copy: '이름 복사', adm_copied: '복사됨',
    adm_sec_watch: '아침 브리핑',
    adm_fold_weights: '가격 계수 (내부)', adm_fold_weights_hint: '시스템 관리자 전용',
    adm_search_ph: '채널 이름으로 검색 (예: 카모아)',
    adm_not_in_list: '(목록에 없음)', adm_no_result: '검색 결과가 없습니다',
    adm_ledger_fail: '원장에서 거래처 목록을 가져오지 못했습니다. 목록이 비어 있는 것과는 다른 상태이니, 잠시 후 다시 열어 주세요.',
    adm_cnt: (n) => `${n}건`, adm_man: '만',
    adm_cars: '대',
    adm_as_of: (d) => `${d} 보유 기준`,
    adm_class_fail: '클래스 목록을 가져오지 못했습니다. 목록이 비어 있는 것과는 다른 상태이니, 잠시 후 다시 열어 주세요.',
    adm_clear: '지우기',
    adm_weights_label: '계수 오버라이드', adm_weights_hint: 'JSON 으로 입력합니다 · 비우면 기본값을 사용합니다',
    adm_weights_err_obj: '객체(JSON) 여야 합니다',
    adm_weights_err_json: 'JSON 형식이 아닙니다 · 저장하면 마지막 정상값이 유지됩니다',
    adm_weights_note: '기본 계수는 **백테스트로 검증되지 않은 값** 입니다. 추천 근거 표에 그 사실이 각주로 표시됩니다.\n여기서 조정한 항목은 추천 근거에 이름이 함께 표시됩니다. 실측 근거가 있는 계수는 항공 편수뿐입니다.',
    adm_key_title: '위젯 API 키 발급', adm_key_tenant: '업체', adm_key_default: ' · 기본 ', adm_key_label: '라벨',
    adm_key_label_ph: '예: 스카이 포털 위젯',
    adm_key_origins: '허용 origin', adm_key_origins_hint: '위젯이 뜰 페이지의 스킴과 호스트 · 줄바꿈이나 콤마로 구분합니다',
    adm_key_locale: '기본 언어', adm_key_locale_hint: '위젯이 처음 뜰 때의 언어 · 사용자가 바꾸면 그 선택을 따릅니다',
    adm_key_roles: '발급 가능한 역할 상한',
    adm_key_ttl: '세션 유지 시간', adm_key_ttl_hint: '초 단위, 최대 28800(8시간) · 호스트가 값을 넘기지 않으면 이 값을 사용합니다',
    adm_key_issuing: '발급 중…', adm_key_issue: '키 발급',
    adm_key_issued: '발급이 완료되었습니다 · 시크릿은 지금 한 번만 표시됩니다',
    adm_key_public: ' (공개) ', adm_key_secret: ' (호스트 서버 env 보관) ',
    adm_key_revoke_confirm: '이 키를 폐기하시겠습니까? 폐기하면 이 키로는 세션을 발급할 수 없습니다.',
    app_title: '알파카 — 리본 AI', prototype: '시제품',
    temp_login: '임시 로그인', temp_login_busy: '로그인 중…',
    temp_login_tip: '리본 로그인 연동 전 테스트용 — 클릭하면 서버가 임시 토큰을 발급해 바로 로그인됩니다',
    temp_login_tip_code: '리본 로그인 연동 전 테스트용 — 공유 암호를 입력하면 서버가 임시 토큰을 발급해 바로 로그인됩니다',
    temp_login_prompt: '임시 로그인 — 공유 암호를 입력하세요.',
    nav_chat: '챗봇', nav_admin: '관리자', nav_manual: '매뉴얼',
    lang_title: '언어 / Language', tenant_title: '업체 전환',
    status_online: '온라인', status_context: '맥락 유지 중',
    hdr_new: '새 대화', hdr_faq: 'FAQ 관리', hdr_close: '대화 종료(저장)',
    welcome_title: '무엇을 도와드릴까요?',
    welcome_sub: '리본 ERP의 인벤토리·예약·매출 데이터를 분석해 차트와 표로 답해 드립니다. 맥락은 대화 내내 이어집니다.',
    suggested: '추천 질문',
    chip_today: '오늘',
    brief_signals: '어제({date}) 이상징후 {n}건',
    brief_none: '어제({date}) 특이사항 없습니다',
    brief_skipped: '판정 보류 {n}건',
    sev_critical: '즉시 조치',
    sev_warning: '주의',
    sev_info: '참고',
    input_placeholder: '차종·기간·지표를 포함해 질문해 보세요',
    disclaimer: '리본 AI는 실수할 수 있습니다. 중요한 수치는 원본 데이터에서 확인해 주세요.',
    analyzing: '분석 중…', understanding: '질문 이해 중…', sources: '출처',
    act_copy: '복사', act_regen: '재생성', act_export: '내보내기', act_report: '문제제기',
    copied: '복사했습니다', exported: '차트를 PNG로 내보냈습니다',
    usage_label: '토큰', usage_in: '입력', usage_cached: '캐시', usage_out: '출력', usage_cost: '비용', usage_presenter: '화면 구성',
    empty_reply: '(빈 응답)',
    side_recent: '최근 대화', side_empty: '아직 저장된 대화가 없습니다.', side_pinned: '고정 질문', side_manage: '관리',
    act_delete: '삭제', act_pin: '고정',
    rel_now: '방금', rel_min: (n) => `${n}분 전`, rel_hour: (n) => `${n}시간 전`, rel_day: (n) => `${n}일 전`,
    faq_title: '자주 묻는 질문 관리', faq_sub: '자주 쓰는 질문을 등록하면 시작화면·사이드바에 노출됩니다',
    faq_new: '새 질문 등록', faq_placeholder: '예: 지점별 가동률 비교해줘', faq_add: '등록', faq_uses: (n) => `사용 ${n}회`,
    step_suffix: (n) => `${n}단계째`,
    calling: (name) => `${name} 호출 중`,
    unit_label: '단위',
    legend_forecast: '예측치',
    chart_single_point: '값이 한 지점뿐이라 추이 선을 그리지 않았습니다.',
    chart_no_data: '표시할 데이터가 없습니다.',
    assistant_name: '알파카',
    weekday_short: ['일', '월', '화', '수', '목', '금', '토'],
    donut_total: '총 대수',
    err_login_required: '로그인이 필요합니다 — 리본에 다시 로그인해 주세요.',
    err_session_expired: '세션이 만료되었습니다 — 호스트 시스템에 갱신을 요청했습니다. 잠시 후 다시 질문해 주세요.',
    err_forbidden: (why) => `접근 권한이 없습니다 — ${why}`,
    err_forbidden_default: '관리자에게 문의하세요.',
    err_bad_request: (why) => `요청이 올바르지 않습니다 — ${why}`,
    err_generic: '현재 리본 데이터를 조회할 수 없습니다. 잠시 후 다시 시도해 주세요. 문제가 계속되면 관리자에게 문의하세요.',
  },
  ja: {
    adm_watch: 'チャネル条件の通知', adm_watch_hint: '朝のブリーフィングで上限を確認',
    adm_watch_on: 'オン', adm_watch_off: 'オフ',
    adm_watch_note: '朝のブリーフィングはこの設定に関わらず毎日作成されます。売上 · 予約 · キャンセル · 稼働率 · 未配車 · 整備時期は**常に**確認し、この設定は そこに**チャネル上限の超過**を加えるかどうかだけを決めます。\nチャットボットに直接尋ねれば、この設定に関わらずいつでもルールの基準で判定します。\n現在の判定は「上限を超えた」という状態を知らせます。すでに超えているチャネルがあると毎日同じ通知が繰り返されるため、その場合はオフにしておくほうが適切です。',
    adm_side_title: '事業者管理', adm_side_sub: '事業者を選んで設定を編集します',
    adm_key_snippet: '埋め込みスニペット', adm_key_empty: '発行済みのキーはありません',
    adm_key_revoked: '失効', adm_key_active: '有効', adm_key_revoke: '失効',
    // ── 管理画面(admin.jsx) ───────────────────────────────────────────
    adm_tab_tenant: '事業者設定', adm_tab_apikeys: 'APIキー(ウィジェット)', adm_tab_feedback: '問題提起の受信箱',
    adm_checking: '確認中…', adm_loading: '読み込み中…', adm_fold_classes: '台帳のクラス一覧', adm_fold_classes_hint: 'ルールに書くコードを確認', adm_classes_note: '**ここで名前は入力しません。** クラスがどの車かは、ルールの「クラス定義」に文章で書きます。\n台帳にはコードだけがあり、それが何の車かは入っていません。ルールで名前を付けると「ミニバンの稼働率」のように車型の言葉で尋ねられるようになり、**ひとつでも空いていると**チャットボットは推測せずコードを聞き返します。\n下のボタンでコード一覧をルールの形式でコピーし、ルールに貼り付けて名前だけ埋めてください。', adm_classes_copy: 'ルール形式でコピー', adm_classes_count: (n) => `クラス ${n} 件`, adm_classes_tpl_head: '■ クラス定義 — 台帳コード = 自社での呼び名', adm_rx_class: 'クラス', adm_rx_class_code: 'コード:', adm_rx_class_missing: (c) => `名前がありません: ${c}`, adm_rx_class_unknown: (c, l) => `この事業者に存在しないクラスコードです: ${c} = ${l}`,
    adm_need_role_title: '管理者権限が必要です',
    adm_need_role_body: '会社設定と問題報告の受付箱は、システム管理者または会社管理者のみ開けます。',
    adm_current_roles: '現在のロール', adm_none: 'なし',
    adm_need_role_ask: '権限が必要な場合は管理者にお問い合わせください。',
    adm_save: '保存', adm_saving: '保存中…', adm_saved: '保存しました',
    adm_static: '静的閲覧(バックエンド未接続)',
    adm_key: 'キー', adm_mosaicar: 'モザイカ', adm_subscribed: '契約中', adm_unsubscribed: '未契約', adm_pilot_badge: 'パイロット',
    adm_sec_rules: '自社ルール', adm_sec_llm: 'LLM 設定', adm_sec_flags: '機能フラグ',
    adm_rules_label: 'ルール',
    adm_rules_hint: '判断の基準 · チャネル上限 · 比較しない対象',
    adm_rules_ph: '■ 何を見て判断するか\n- ...\n\n■ チャネル上限\n- チェジュパスの予約件数はホームページの1/3を超えない\n- チェジュパスの単価はホームページの60%以上\n\n■ 比較しないもの\n- ...',
    adm_rules_count: (n) => `${n} / 4000文字`,
    adm_rules_over: ' — 超えた分は反映されません',
    adm_rules_note: '上限も文章で書きます。たとえば「チェジュパスの予約件数はホームページの1/3を超えない」のように書きます。\n**「ルールから判定基準を取り出す」** を押すと、解釈結果を先にお見せします。確認された値でサーバーが判定し、チャットボットが文中の数値を直接計算することはありません。\n**取引先コードは書きません。** 普段の呼び名だけ書けば、サーバーが台帳から探します。\nルールが照会結果と食い違う場合は、照会結果に従います。',
    adm_provider: 'プロバイダ', adm_provider_hint: '応答生成 LLM プロバイダ',
    adm_model: 'モデル', adm_model_hint: '例: gpt-4o · gpt-5-mini（一覧から選ぶか直接入力）',
    adm_effort: '推論強度', adm_effort_hint: 'gpt-5 系（推論モデル）専用 — 下げるほど速く安くなります。gpt-4o 系に設定すると毎回エラー(400)になるため、その場合は未設定のままにしてください。', adm_effort_unset: '未設定（モデル既定）',
    adm_flag_fleet: 'フリート最適化', adm_flag_fleet_hint: 'クラス診断 · 増車減車 · 車種別稼働率', adm_flag_fleet_note: 'オンにすると、クラス別の稼働率診断、増車・減車シミュレーション、車種別の稼働率照会が利用できます。\n**オフにすると車種単位の質問も併せて使えなくなります。** 「モーニングとレイを比較して」のように個別車種を尋ねる質問がこれに当たるため、車種で管理される場合はオンのままにすることをおすすめします。\n料金の推奨と台帳の照会は、この設定に関わらず動作します。',
    adm_rx_btn: 'ルールから判定基準を取り出す', adm_rx_busy: '解析中…',
    adm_rx_hint: 'ルールから上限と基準チャネルを探して表示します · 確認すると保存されます',
    adm_rx_title: 'このように理解しました', adm_rx_nothing: 'このルールからは判定基準が見つかりませんでした。',
    adm_rx_excluded: '上限対象から除外',
    adm_rx_primary: '基準チャネル', adm_rx_target: '対象チャネル', adm_rx_ledger: '台帳:', adm_rx_cnt: '件',
    adm_rx_wrote: (raw) => `解析値: ${raw}`,
    adm_rx_quote: (q) => `ルール: "${q}"`,
    adm_rx_missing: 'ルールに無いため判定しません', adm_rx_not_written: 'ルール文にありません',
    adm_rx_n_count: '件数上限', adm_rx_n_rate: '単価下限', adm_rx_n_turns: '稼働回数基準', adm_rx_turns_unit: '回/台·月',
    adm_rx_unusable: '基準チャネルと対象チャネルが両方そろって初めて判定します — 現状では判定しません。',
    adm_rx_unres: (n) => `解析できなかったもの (${n})`,
    adm_rx_none: (q) => `'${q}' に一致する取引先が台帳にありません。`,
    adm_rx_cands: '近いもの:',
    adm_rx_ambig: (q) => `'${q}' で複数該当したため選びませんでした — 下の「判定基準」で直接お選びください。`,
    adm_rx_group_empty: (g) => `'${g}' グループは取引先が一つも見つからず作成しませんでした。`,
    adm_rx_num_rejected: (f, raw) => `${f} の値 "${raw}" は受け付けられません — 1/3 · 33% · 0.333 のように書いてください。`,
    adm_rx_sentence: (t) => `条件のようですが変換できませんでした: "${t}"`,
    adm_rx_no_json: '解釈結果を読み取れませんでした。ルールをもう少し明確に書いてから、もう一度お試しください。',
    adm_rx_apply: 'この内容で反映して保存', adm_rx_cancel: 'キャンセル',
    adm_rx_stale: 'ルールが変更されました。再度解釈するまで判定には反映されず、それまでは以前のルールで判定します。',
    adm_rx_derived: '下の「判定基準」は、上のルール文から解釈して保存した値です。',
    adm_rx_err: (m) => `解析できませんでした: ${m}`,
    adm_rx_ledger_off: '台帳の取引先一覧を読み取れませんでした。名前をコードに変換できなかっただけで、そのチャネルが存在しないという意味ではありません。',
    adm_rx_none_ledger: (q) => `'${q}' — 台帳一覧が無いため確認できませんでした。`,
    adm_fold_lexicon: '台帳の取引先一覧', adm_fold_lexicon_hint: 'ルールに書く正確な表記を確認',
    adm_lexicon_note: '**この一覧で選んだ値は保存されません。** ルールに書く正確な表記を確認するためのものです。\n件数と売上を見てどの行が目的のチャネルかを確認し、その名前をルールの「チャネル定義」に書きます。\n台帳の表記が普段の呼び名と異なることがよくあります。たとえば「チェジュパス」は台帳では「キャフリックス」として入っています。',
    adm_copy: '名称をコピー', adm_copied: 'コピー済み',
    adm_sec_watch: '朝のブリーフィング',
    adm_fold_weights: '料金係数 (内部)', adm_fold_weights_hint: 'システム管理者専用',
    adm_search_ph: 'チャネル名で検索 (例: カモア)',
    adm_not_in_list: '(一覧にありません)', adm_no_result: '検索結果がありません',
    adm_ledger_fail: '台帳から取引先一覧を取得できませんでした。一覧が空であるのとは別の状態です。しばらくしてから開き直してください。',
    adm_cnt: (n) => `${n}件`, adm_man: '万',
    adm_cars: '台',
    adm_as_of: (d) => `${d} 保有基準`,
    adm_class_fail: 'クラス一覧を取得できませんでした。一覧が空であるのとは別の状態です。しばらくしてから開き直してください。',
    adm_clear: '消す',
    adm_weights_label: '係数オーバーライド', adm_weights_hint: 'JSON で入力します · 空にすると既定値を使用します',
    adm_weights_err_obj: 'オブジェクト(JSON)である必要があります',
    adm_weights_err_json: 'JSON 形式ではありません · 保存すると最後の正常値が維持されます',
    adm_weights_note: '既定の係数は**バックテストで検証されていない値**です。推奨根拠の表にその旨が注記として表示されます。\nここで調整した項目は、推奨根拠に名前が併記されます。実測の根拠がある係数は航空便数のみです。',
    adm_key_title: 'ウィジェット API キー発行', adm_key_tenant: '事業者', adm_key_default: ' · 既定 ', adm_key_label: 'ラベル',
    adm_key_label_ph: '例: SKY ポータル ウィジェット',
    adm_key_origins: '許可 origin', adm_key_origins_hint: 'ウィジェットを表示するページのスキームとホスト · 改行またはカンマで区切ります',
    adm_key_locale: '既定の言語', adm_key_locale_hint: 'ウィジェットが最初に開くときの言語 · ユーザーが変更した場合はその選択に従います',
    adm_key_roles: '発行可能なロールの上限',
    adm_key_ttl: 'セッション保持時間', adm_key_ttl_hint: '秒単位、最大 28800(8時間) · ホストが値を渡さない場合はこの値を使用します',
    adm_key_issuing: '発行中…', adm_key_issue: 'キー発行',
    adm_key_issued: '発行が完了しました · シークレットは今回のみ表示されます',
    adm_key_public: ' (公開) ', adm_key_secret: ' (ホストサーバーの env に保管) ',
    adm_key_revoke_confirm: 'このキーを失効させますか？ 失効するとこのキーではセッションを発行できません。',
    app_title: 'アルパカ — リボンAI', prototype: '試作',
    temp_login: '仮ログイン', temp_login_busy: 'ログイン中…',
    temp_login_tip: 'リボンのログイン連携前のテスト用 — クリックするとサーバーが一時トークンを発行し、そのままログインします',
    temp_login_tip_code: 'リボンのログイン連携前のテスト用 — 共有パスコードを入力するとサーバーが一時トークンを発行し、そのままログインします',
    temp_login_prompt: '仮ログイン — 共有パスコードを入力してください。',
    nav_chat: 'チャット', nav_admin: '管理', nav_manual: 'マニュアル',
    lang_title: '言語 / Language', tenant_title: '事業者切替',
    status_online: 'オンライン', status_context: '文脈を保持中',
    hdr_new: '新しい会話', hdr_faq: 'FAQ管理', hdr_close: '会話を終了(保存)',
    welcome_title: 'ご用件は何でしょう？',
    welcome_sub: 'リボンERPの在庫・予約・売上データを分析し、チャートと表でお答えします。文脈は会話の間ずっと保持されます。',
    suggested: 'おすすめの質問',
    chip_today: '今日',
    brief_signals: '昨日({date}) 異常サイン {n}件',
    brief_none: '昨日({date}) 特記事項はありません',
    brief_skipped: '判定保留 {n}件',
    sev_critical: '要対応',
    sev_warning: '注意',
    sev_info: '参考',
    input_placeholder: '車種・期間・指標を含めて質問してください',
    disclaimer: 'リボンAIは誤ることがあります。重要な数値は元データをご確認ください。',
    analyzing: '分析中…', understanding: '質問を理解中…', sources: '出典',
    act_copy: 'コピー', act_regen: '再生成', act_export: 'エクスポート', act_report: '問題報告',
    copied: 'コピーしました', exported: 'チャートをPNGで書き出しました',
    usage_label: 'トークン', usage_in: '入力', usage_cached: 'キャッシュ', usage_out: '出力', usage_cost: '費用', usage_presenter: '画面構成',
    empty_reply: '(空の応答)',
    side_recent: '最近の会話', side_empty: '保存された会話はまだありません。', side_pinned: '固定質問', side_manage: '管理',
    act_delete: '削除', act_pin: '固定',
    rel_now: 'たった今', rel_min: (n) => `${n}分前`, rel_hour: (n) => `${n}時間前`, rel_day: (n) => `${n}日前`,
    faq_title: 'よくある質問の管理', faq_sub: 'よく使う質問を登録すると開始画面・サイドバーに表示されます',
    faq_new: '新しい質問を登録', faq_placeholder: '例: 拠点別の稼働率を比較して', faq_add: '登録', faq_uses: (n) => `使用 ${n}回`,
    step_suffix: (n) => `${n}ステップ目`,
    calling: (name) => `${name} を呼び出し中`,
    unit_label: '単位',
    legend_forecast: '予測値',
    chart_single_point: '値が1地点のみのため、推移線は描画していません。',
    chart_no_data: '表示するデータがありません。',
    assistant_name: 'アルパカ',
    weekday_short: ['日', '月', '火', '水', '木', '金', '土'],
    donut_total: '総台数',
    err_login_required: 'ログインが必要です — リボンに再度ログインしてください。',
    err_session_expired: 'セッションの有効期限が切れました — ホストシステムに更新を要求しました。しばらくしてからもう一度質問してください。',
    err_forbidden: (why) => `アクセス権限がありません — ${why}`,
    err_forbidden_default: '管理者にお問い合わせください。',
    err_bad_request: (why) => `リクエストが正しくありません — ${why}`,
    err_generic: '現在リボンのデータを照会できません。しばらくしてからもう一度お試しください。続く場合は管理者にお問い合わせください。',
  },
  en: {
    adm_watch: 'Channel limit alerts', adm_watch_hint: 'Check limits in the morning briefing',
    adm_watch_on: 'On', adm_watch_off: 'Off',
    adm_watch_note: 'The morning briefing runs every day regardless of this setting. Revenue, bookings, cancellations, utilization, unassigned vehicles and service intervals are **always** checked; this setting only decides whether **channel limit breaches** are added to them.\nAsking the chatbot directly always applies your rules, whether or not this is on.\nToday the check reports a state — that a limit has been exceeded. If a channel is already over, the same alert repeats every day, so it is usually better left off in that case.',
    adm_side_title: 'Tenants', adm_side_sub: 'Pick a tenant to edit its settings',
    adm_key_snippet: 'Embed snippet', adm_key_empty: 'No keys issued',
    adm_key_revoked: 'Revoked', adm_key_active: 'Active', adm_key_revoke: 'Revoke',
    // ── Admin screen (admin.jsx) ─────────────────────────────────────
    adm_tab_tenant: 'Tenant settings', adm_tab_apikeys: 'API keys (widget)', adm_tab_feedback: 'Reported issues',
    adm_checking: 'Checking…', adm_loading: 'Loading…', adm_fold_classes: 'Ledger class list', adm_fold_classes_hint: 'Check the codes to use in your rules', adm_classes_note: '**Names are not entered here.** What each class actually is belongs in the Class definitions section of your rules.\nThe ledger holds codes only — not what kind of car each one is. Name them in your rules and people can ask in everyday terms such as minivan utilization; **while any class is unnamed**, the chatbot asks for the code instead of guessing.\nUse the button below to copy the code list in rules format, paste it into your rules, and fill in the names.', adm_classes_copy: 'Copy in rules format', adm_classes_count: (n) => `${n} classes`, adm_classes_tpl_head: '■ Class definitions — ledger code = what we call it', adm_rx_class: 'Class', adm_rx_class_code: 'code:', adm_rx_class_missing: (c) => `No name given: ${c}`, adm_rx_class_unknown: (c, l) => `No such class code for this company: ${c} = ${l}`,
    adm_need_role_title: 'Admin access required',
    adm_need_role_body: 'Company settings and the feedback inbox are open to system administrators and company administrators only.',
    adm_current_roles: 'Your roles', adm_none: 'none',
    adm_need_role_ask: 'Ask an administrator if you need access.',
    adm_save: 'Save', adm_saving: 'Saving…', adm_saved: 'Saved',
    adm_static: 'Read-only (backend not connected)',
    adm_key: 'keys', adm_mosaicar: 'Mosaicar', adm_subscribed: 'subscribed', adm_unsubscribed: 'not subscribed', adm_pilot_badge: 'Pilot',
    adm_sec_rules: 'Our rules', adm_sec_llm: 'LLM', adm_sec_flags: 'Feature flags',
    adm_rules_label: 'Rules',
    adm_rules_hint: 'What to look at first · channel limits · what not to compare',
    adm_rules_ph: '■ What we judge by\n- ...\n\n■ Channel limits\n- Jeju Pass bookings must not exceed 1/3 of the homepage\n- Jeju Pass rate per booking must be at least 60% of the homepage\n\n■ What we do not compare against\n- ...',
    adm_rules_count: (n) => `${n} / 4000 chars`,
    adm_rules_over: ' — anything beyond this is not applied',
    adm_rules_note: 'Write limits as sentences too — for example, \"Jeju Pass bookings stay under one third of the homepage.\"\n**Extract criteria from rules** shows how the text was read before anything is saved. The server judges using the values you confirm; the chatbot never calculates from the numbers in the text.\n**Do not enter partner codes.** Write the names you normally use and the server will find them in the ledger.\nWhere a rule conflicts with query results, the query results win.',
    adm_provider: 'Provider', adm_provider_hint: 'LLM provider used for answers',
    adm_model: 'Model', adm_model_hint: 'e.g. gpt-4o · gpt-5-mini (pick from the list or type)',
    adm_effort: 'Reasoning effort', adm_effort_hint: 'For gpt-5-family (reasoning) models only — lower is faster and cheaper. Setting it on gpt-4o-class models makes every question fail (400); leave unset in that case.', adm_effort_unset: 'Not set (model default)',
    adm_flag_fleet: 'Fleet optimization', adm_flag_fleet_hint: 'Diagnostics · fleet sizing · by model', adm_flag_fleet_note: 'When on, class-level utilization diagnostics, add/remove vehicle simulations and utilization by model are available.\n**Turning it off also blocks questions about individual models** — \"compare the Morning and the Ray\" is one of them. Keep it on if you manage the fleet by model.\nPrice recommendations and ledger queries work regardless of this setting.',
    adm_rx_btn: 'Extract decision inputs from rules', adm_rx_busy: 'Reading…',
    adm_rx_hint: 'Finds the limits and reference channel in your rules · saved once you confirm',
    adm_rx_title: 'Here is what we understood', adm_rx_nothing: 'No decision inputs were found in these rules.',
    adm_rx_excluded: 'Excluded from limits',
    adm_rx_primary: 'Baseline channel', adm_rx_target: 'Compared channel', adm_rx_ledger: 'Ledger:', adm_rx_cnt: '',
    adm_rx_wrote: (raw) => `parsed as: ${raw}`,
    adm_rx_quote: (q) => `rule: "${q}"`,
    adm_rx_missing: 'Not in your rules — not judged', adm_rx_not_written: 'not written in the rules',
    adm_rx_n_count: 'Count ceiling', adm_rx_n_rate: 'Rate floor', adm_rx_n_turns: 'Turns threshold', adm_rx_turns_unit: 'turns/unit·month',
    adm_rx_unusable: 'Judging needs both a baseline and a compared channel — as it stands, nothing is judged.',
    adm_rx_unres: (n) => `Not interpreted (${n})`,
    adm_rx_none: (q) => `No ledger account matches '${q}'.`,
    adm_rx_cands: 'Closest:',
    adm_rx_ambig: (q) => `'${q}' matched several accounts, so none was picked — choose one under "Decision inputs" below.`,
    adm_rx_group_empty: (g) => `The '${g}' group was not created — no account matched any of its names.`,
    adm_rx_num_rejected: (f, raw) => `${f} value "${raw}" cannot be accepted — write it as 1/3, 33% or 0.333.`,
    adm_rx_sentence: (t) => `Looks like a condition but could not be mapped: "${t}"`,
    adm_rx_no_json: 'The extraction could not be read. Write the rules a little more explicitly and try again.',
    adm_rx_apply: 'Apply and save', adm_rx_cancel: 'Cancel',
    adm_rx_stale: 'The rules have changed. They apply to judgements only after another extraction; until then the previous rules are used.',
    adm_rx_derived: 'The criteria below were extracted from the rules above and saved.',
    adm_rx_err: (m) => `Could not interpret: ${m}`,
    adm_rx_ledger_off: 'The partner list could not be read. Names were not resolved to codes — it does not mean those channels are missing.',
    adm_rx_none_ledger: (q) => `'${q}' — could not be checked because the ledger list was unavailable.`,
    adm_fold_lexicon: 'Ledger accounts', adm_fold_lexicon_hint: 'Find the exact spelling to use in your rules',
    adm_lexicon_note: '**Nothing selected here is saved.** This list exists so you can find the exact spelling to write in your rules.\nUse the booking counts and revenue to identify the right row, then write that name into the Channel definitions section of your rules.\nLedger names often differ from everyday names — Jeju Pass, for instance, appears as Caflix.',
    adm_copy: 'Copy name', adm_copied: 'Copied',
    adm_sec_watch: 'Morning briefing',
    adm_fold_weights: 'Pricing coefficients (internal)', adm_fold_weights_hint: 'System administrators only',
    adm_search_ph: 'Search channels by name',
    adm_not_in_list: '(not in list)', adm_no_result: 'No matches',
    adm_ledger_fail: 'The partner list could not be loaded from the ledger. This is not the same as an empty list; please try again shortly.',
    adm_cnt: (n) => `${n}`, adm_man: 'K',
    adm_cars: 'cars',
    adm_as_of: (d) => `fleet as of ${d}`,
    adm_class_fail: 'The class list could not be loaded. This is not the same as an empty list; please try again shortly.',
    adm_clear: 'Clear',
    adm_weights_label: 'Coefficient overrides', adm_weights_hint: 'Enter as JSON · leave empty to use the defaults',
    adm_weights_err_obj: 'Must be a JSON object',
    adm_weights_err_json: 'Not valid JSON · saving keeps the last valid value',
    adm_weights_note: 'The default weights are **not backtested**. That is noted as a footnote in the recommendation rationale.\nAnything overridden here is named in that rationale. Flight volume is the only weight with measured evidence behind it.',
    adm_key_title: 'Issue widget API key', adm_key_tenant: 'Tenant', adm_key_default: ' · default ', adm_key_label: 'Label',
    adm_key_label_ph: 'e.g. SKY portal widget',
    adm_key_origins: 'Allowed origins', adm_key_origins_hint: 'Scheme and host of the page that embeds the widget · separate with line breaks or commas',
    adm_key_locale: 'Default language', adm_key_locale_hint: 'Language the widget opens in · a user\'s own choice takes precedence',
    adm_key_roles: 'Maximum role that can be issued',
    adm_key_ttl: 'Session lifetime', adm_key_ttl_hint: 'Seconds, up to 28800 (8 hours) · used when the host does not pass a value',
    adm_key_issuing: 'Issuing…', adm_key_issue: 'Issue key',
    adm_key_issued: 'Key issued · the secret is shown this once only',
    adm_key_public: ' (public) ', adm_key_secret: ' (keep in host server env) ',
    adm_key_revoke_confirm: 'Revoke this key? Once revoked it can no longer issue sessions.',
    app_title: 'Alpaca — Reborn AI', prototype: 'Prototype',
    temp_login: 'Temp login', temp_login_busy: 'Signing in…',
    temp_login_tip: 'For testing before Reborn SSO — click to get a temporary token and sign in',
    temp_login_tip_code: 'For testing before Reborn SSO — enter the shared passcode to get a temporary token and sign in',
    temp_login_prompt: 'Temp login — enter the shared passcode.',
    nav_chat: 'Chat', nav_admin: 'Admin', nav_manual: 'Manual',
    lang_title: 'Language / 언어', tenant_title: 'Switch company',
    status_online: 'Online', status_context: 'Context kept',
    hdr_new: 'New chat', hdr_faq: 'Manage FAQ', hdr_close: 'End & save',
    welcome_title: 'How can I help you?',
    welcome_sub: 'I analyze Reborn ERP inventory, booking, and revenue data and answer with charts and tables. Context is kept throughout the conversation.',
    suggested: 'Suggested questions',
    chip_today: 'Today',
    brief_signals: 'Yesterday ({date}): {n} signal(s)',
    brief_none: 'Yesterday ({date}): nothing unusual',
    brief_skipped: '{n} check(s) skipped',
    sev_critical: 'Act now',
    sev_warning: 'Watch',
    sev_info: 'FYI',
    input_placeholder: 'Ask with a vehicle type, period, and metric',
    disclaimer: 'Reborn AI can make mistakes. Verify important figures against the source data.',
    analyzing: 'Analyzing…', understanding: 'Understanding your question…', sources: 'Sources',
    act_copy: 'Copy', act_regen: 'Regenerate', act_export: 'Export', act_report: 'Report',
    copied: 'Copied', exported: 'Exported chart as PNG',
    usage_label: 'tokens', usage_in: 'input', usage_cached: 'cached', usage_out: 'output', usage_cost: 'cost', usage_presenter: 'layout',
    empty_reply: '(empty response)',
    side_recent: 'Recent', side_empty: 'No saved conversations yet.', side_pinned: 'Pinned', side_manage: 'Manage',
    act_delete: 'Delete', act_pin: 'Pin',
    rel_now: 'just now', rel_min: (n) => `${n}m ago`, rel_hour: (n) => `${n}h ago`, rel_day: (n) => `${n}d ago`,
    faq_title: 'Manage FAQs', faq_sub: 'Registered questions appear on the welcome screen and sidebar',
    faq_new: 'Add a question', faq_placeholder: 'e.g. Compare utilization by branch', faq_add: 'Add', faq_uses: (n) => `${n} uses`,
    step_suffix: (n) => `step ${n}`,
    calling: (name) => `calling ${name}`,
    unit_label: 'Unit',
    legend_forecast: 'forecast',
    chart_single_point: 'Only one data point — no trend line drawn.',
    chart_no_data: 'No data to display.',
    assistant_name: 'Alpaca',
    weekday_short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
    donut_total: 'Total cars',
    err_login_required: 'Sign-in required — please log in to Reborn again.',
    err_session_expired: 'Your session expired — a refresh was requested from the host system. Please ask again in a moment.',
    err_forbidden: (why) => `You do not have access — ${why}`,
    err_forbidden_default: 'Contact an administrator.',
    err_bad_request: (why) => `That request was not valid — ${why}`,
    err_generic: 'Reborn data cannot be queried right now. Please try again shortly; contact an administrator if it persists.',
  },
  zh: {
    adm_watch: '渠道条件提醒', adm_watch_hint: '在晨间简报中检查渠道上限',
    adm_watch_on: '开启', adm_watch_off: '关闭',
    adm_watch_note: '无论此设置如何，晨间简报每天都会生成。销售额 · 预订 · 取消 · 出租率 · 未派车 · 保养周期**始终**会检查，此设置只决定是否在其中加入**渠道上限超标**。\n直接询问聊天机器人时，无论此设置如何，都会按规则的标准判定。\n目前的判定报告的是「已超过上限」这一状态。若某渠道已经超标，每天都会重复同样的提醒，这种情况下建议保持关闭。',
    adm_side_title: '业者管理', adm_side_sub: '选择业者以编辑其设置',
    adm_key_snippet: '嵌入代码', adm_key_empty: '尚未发放密钥',
    adm_key_revoked: '作废', adm_key_active: '启用', adm_key_revoke: '作废',
    // ── 管理页面(admin.jsx) ──────────────────────────────────────────
    adm_tab_tenant: '业者设置', adm_tab_apikeys: 'API 密钥(挂件)', adm_tab_feedback: '问题反馈收件箱',
    adm_checking: '确认中…', adm_loading: '加载中…', adm_fold_classes: '台账级别列表', adm_fold_classes_hint: '确认要写入规则的代码', adm_classes_note: '**这里不填写名称。** 各级别具体是什么车，写在规则的「级别定义」中。\n台账里只有代码，没有它是什么车的信息。在规则中命名后，就可以用「MPV 出租率」这样的车型词汇提问；**只要还有一个没有命名**，聊天机器人就不会猜测，而是反问代码。\n用下面的按钮把代码列表按规则格式复制，粘贴到规则中，只需填上名称即可。', adm_classes_copy: '按规则格式复制', adm_classes_count: (n) => `级别 ${n} 个`, adm_classes_tpl_head: '■ 级别定义 — 台账代码 = 我们的叫法', adm_rx_class: '级别', adm_rx_class_code: '代码:', adm_rx_class_missing: (c) => `没有名称: ${c}`, adm_rx_class_unknown: (c, l) => `该商户不存在此级别代码: ${c} = ${l}`,
    adm_need_role_title: '需要管理员权限',
    adm_need_role_body: '公司设置与问题反馈收件箱仅限系统管理员或公司管理员打开。',
    adm_current_roles: '当前角色', adm_none: '无',
    adm_need_role_ask: '如需权限请联系管理员。',
    adm_save: '保存', adm_saving: '保存中…', adm_saved: '已保存',
    adm_static: '静态查看(后端未连接)',
    adm_key: '密钥', adm_mosaicar: 'Mosaicar', adm_subscribed: '已订阅', adm_unsubscribed: '未订阅', adm_pilot_badge: '试点',
    adm_sec_rules: '本公司规则', adm_sec_llm: 'LLM 设置', adm_sec_flags: '功能开关',
    adm_rules_label: '规则',
    adm_rules_hint: '判断依据 · 渠道上限 · 不作比较的对象',
    adm_rules_ph: '■ 依据什么判断\n- ...\n\n■ 渠道上限\n- 济州通的预订单量不超过官网的1/3\n- 济州通的单均价不低于官网的60%\n\n■ 不作比较的对象\n- ...',
    adm_rules_count: (n) => `${n} / 4000字`,
    adm_rules_over: ' — 超出部分不会生效',
    adm_rules_note: '上限也用句子写。例如：\"济州通行证的预订量不超过官网的三分之一\"。\n点击 **「从规则中提取判定标准」**，会先显示系统的理解结果。服务器按您确认的数值判定，聊天机器人不会直接用句中的数字计算。\n**请勿填写客户代码。** 只写平时的称呼，服务器会在台账中查找。\n规则与查询结果不一致时，以查询结果为准。',
    adm_provider: '提供方', adm_provider_hint: '生成回答的 LLM 提供方',
    adm_model: '模型', adm_model_hint: '例: gpt-4o · gpt-5-mini（从列表选择或直接输入）',
    adm_effort: '推理强度', adm_effort_hint: '仅适用于 gpt-5 系列（推理模型）— 越低越快越省。若为 gpt-4o 类模型设置，每次提问都会报错(400)，此时请保持未设置。', adm_effort_unset: '未设置（模型默认）',
    adm_flag_fleet: '车队优化', adm_flag_fleet_hint: '级别诊断 · 增减车 · 各车型出租率', adm_flag_fleet_note: '开启后可使用按级别的出租率诊断、增车与减车模拟，以及各车型出租率查询。\n**关闭后按车型提问也会一并被阻止。** 「比较一下 Morning 和 Ray」这类针对单个车型的提问即属于此类，因此按车型管理的公司建议保持开启。\n价格推荐与台账查询不受此设置影响。',
    adm_rx_btn: '从规则中提取判定基准', adm_rx_busy: '解析中…',
    adm_rx_hint: '从规则中找出上限和基准渠道并显示 · 确认后保存',
    adm_rx_title: '我们这样理解', adm_rx_nothing: '这些规则中未找到判定基准。',
    adm_rx_excluded: '不适用上限',
    adm_rx_primary: '基准渠道', adm_rx_target: '对比渠道', adm_rx_ledger: '台账:', adm_rx_cnt: '单',
    adm_rx_wrote: (raw) => `解析值: ${raw}`,
    adm_rx_quote: (q) => `规则: "${q}"`,
    adm_rx_missing: '规则中没有 — 不作判定', adm_rx_not_written: '规则文本中没有',
    adm_rx_n_count: '单量上限', adm_rx_n_rate: '单价下限', adm_rx_n_turns: '周转次数基准', adm_rx_turns_unit: '次/台·月',
    adm_rx_unusable: '需要同时有基准渠道和对比渠道才能判定 — 当前不作判定。',
    adm_rx_unres: (n) => `未能解析 (${n})`,
    adm_rx_none: (q) => `台账中没有与 '${q}' 匹配的客户。`,
    adm_rx_cands: '相近的:',
    adm_rx_ambig: (q) => `'${q}' 匹配到多家，因此未选 — 请在下方「判定基准」中自行选择。`,
    adm_rx_group_empty: (g) => `'${g}' 分组未创建 — 没有找到任何对应客户。`,
    adm_rx_num_rejected: (f, raw) => `${f} 的值 "${raw}" 无法接受 — 请写成 1/3 · 33% · 0.333。`,
    adm_rx_sentence: (t) => `看似条件但无法转换: "${t}"`,
    adm_rx_no_json: '未能读取提取结果。请将规则写得更明确一些，然后重试。',
    adm_rx_apply: '按此应用并保存', adm_rx_cancel: '取消',
    adm_rx_stale: '规则已更改。需要重新提取才会反映到判定中，在此之前仍按之前的规则判定。',
    adm_rx_derived: '下方的「判定标准」是从上面的规则中提取并保存的数值。',
    adm_rx_err: (m) => `无法解析: ${m}`,
    adm_rx_ledger_off: '未能读取台账客户列表。只是未能将名称转换为代码，并不表示该渠道不存在。',
    adm_rx_none_ledger: (q) => `'${q}' — 因台账列表不可用而无法确认。`,
    adm_fold_lexicon: '台账客户列表', adm_fold_lexicon_hint: '查找写入规则的准确名称',
    adm_lexicon_note: '**在此列表中选择的内容不会被保存。** 它用于确认写入规则的准确名称。\n根据订单量和销售额确认哪一行是您要找的渠道，然后把该名称写入规则的「渠道定义」中。\n台账名称常与平时的称呼不同。例如「济州通行证」在台账中记为「Caflix」。',
    adm_copy: '复制名称', adm_copied: '已复制',
    adm_sec_watch: '晨间简报',
    adm_fold_weights: '价格系数 (内部)', adm_fold_weights_hint: '仅限系统管理员',
    adm_search_ph: '按渠道名称搜索',
    adm_not_in_list: '(不在列表中)', adm_no_result: '无搜索结果',
    adm_ledger_fail: '未能从台账获取客户列表。这与列表为空是不同的状态，请稍后重新打开。',
    adm_cnt: (n) => `${n}件`, adm_man: '万',
    adm_cars: '台',
    adm_as_of: (d) => `${d} 保有基准`,
    adm_class_fail: '未能获取级别列表。这与列表为空是不同的状态，请稍后重新打开。',
    adm_clear: '清除',
    adm_weights_label: '系数覆盖', adm_weights_hint: '以 JSON 输入 · 留空则使用默认值',
    adm_weights_err_obj: '必须是对象(JSON)',
    adm_weights_err_json: '不是 JSON 格式 · 保存后将保留最后一次的有效值',
    adm_weights_note: '默认系数是**未经回测验证的数值**。推荐依据表中会以脚注标明这一点。\n在此调整的项目会在推荐依据中一并显示名称。有实测依据的系数只有航班数量。',
    adm_key_title: '发放挂件 API 密钥', adm_key_tenant: '业者', adm_key_default: ' · 默认 ', adm_key_label: '标签',
    adm_key_label_ph: '例: SKY 门户挂件',
    adm_key_origins: '允许的 origin', adm_key_origins_hint: '嵌入组件的页面的协议与主机 · 用换行或逗号分隔',
    adm_key_locale: '默认语言', adm_key_locale_hint: '组件首次打开时的语言 · 用户自行更改后以其选择为准',
    adm_key_roles: '可发放的最高角色',
    adm_key_ttl: '会话保持时长', adm_key_ttl_hint: '以秒为单位，最大 28800(8小时) · 主机未传值时使用此值',
    adm_key_issuing: '发放中…', adm_key_issue: '发放密钥',
    adm_key_issued: '发放完成 · 密钥仅显示这一次',
    adm_key_public: ' (公开) ', adm_key_secret: ' (保存在主机服务器 env) ',
    adm_key_revoke_confirm: '要吊销此密钥吗？吊销后将无法用它发放会话。',
    app_title: 'Alpaca — Reborn AI', prototype: '原型',
    temp_login: '临时登录', temp_login_busy: '登录中…',
    temp_login_tip: 'Reborn 登录联动前的测试用 — 点击后服务器签发临时令牌并直接登录',
    temp_login_tip_code: 'Reborn 登录联动前的测试用 — 输入共享口令后服务器签发临时令牌并直接登录',
    temp_login_prompt: '临时登录 — 请输入共享口令。',
    nav_chat: '聊天', nav_admin: '管理', nav_manual: '手册',
    lang_title: '语言 / Language', tenant_title: '切换企业',
    status_online: '在线', status_context: '保持上下文',
    hdr_new: '新对话', hdr_faq: 'FAQ 管理', hdr_close: '结束并保存',
    welcome_title: '有什么可以帮您？',
    welcome_sub: '我会分析 Reborn ERP 的库存、预订和营收数据，用图表和表格作答。整个对话过程中都会保持上下文。',
    suggested: '推荐问题',
    chip_today: '今日',
    brief_signals: '昨天({date}) 异常信号 {n} 项',
    brief_none: '昨天({date}) 无异常',
    brief_skipped: '判定保留 {n} 项',
    sev_critical: '立即处理',
    sev_warning: '注意',
    sev_info: '参考',
    input_placeholder: '请包含车型、时间段和指标来提问',
    disclaimer: 'Reborn AI 可能会出错。重要数据请核对原始数据。',
    analyzing: '分析中…', understanding: '正在理解问题…', sources: '来源',
    act_copy: '复制', act_regen: '重新生成', act_export: '导出', act_report: '反馈问题',
    copied: '已复制', exported: '已将图表导出为 PNG',
    usage_label: '令牌', usage_in: '输入', usage_cached: '缓存', usage_out: '输出', usage_cost: '费用', usage_presenter: '版面',
    empty_reply: '（空回复）',
    side_recent: '最近对话', side_empty: '还没有已保存的对话。', side_pinned: '置顶问题', side_manage: '管理',
    act_delete: '删除', act_pin: '置顶',
    rel_now: '刚刚', rel_min: (n) => `${n}分钟前`, rel_hour: (n) => `${n}小时前`, rel_day: (n) => `${n}天前`,
    faq_title: '常见问题管理', faq_sub: '登记常用问题后会显示在欢迎页和侧边栏',
    faq_new: '添加新问题', faq_placeholder: '例：按门店比较出租率', faq_add: '添加', faq_uses: (n) => `使用 ${n} 次`,
    step_suffix: (n) => `第${n}步`,
    calling: (name) => `正在调用 ${name}`,
    weekday_short: ['日', '一', '二', '三', '四', '五', '六'],
    donut_total: '总台数',
    err_login_required: '需要登录 — 请重新登录 Reborn。',
    err_session_expired: '会话已过期 — 已向宿主系统请求刷新。请稍后再提问。',
    err_forbidden: (why) => `没有访问权限 — ${why}`,
    err_forbidden_default: '请联系管理员。',
    err_bad_request: (why) => `请求不正确 — ${why}`,
    err_generic: '当前无法查询 Reborn 数据。请稍后重试；如果持续出现，请联系管理员。',
    unit_label: '单位',
    legend_forecast: '预测值',
    chart_single_point: '仅有一个数据点，未绘制趋势线。',
    chart_no_data: '没有可显示的数据。',
    assistant_name: 'Alpaca',
  },
};

// 시작화면 추천 질문 칩 — 언어별. 클릭 시 해당 언어 질문이 그대로 전송된다(LLM 이 다국어 처리).
// 칩 = core 4(어느 테넌트나 답 가능) + ledger 2(원장 연결 시) 또는 noledger 2(원장 미연결 시).
// 그레인 어휘(ALPACA-44): 칩의 {cls} 를 테넌트 class_grain 별 단어로. 한국은 판매 단위가 차종(모델)이라 '차종'
// (조사 붙임 — 은/을은 한국어 칩 문장이 받침 없는 '차종'/'클래스' 둘 다에 맞도록 원문을 '{cls}은/{cls}을'로 두었다).
const RIBBON_CLS_WORD = {
  cmmn_class: { ko: '차종', ja: '車種', en: 'model', zh: '车型' },
  vhcty_code: { ko: '클래스', ja: 'クラス', en: 'class', zh: '级别' },
};
const RIBBON_CHIP_SETS = {
  ko: { core: window.RibbonData.STARTER_CHIPS_CORE, ledger: window.RibbonData.STARTER_CHIPS_LEDGER, noledger: window.RibbonData.STARTER_CHIPS_NOLEDGER },
  ja: {
    core: [
      '今月の週別 稼働率・客単価・売上を見せて',
      '来週の{cls}の稼働率を55%に上げるには、料金をどう設定すべき？',
      '先週いちばん出た{cls}は何で、なぜ急に増えた？',
      '今後どの{cls}を増やし、どれを減らすべき？稼働率を基準に',
    ],
    ledger: [
      '先月の時間帯別 出庫・返却件数と、ピーク時間帯の適正人員は何人？',
      '営業時間を08〜19時に制限すると、損失はどれくらい？',
    ],
    noledger: [
      '来週の{cls}別 予想稼働率とおすすめ料金を教えて',
      '今月の売上は昨年同月と比べてどれくらい違う？',
    ],
  },
  en: {
    core: [
      'Show weekly utilization, ADR and revenue for this month',
      "To lift next week's {cls} utilization to 55%, how should I set rates?",
      'Which {cls} went out the most last week, and why did it spike?',
      'Which {cls}s should I add or cut going forward? Base it on utilization',
    ],
    ledger: [
      "Last month's pickups/returns by hour — how many staff do we need at peak?",
      'If we limit hours to 08–19, how much would we lose?',
    ],
    noledger: [
      "Next week's expected utilization and recommended rates by {cls}",
      "How does this month's revenue compare with the same month last year?",
    ],
  },
  zh: {
    core: [
      '显示本月按周的出租率、客单价和销售额',
      '要把下周{cls}的出租率提到55%，价格该怎么定？',
      '上周出车最多的是哪个{cls}？为什么突然增加？',
      '今后应该增加或减少哪个{cls}？以出租率为依据',
    ],
    ledger: [
      '上个月按时段的取还车件数，高峰时段需要几名人员？',
      '如果把营业时间限制在08~19点，会损失多少？',
    ],
    noledger: [
      '下周各{cls}的预计出租率和推荐价格',
      '本月销售额与去年同月相比差多少？',
    ],
  },
};
// 원장 연결 여부 — main.jsx 가 /api/ready 의 erp 로 세팅(기본 true: 한국 파드 종전 동작).
window.RibbonData.hasErp = (typeof window.RibbonData.hasErp === 'boolean') ? window.RibbonData.hasErp : true;
// 테넌트 그레인 — main.jsx 가 /api/me 의 tenant.class_grain 으로 세팅(기본 cmmn_class = 한국 어휘).
window.RibbonData.classGrain = window.RibbonData.classGrain || 'cmmn_class';
function ribbonChipsFor(locale, hasErp, classGrain) {
  const loc = ribbonNormLocale(locale);
  const set = RIBBON_CHIP_SETS[loc] || RIBBON_CHIP_SETS.ko;
  const extra = hasErp ? set.ledger : set.noledger;
  const word = (RIBBON_CLS_WORD[classGrain] || RIBBON_CLS_WORD.cmmn_class)[loc] || RIBBON_CLS_WORD.cmmn_class.ko;
  // 배치: core 3 → extra 2 → core 4(증차/감차) — 원장 여부와 무관하게 순서 감이 같다.
  const hasBatchim = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; };
  return [...set.core.slice(0, 3), ...extra, set.core[3]].map((c) => {
    let out = c.replace(/\{cls\}/g, word);
    if (loc === 'ko' && !hasBatchim(word)) out = out.split(word + '은').join(word + '는').split(word + '을').join(word + '를');
    return out;
  });
}
function ribbonLocalChips(locale) {
  return ribbonChipsFor(locale, window.RibbonData.hasErp !== false, window.RibbonData.classGrain || 'cmmn_class');
}
// ── 서버 동적 추천질문(ALPACA-44) ──────────────────────────────
// GET /api/starter-chips?locale=&tenant_id= — 서버가 그날 브리핑 신호(매출 급락·미배차·정비 도래 등)를 앞세워
// 테넌트 어휘(차종/클래스)·원장 유무·언어에 맞춘 칩을 만들어 준다(결정적, LLM 없음). 실패·미도착이면 로컬 풀로 폴백.
// 캐시 키 = 테넌트|언어. 같은 세션에서 반복 호출하지 않는다(브리핑 캐시는 서버 몫).
window.RibbonData.serverChips = window.RibbonData.serverChips || {};
function ribbonChipKey(locale) {
  return (window.RibbonData.currentTenantId || '') + '|' + ribbonNormLocale(locale);
}
function ribbonChips(locale) {
  const hit = window.RibbonData.serverChips[ribbonChipKey(locale)];
  return hit && Array.isArray(hit.chips) && hit.chips.length ? hit.chips : ribbonLocalChips(locale);
}
const ribbonChipInflight = {};
function ribbonFetchChips(locale) {
  const key = ribbonChipKey(locale);
  if (window.RibbonData.serverChips[key]) return Promise.resolve(window.RibbonData.serverChips[key]);
  if (ribbonChipInflight[key]) return ribbonChipInflight[key];
  const f = (window.RibbonAuth ? window.RibbonAuth.fetch : fetch);
  const q = new URLSearchParams({ locale: ribbonNormLocale(locale) });
  // 임베드(위젯)에서는 업체를 토큰이 정한다 — 첫 요청이 기본값 'thesafe' 로 나가면 스카이 파드에서
  // 403 이 되어 시작 칩이 비어 보인다(2026-08-25 시연 안정화).
  if (!(window.RibbonWidget && window.RibbonWidget.embed) && window.RibbonData.currentTenantId) {
    q.set('tenant_id', window.RibbonData.currentTenantId);
  }
  const pr = f('/api/starter-chips?' + q.toString())
    .then((r) => (r.ok ? r.json() : null))
    .then((j) => {
      if (j && Array.isArray(j.chips) && j.chips.length) window.RibbonData.serverChips[key] = j;
      return window.RibbonData.serverChips[key] || null;
    })
    .catch(() => null)
    .finally(() => { delete ribbonChipInflight[key]; });
  ribbonChipInflight[key] = pr;
  return pr;
}
// 훅: 시작 화면이 쓴다 — 로컬 폴백을 즉시 돌려주고, 서버 칩이 도착하면 재렌더. from_signals = 앞에서 몇 개가 "오늘 신호" 칩인지.
function ribbonUseChips(locale) {
  const [tick, setTick] = React.useState(0);
  const key = ribbonChipKey(locale);
  React.useEffect(() => {
    let alive = true;
    ribbonFetchChips(locale).then((r) => { if (alive && r) setTick((x) => x + 1); });
    return () => { alive = false; };
  }, [key]);
  const hit = window.RibbonData.serverChips[key];
  void tick;
  return { chips: ribbonChips(locale), fromSignals: hit && Number(hit.from_signals) > 0 ? Number(hit.from_signals) : 0, basis: hit ? hit.basis : 'local' };
}

function ribbonNormLocale(x) {
  return RIBBON_LOCALES.some((l) => l.id === x) ? x : RIBBON_DEFAULT_LOCALE;
}
function ribbonInitialLocale() {
  try {
    const saved = localStorage.getItem('ribbon.locale');
    if (saved) return ribbonNormLocale(saved);
  } catch (e) {}
  // 브라우저 언어 추정(첫 방문): ja/en/zh 면 그 언어, 그 외는 기본(ko).
  const nav = (typeof navigator !== 'undefined' && (navigator.language || '')).toLowerCase();
  if (nav.startsWith('ja')) return 'ja';
  if (nav.startsWith('zh')) return 'zh';
  if (nav.startsWith('en')) return 'en';
  return RIBBON_DEFAULT_LOCALE;
}

// t(locale, key, ...args): 문자열 또는 함수(보간) 모두 지원. 미정의 키는 ko 폴백 후 키 자체.
function ribbonT(locale, key, ...args) {
  const loc = ribbonNormLocale(locale);
  const val = (RIBBON_STRINGS[loc] && RIBBON_STRINGS[loc][key]) ?? (RIBBON_STRINGS.ko && RIBBON_STRINGS.ko[key]) ?? key;
  return typeof val === 'function' ? val(...args) : val;
}

// 로케일 컨텍스트 — App 이 Provider 로 감싸고, 하위 컴포넌트는 useLocale()/useT() 로 소비.
const RibbonLocaleCtx = React.createContext(RIBBON_DEFAULT_LOCALE);
function ribbonUseLocale() { return React.useContext(RibbonLocaleCtx); }
function ribbonUseT() {
  const loc = React.useContext(RibbonLocaleCtx);
  return React.useCallback((key, ...args) => ribbonT(loc, key, ...args), [loc]);
}

// 전역 미러 — 최초 로드 시점에 세팅(훅 밖의 함수(askBackendStream 등)가 참조).
window.RibbonData.currentLocale = ribbonInitialLocale();

window.RibbonI18n = {
  LOCALES: RIBBON_LOCALES,
  DEFAULT: RIBBON_DEFAULT_LOCALE,
  normLocale: ribbonNormLocale,
  initialLocale: ribbonInitialLocale,
  t: ribbonT,
  chips: ribbonChips,
  useChips: ribbonUseChips,
  fetchChips: ribbonFetchChips,
  LocaleCtx: RibbonLocaleCtx,
  useLocale: ribbonUseLocale,
  useT: ribbonUseT,
};
