<!--
  /chat — 이드 채팅 표면 (PR-Eid-Chat).

  표면 문법: 페이지 정체성 = "이드". 모델명·머신명·alias 비노출
  (persona model-agnostic 원칙 — 프로토콜 레이어도 동일: SSE payload 의
  model 필드는 서버에서 mode 값으로 치환되고 usage 는 제거됨).
  클라이언트는 mode('daily'|'deep') 만 보내고 alias 매핑은 서버(/api/eid/chat) 책임.

  - 모드: 일상(daily) / 심층(deep) segmented 토글. 심층 = 장문·무거운 질문,
    잠들어 있으면 자동 기동(처음 최대 ~1분) — 기계중립 표현만 사용.
  - 스트리밍: POST /api/eid/chat → SSE. api<T>() 는 JSON 전용이라 raw fetch
    (apiFetchRaw, 토큰 첨부 + 401 refresh 1회 공유) 사용. 라인 버퍼로 청크
    경계 분리, "data:" 라인만, [DONE] 종료, choices[0].delta.content 누적
    (fixture 2종 — 26B tool_calls 배열 / 27B reasoning·logprobs null — 모두
    content 만 읽으면 동일 처리).
  - 에러: error_reason 매핑 (warming / editor_busy / upstream_cold /
    macbook_unavailable / substrate_degraded / 기타 detail). 자동 fallback
    금지 — 다른 모드로 자동 전환하지 않는다. 스트림 도중 중단 = 받은 부분
    유지 + 표시.
  - 대기 표시(첫 바이트 전): 경과 타이머 1초 갱신 + 3초 후 GET /api/eid/status
    1회·이후 8초 간격 재조회(실패는 조용히 무시 — 기능 비차단)로 "대기"와
    "고장"을 정직하게 구분. daily.busy=true 면 줄 서는 중 안내. 15초 경과 +
    daily 모드면 [심층으로 전환]/[취소] 버튼 노출 — 전환은 명시 클릭만
    (자동 fallback 금지 정책 위반 아님). 첫 바이트 도착/스트림 종료 시
    타이머·폴링 즉시 정리.
  - 이력: localStorage `eid_chat:v1` (키 상수는 $lib/eidChat — logout 시 제거와 공유).
    전송 payload 는 마지막 20턴(40 messages) cap.
  - 입력 한도: 메시지당 8,000자 클라 선차단(서버 422 검증과 동일 한도).
    422 수신 시 detail 을 한 줄로 정규화 + 방금 push 한 user 턴 pop 으로
    payload 오염 고리 차단.
-->
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { api, apiFetchRaw } from '$lib/api';
  import { EID_CHAT_STORAGE_KEY } from '$lib/eidChat';
  import Button from '$lib/components/ui/Button.svelte';
  import EmptyState from '$lib/components/ui/EmptyState.svelte';
  import EidEvidenceCard from '$lib/components/eid/EidEvidenceCard.svelte';
  import { MessageCircle, SendHorizontal, RotateCcw, AlertCircle } from 'lucide-svelte';

  type ChatMode = 'daily' | 'deep';
  // deep(검색) 답변은 sources(근거)·partial 동반. daily 답변은 없음.
  type EidSource = { id?: number; doc_id?: number; title?: string; score?: number };
  type ChatMessage = {
    role: 'user' | 'assistant';
    content: string;
    sources?: EidSource[];
    partial?: boolean;
  };
  type Notice = { kind: 'warn' | 'error'; message: string; retryable: boolean };
  // GET /api/eid/status 응답 — 대기 중 바쁨 신호 조회에 필요한 필드만 좁게 정의
  type EidStatus = { daily?: { busy?: boolean } };

  // 이력 키 — logout(stores/auth.ts) 의 이력 제거와 단일 상수 공유
  const STORAGE_KEY = EID_CHAT_STORAGE_KEY;
  // 전송 payload cap: 마지막 20턴(40 messages)
  const MAX_PAYLOAD_MESSAGES = 40;
  // localStorage 보존 cap (payload cap 과 별개 — 화면 표시용 이력)
  const MAX_STORED_MESSAGES = 200;
  // 메시지당 입력 한도 — 서버(eid_chat.py) 422 검증과 동일 한도, 클라에서 선차단
  const MAX_MESSAGE_CHARS = 8000;
  // 한도 근접 카운터 노출 시작점
  const COUNTER_THRESHOLD = 7500;
  // 대기 표시(첫 바이트 전): 상태 폴링 시작 시점(초) / 재조회 간격(초) / 행동 버튼 노출 시점(초)
  const STATUS_POLL_START_SEC = 3;
  const STATUS_POLL_INTERVAL_SEC = 8;
  const WAIT_ACTIONS_SEC = 15;

  const DEEP_CAPTION =
    '장문·무거운 질문에 적합 — 잠들어 있으면 자동 기동 (처음 응답까지 최대 ~1분)';

  // 프리셋 칩: 입력창 prefix 채움
  const PRESETS: Array<{ label: string; prefix: string }> = [
    { label: '번역 한→영', prefix: '다음을 영어로 번역해줘.\n\n' },
    { label: '번역 영→한', prefix: '다음을 한국어로 번역해줘.\n\n' },
    { label: '요약', prefix: '다음 내용을 핵심만 간결히 요약해줘.\n\n' },
    { label: '글 다듬기', prefix: '다음 글을 뜻은 유지하면서 자연스럽게 다듬어줘.\n\n' },
  ];

  // ── state ───────────────────────────────────────────
  let mode = $state<ChatMode>('daily');
  let messages = $state<ChatMessage[]>([]);
  let input = $state('');
  let streaming = $state(false);
  let streamingText = $state('');
  let notice = $state<Notice | null>(null);
  // deep(검색) 모드 첫 바이트 전 단계 — 'searching' 이면 대기 표시를 "근거 검색 중"으로
  let deepPhase = $state<'searching' | null>(null);

  let scrollEl: HTMLDivElement | undefined = $state();
  let textareaEl: HTMLTextAreaElement | undefined = $state();
  let abortCtrl: AbortController | null = null;

  // ── 대기 추적 (첫 바이트 전) ────────────────────────
  // 경과 초 + daily 엔진 바쁨 여부(null = 미확인). 토큰(세대 카운터)으로
  // 스트림별 소유를 구분 — abort 직후 즉시 재전송(심층 전환) 경로에서
  // 이전 스트림의 늦은 정리가 새 스트림의 타이머를 죽이지 않게 한다.
  let waitSeconds = $state(0);
  let dailyBusy = $state<boolean | null>(null);
  let waitIntervalId: ReturnType<typeof setInterval> | null = null;
  let waitTokenSeq = 0;
  let waitToken = 0; // 현재 활성 추적 토큰 (0 = 추적 없음)

  function startWaitTracking(streamMode: ChatMode): number {
    // 이전 추적 잔여 정리 (전환 재전송처럼 stop 전에 start 가 오는 경로 방어)
    if (waitIntervalId !== null) {
      clearInterval(waitIntervalId);
      waitIntervalId = null;
    }
    const token = ++waitTokenSeq;
    waitToken = token;
    waitSeconds = 0;
    dailyBusy = null;
    waitIntervalId = setInterval(() => {
      if (waitToken !== token) return; // 정리 누락 방어 — 무해 no-op
      waitSeconds += 1;
      // 바쁨 신호 폴링: 3초 경과 시 1회 + 이후 8초 간격 (3, 11, 19, ...).
      // daily 모드 전용 — deep 대기는 기존 wake 안내 + 경과 타이머만.
      if (
        streamMode === 'daily' &&
        waitSeconds >= STATUS_POLL_START_SEC &&
        (waitSeconds - STATUS_POLL_START_SEC) % STATUS_POLL_INTERVAL_SEC === 0
      ) {
        void pollEidStatus(token);
      }
    }, 1000);
    return token;
  }

  // token 가드: 본인 소유 추적만 정리 — 다른 스트림이 이어받았으면 no-op
  function stopWaitTracking(token: number) {
    if (token !== waitToken) return;
    waitToken = 0;
    if (waitIntervalId !== null) {
      clearInterval(waitIntervalId);
      waitIntervalId = null;
    }
    waitSeconds = 0;
    dailyBusy = null;
  }

  // 상태 조회 — 실패는 조용히 무시 (대기 표시는 타이머만으로 유지, 기능 비차단)
  async function pollEidStatus(token: number) {
    try {
      const status = await api<EidStatus>('/eid/status');
      if (token !== waitToken) return; // 스트림 종료/교체 후 도착한 늦은 응답 폐기
      dailyBusy = status?.daily?.busy === true;
    } catch {
      // 무시 — 바쁨 신호는 부가 정보일 뿐 채팅 기능을 차단하지 않는다
    }
  }

  // ── localStorage 이력 ───────────────────────────────
  function persist() {
    if (typeof window === 'undefined') return;
    try {
      const trimmed = messages.slice(-MAX_STORED_MESSAGES);
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode, messages: trimmed }));
    } catch {
      // quota 초과 등 — 이력 저장 실패는 치명적이지 않음
    }
  }

  function restore() {
    if (typeof window === 'undefined') return;
    try {
      const raw = window.localStorage.getItem(STORAGE_KEY);
      if (!raw) return;
      const parsed = JSON.parse(raw) as { mode?: unknown; messages?: unknown };
      if (parsed.mode === 'daily' || parsed.mode === 'deep') mode = parsed.mode;
      if (Array.isArray(parsed.messages)) {
        messages = parsed.messages
          .filter(
            (m): m is ChatMessage =>
              !!m &&
              typeof m === 'object' &&
              ((m as ChatMessage).role === 'user' || (m as ChatMessage).role === 'assistant') &&
              typeof (m as ChatMessage).content === 'string'
          )
          // 배열 크기 가드 + content 8,000자 clamp — 외부에서 손상/비대해진
          // 이력이 전송 payload 를 오염시키지 않도록 복원 시점에 정규화.
          // sources/partial(deep 답변 근거)은 보존 — 전송 payload 엔 안 실림(runStream map 이 role/content 만).
          .slice(-MAX_STORED_MESSAGES)
          .map((m) => ({
            role: m.role,
            content: m.content.slice(0, MAX_MESSAGE_CHARS),
            sources: Array.isArray((m as ChatMessage).sources) ? (m as ChatMessage).sources : undefined,
            partial: (m as ChatMessage).partial === true || undefined,
          }));
      }
    } catch {
      // 손상된 이력은 무시 (새 대화로 시작)
    }
  }

  onMount(() => restore());
  onDestroy(() => {
    abortCtrl?.abort();
    // 페이지 이탈 시 대기 타이머/폴링 정리 (abort 의 finally 와 이중이어도 무해)
    if (waitIntervalId !== null) clearInterval(waitIntervalId);
  });

  // ── 자동 스크롤 (새 메시지 / 스트림 청크마다 하단 고정) ──
  $effect(() => {
    void messages.length;
    void streamingText;
    if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
  });

  // ── 입력 textarea auto-grow ─────────────────────────
  $effect(() => {
    void input;
    if (!textareaEl) return;
    textareaEl.style.height = 'auto';
    textareaEl.style.height = Math.min(textareaEl.scrollHeight, 160) + 'px';
  });

  function applyPreset(prefix: string) {
    if (!input.startsWith(prefix)) input = prefix + input;
    textareaEl?.focus();
  }

  function newConversation() {
    abortCtrl?.abort();
    messages = [];
    notice = null;
    streamingText = '';
    streaming = false;
    persist();
    textareaEl?.focus();
  }

  // ── error_reason → 안내 메시지 매핑 ──────────────────
  // 자동 fallback 금지 ([[feedback_no_silent_fallback_explicit_opt_in]]):
  // 어떤 사유든 다른 모드로 자동 전환하지 않고 명시 표시만 한다.
  function mapErrorReason(reason: string | undefined, detail: string): Notice {
    switch (reason) {
      case 'warming':
        return {
          kind: 'warn',
          message: '심층 엔진 기동 중입니다 — 잠시 후 다시 시도하세요.',
          retryable: true,
        };
      case 'editor_busy':
        return {
          kind: 'warn',
          message: '편집 작업 보호로 잠시 사용할 수 없습니다.',
          retryable: false,
        };
      case 'upstream_cold':
      case 'macbook_unavailable':
        return {
          kind: 'warn',
          message: '심층 엔진이 잠들어 있습니다 — 다시 시도하면 기동을 시작합니다.',
          retryable: true,
        };
      case 'substrate_degraded':
        return {
          kind: 'error',
          message: '운영 규칙이 적재되지 않았습니다 — 관리자 확인이 필요합니다.',
          retryable: false,
        };
      default:
        return { kind: 'error', message: detail || '응답 생성에 실패했습니다.', retryable: true };
    }
  }

  // 비-200 응답 body 파싱: {detail, error_reason} — detail 은 string 또는
  // {message} 객체 가능 (api.ts parseDetail 과 같은 정규화 규칙의 축소판).
  async function parseErrorBody(res: Response): Promise<Notice> {
    const body = (await res.json().catch(() => null)) as
      | { detail?: unknown; error_reason?: unknown }
      | null;
    const reason = typeof body?.error_reason === 'string' ? body.error_reason : undefined;
    let detail = '';
    if (typeof body?.detail === 'string') detail = body.detail;
    else if (body?.detail && typeof body.detail === 'object') {
      const obj = body.detail as { message?: string; error_reason?: string };
      detail = obj.message || '';
      // error_reason 이 detail 객체 안에 중첩된 경우도 수용
      return mapErrorReason(reason ?? obj.error_reason, detail || res.statusText);
    }
    return mapErrorReason(reason, detail || res.statusText);
  }

  // 422: FastAPI validation detail(배열 shape — [{loc, msg, type}, ...]) 을
  // 사람이 읽을 한 줄로 정규화. 길이 한도 위반(메시지당 8,000자 / 총량 cap)
  // 은 친화 메시지로 치환. pydantic v2 의 "Value error, " prefix 는 제거.
  function normalizeValidationDetail(detail: unknown): string {
    const first = (Array.isArray(detail) ? detail[0] : undefined) as
      | { msg?: unknown }
      | undefined;
    const msg =
      typeof first?.msg === 'string' ? first.msg.replace(/^Value error,\s*/i, '') : '';
    if (/at most|too.?long|초과|깁니다/i.test(msg)) {
      return '입력이 너무 깁니다 — 메시지는 8,000자 이내로 줄이거나, 대화가 길면 새 대화로 시작하세요.';
    }
    if (msg) return `요청 형식 오류: ${msg}`;
    return '요청 형식이 올바르지 않습니다 — 입력을 줄이거나 새 대화로 시작하세요.';
  }

  // ── 전송 / 재시도 ───────────────────────────────────
  function sendMessage() {
    const text = input.trim();
    if (!text || streaming) return;
    // 메시지당 8,000자 클라 선차단 — 한도 초과 payload 를 422 전에 막는다
    // (입력바 하단 카운터가 같은 안내를 인라인으로 상시 표시)
    if (text.length > MAX_MESSAGE_CHARS) {
      notice = {
        kind: 'error',
        message: '입력이 너무 깁니다 — 8,000자 이내로 줄여주세요.',
        retryable: false,
      };
      return;
    }
    messages.push({ role: 'user', content: text });
    input = '';
    persist();
    void runStream();
  }

  // 재시도: 이력 끝의 user 메시지를 그대로 재전송 (user 턴 중복 추가 X)
  function retry() {
    if (streaming) return;
    if (messages.length === 0 || messages[messages.length - 1].role !== 'user') return;
    void runStream();
  }

  // ── 대기 중 행동 버튼 (daily + 15초 경과) ────────────
  // [심층으로 전환] — 명시 클릭에 의한 모드 전환 (자동 fallback 금지 정책
  // 위반 아님). 현재 fetch abort → 같은 user 턴을 mode=deep 으로 즉시 재전송.
  // abort 된 이전 스트림의 finally 는 abortCtrl 비교 + 대기 token 가드로
  // 새 스트림 상태를 건드리지 않는다 (새 대화 abort race 가드와 동일 구조).
  function switchToDeep() {
    if (!streaming || mode !== 'daily') return;
    mode = 'deep'; // 모드 토글 상태도 deep 으로 갱신
    abortCtrl?.abort();
    void runStream();
  }

  // [취소] — abort 후 방금 push 한 user 턴 pop + 입력창 본문 복원
  // (422 처리와 동일 패턴: 이력 오염 차단 + localStorage 재저장).
  // placeholder 제거는 abort 된 스트림의 finally(streaming=false)가 처리.
  function cancelWait() {
    if (!streaming) return;
    abortCtrl?.abort();
    if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
      const popped = messages.pop();
      if (popped && !input) input = popped.content;
      persist();
    }
  }

  async function runStream() {
    notice = null;
    streaming = true;
    streamingText = '';
    const ctrl = new AbortController();
    abortCtrl = ctrl;
    // 첫 바이트 전 대기 추적 시작 — 본 스트림 소유 토큰으로 정리 시점 제어
    const waitTok = startWaitTracking(mode);

    const payload = {
      mode,
      messages: messages
        .slice(-MAX_PAYLOAD_MESSAGES)
        .map((m) => ({ role: m.role, content: m.content })),
    };

    let acc = '';
    let sawDone = false;
    // deep(검색) 답변 동반 데이터 — daily 는 안 옴
    let accSources: EidSource[] = [];
    let accPartial = false;

    try {
      const res = await apiFetchRaw('/eid/chat', {
        method: 'POST',
        body: JSON.stringify(payload),
        signal: ctrl.signal,
      });

      if (!res.ok) {
        if (res.status === 422) {
          // validation 거부 — detail 정규화 + 방금 push 한 user 턴 pop.
          // 한도 초과 턴이 이력에 남으면 이후 모든 전송 payload 가 계속
          // 422 를 맞는 오염 고리가 되므로 여기서 끊는다 (localStorage 재저장).
          const body = (await res.json().catch(() => null)) as { detail?: unknown } | null;
          notice = {
            kind: 'error',
            message: normalizeValidationDetail(body?.detail),
            retryable: false,
          };
          if (messages.length > 0 && messages[messages.length - 1].role === 'user') {
            const popped = messages.pop();
            // 입력창이 비어 있으면 본문을 돌려놓아 줄여서 재전송할 수 있게 한다
            if (popped && !input) input = popped.content;
            persist();
          }
          return;
        }
        notice = await parseErrorBody(res);
        return;
      }
      if (!res.body) {
        notice = { kind: 'error', message: '스트림을 열 수 없습니다.', retryable: true };
        return;
      }

      // SSE 라인 버퍼 파싱 — 청크 경계에서 라인이 잘릴 수 있으므로
      // 마지막 불완전 라인은 buf 에 남겨 다음 청크와 이어붙인다.
      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buf = '';

      // data: 라인 1개 처리 — [DONE] 이면 true (acc/streamingText 누적은 closure)
      const processLine = (rawLine: string): boolean => {
        const line = rawLine.trim();
        if (!line.startsWith('data:')) return false;
        const data = line.slice(5).trim();
        if (data === '[DONE]') return true;
        try {
          const obj = JSON.parse(data) as {
            choices?: Array<{ delta?: { content?: unknown } }>;
            phase?: string;
            error_reason?: string;
            eid_sources?: EidSource[];
            partial?: boolean;
          };
          // deep(검색) envelope 분기 — daily 응답엔 없음
          if (obj?.phase === 'ping') return false; // heartbeat — 무시
          if (obj?.phase === 'searching') {
            deepPhase = 'searching'; // 대기 표시를 "근거 검색 중"으로
            return false;
          }
          if (obj?.phase === 'error') {
            // in-stream 미가용/실패 — 받은 부분 유지 + 명시 표시 (자동 fallback 0).
            // 뒤따르는 [DONE] 이 sawDone 처리하므로 '중단' 오경보 없음.
            notice = mapErrorReason(obj.error_reason, '');
            return false;
          }
          if (Array.isArray(obj?.eid_sources)) {
            accSources = obj.eid_sources;
            accPartial = obj.partial === true;
            return false;
          }
          const piece = obj?.choices?.[0]?.delta?.content;
          if (typeof piece === 'string' && piece) {
            // 첫 바이트 도착 — 대기 타이머/폴링 제거, 기존 스트리밍 표시로 전환
            if (!acc) {
              stopWaitTracking(waitTok);
              deepPhase = null;
            }
            acc += piece;
            streamingText = acc;
          }
        } catch {
          // 불완전/비 JSON data 라인 무시
        }
        return false;
      };

      while (true) {
        const { value, done } = await reader.read();
        if (done) {
          // 종단 flush — decoder 내부 잔여 바이트 + 개행 없이 끝난 마지막
          // 라인을 1회 처리. 마지막 data:/[DONE] 라인이 \n 없이 끝나면 buf 에
          // 남아 '응답이 중단되었습니다' 오경보가 나던 경로의 해소 지점.
          buf += decoder.decode();
          for (const rawLine of buf.split('\n')) {
            if (processLine(rawLine)) {
              sawDone = true;
              break;
            }
          }
          break;
        }
        buf += decoder.decode(value, { stream: true });
        const lines = buf.split('\n');
        buf = lines.pop() ?? '';
        for (const rawLine of lines) {
          if (processLine(rawLine)) {
            sawDone = true;
            break;
          }
        }
        if (sawDone) {
          // [DONE] 수신 — 잔여 스트림 lock 해제 (실패해도 종료에 영향 없음)
          void reader.cancel().catch(() => {});
          break;
        }
      }

      // [DONE] 없이 연결이 끊긴 경우 — 받은 부분 유지 + 표시
      if (!sawDone) {
        notice = acc
          ? {
              kind: 'warn',
              message: '응답이 중단되었습니다 — 받은 부분까지 표시합니다.',
              retryable: false,
            }
          : { kind: 'error', message: '응답을 받지 못했습니다 — 다시 시도하세요.', retryable: true };
      }
    } catch (err) {
      if ((err as Error)?.name === 'AbortError') {
        // 새 대화 / 대기 취소 / 심층 전환 등 사용자 의도 중단 — 안내 불필요
        return;
      }
      // 스트림 도중 네트워크 에러 — 받은 부분 유지 + 표시
      notice = acc
        ? {
            kind: 'warn',
            message: '연결이 끊겼습니다 — 받은 부분까지 표시합니다.',
            retryable: false,
          }
        : { kind: 'error', message: '요청에 실패했습니다 — 네트워크를 확인하세요.', retryable: true };
    } finally {
      // 스트림 종료 — 대기 타이머/폴링 정리. 첫 바이트에서 이미 정리됐거나
      // 전환 재전송으로 새 스트림이 추적을 이어받았으면 token 가드로 no-op.
      stopWaitTracking(waitTok);
      // abort(새 대화/페이지 이탈) 시에는 push 하지 않음 — 새 대화로 비운
      // messages 에 이전 스트림 잔여분이 흘러들어가는 race 방지.
      if (acc && !ctrl.signal.aborted) {
        messages.push({
          role: 'assistant',
          content: acc,
          sources: accSources.length ? accSources : undefined,
          partial: accPartial || undefined,
        });
      }
      if (abortCtrl === ctrl) {
        streaming = false;
        streamingText = '';
        deepPhase = null;
        abortCtrl = null;
      }
      persist();
    }
  }

  function handleKeydown(e: KeyboardEvent) {
    // Enter 전송 / Shift+Enter 줄바꿈 (한글 조합 중 전송 방지)
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
      e.preventDefault();
      sendMessage();
    }
  }

  // 마지막 메시지가 user 턴이고 스트리밍 중이 아니면 재시도 가능 상태
  let canRetry = $derived(
    !streaming && messages.length > 0 && messages[messages.length - 1].role === 'user'
  );

  // 입력 길이(전송 기준 = trim 후) — 7,500자부터 카운터 노출, 8,000자 초과 차단
  let inputLength = $derived(input.trim().length);
  let overLimit = $derived(inputLength > MAX_MESSAGE_CHARS);

  // 첫 바이트 전 placeholder 문구 — "대기"와 "고장"의 정직한 구분:
  // 바쁨 확인 = 줄 서는 중 / 비-바쁨 확인 = 생성 준비 중 / 미확인 = 응답 대기 중.
  // deep 모드는 폴링하지 않으므로 항상 미확인(타이머만) — wake 안내는 헤더 caption.
  let waitPlaceholder = $derived(
    deepPhase === 'searching'
      ? `이드가 문서·뉴스에서 근거를 찾는 중 · ${waitSeconds}초`
      : dailyBusy === true
        ? `엔진이 다른 작업을 처리하고 있어요 — 차례가 오면 바로 시작됩니다 (대기 ${waitSeconds}초)`
        : dailyBusy === false
          ? `응답 생성 준비 중 · ${waitSeconds}초`
          : `응답 대기 중 · ${waitSeconds}초`
  );

  // 행동 버튼 노출: daily 모드 + 첫 바이트 전 + 15초 경과
  let showWaitActions = $derived(
    streaming && !streamingText && mode === 'daily' && waitSeconds >= WAIT_ACTIONS_SEC
  );
</script>

<svelte:head>
  <title>이드 - PKM</title>
</svelte:head>

<div class="h-full flex flex-col">
  <!-- 헤더: 정체성 + 모드 토글 + 새 대화 -->
  <div class="shrink-0 border-b border-default bg-sidebar px-4 py-2.5">
    <div class="max-w-3xl mx-auto flex items-center gap-2 flex-wrap">
      <h1 class="flex items-center gap-2 text-sm font-extrabold tracking-tight shrink-0">
        <MessageCircle size={16} class="text-accent" />
        이드
      </h1>

      <!-- 모드 segmented 토글: 일상 / 심층 -->
      <div class="flex rounded-md border border-default overflow-hidden" role="group" aria-label="응답 모드">
        <button
          type="button"
          aria-pressed={mode === 'daily'}
          onclick={() => (mode = 'daily')}
          disabled={streaming}
          title="짧은 질문·일상 대화에 적합"
          class="px-3 py-1.5 text-xs font-semibold transition-colors disabled:opacity-50
            {mode === 'daily' ? 'bg-accent text-white' : 'bg-surface text-dim hover:text-text hover:bg-surface-hover'}"
        >
          일상
        </button>
        <button
          type="button"
          aria-pressed={mode === 'deep'}
          onclick={() => (mode = 'deep')}
          disabled={streaming}
          title={DEEP_CAPTION}
          class="px-3 py-1.5 text-xs font-semibold border-l border-default transition-colors disabled:opacity-50
            {mode === 'deep' ? 'bg-accent text-white' : 'bg-surface text-dim hover:text-text hover:bg-surface-hover'}"
        >
          심층
        </button>
      </div>

      <div class="flex-1"></div>

      <Button variant="ghost" size="sm" icon={RotateCcw} onclick={newConversation}>
        새 대화
      </Button>
    </div>
    {#if mode === 'deep'}
      <div class="max-w-3xl mx-auto mt-1.5">
        <p class="text-[11px] text-dim">{DEEP_CAPTION}</p>
      </div>
    {/if}
  </div>

  <!-- 메시지 리스트 -->
  <div bind:this={scrollEl} class="flex-1 min-h-0 overflow-y-auto px-4 py-4">
    <div class="max-w-3xl mx-auto flex flex-col gap-3" role="log" aria-live="polite">
      {#if messages.length === 0 && !streaming}
        <div class="py-10">
          <EmptyState
            icon={MessageCircle}
            title="이드와 대화를 시작하세요"
            description="일상 질문은 바로, 장문·무거운 질문은 심층 모드로 물어보세요. 아래 프리셋 칩으로 번역·요약·글 다듬기를 빠르게 시작할 수 있습니다."
          />
        </div>
      {/if}

      {#each messages as msg, i (i)}
        {#if msg.role === 'user'}
          <div class="flex justify-end">
            <div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-br-sm bg-accent text-white text-sm whitespace-pre-wrap break-words">
              {msg.content}
            </div>
          </div>
        {:else}
          <div class="flex flex-col items-start">
            <div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-bl-sm bg-surface border border-default text-text text-sm whitespace-pre-wrap break-words">
              {msg.content}
            </div>
            {#if msg.sources?.length}
              <EidEvidenceCard sources={msg.sources} partial={msg.partial ?? false} />
            {/if}
          </div>
        {/if}
      {/each}

      <!-- 스트리밍 중 assistant 부분 응답 / 첫 바이트 전 대기 표시 -->
      {#if streaming}
        <div class="flex justify-start">
          <div class="max-w-[85%] sm:max-w-[75%] px-3.5 py-2.5 rounded-lg rounded-bl-sm bg-surface border border-default text-text text-sm whitespace-pre-wrap break-words">
            {#if streamingText}
              {streamingText}<span class="inline-block w-1.5 h-3.5 ml-0.5 align-middle bg-accent animate-pulse rounded-sm"></span>
            {:else}
              <span class="text-dim animate-pulse">{waitPlaceholder}</span>
            {/if}
          </div>
        </div>
        <!-- 대기 행동 버튼: daily + 15초 경과 — 전환은 명시 클릭만 (자동 fallback 금지) -->
        {#if showWaitActions}
          <div class="flex justify-start gap-2">
            <Button variant="secondary" size="sm" onclick={switchToDeep}>심층으로 전환</Button>
            <Button variant="ghost" size="sm" onclick={cancelWait}>취소</Button>
          </div>
        {/if}
      {/if}

      <!-- 에러/안내 카드: 자동 fallback 없이 명시 표시만 -->
      {#if notice}
        <div
          class="flex items-start gap-2 px-3.5 py-3 rounded-lg border text-sm
            {notice.kind === 'warn'
              ? 'border-warning/30 bg-warning/10 text-warning'
              : 'border-error/30 bg-error/10 text-error'}"
        >
          <AlertCircle size={15} class="mt-0.5 shrink-0" />
          <div class="flex-1 min-w-0">
            <p>{notice.message}</p>
            {#if notice.retryable && canRetry}
              <Button variant="secondary" size="sm" class="mt-2" onclick={retry}>
                다시 시도
              </Button>
            {/if}
          </div>
        </div>
      {/if}
    </div>
  </div>

  <!-- 입력 바 (하단 고정 — 모바일에서도 flex 컬럼 하단에 붙음) -->
  <div class="shrink-0 border-t border-default bg-sidebar px-4 pt-2 pb-3">
    <div class="max-w-3xl mx-auto">
      <!-- 프리셋 칩 -->
      <div class="flex gap-1.5 overflow-x-auto pb-2">
        {#each PRESETS as preset (preset.label)}
          <button
            type="button"
            onclick={() => applyPreset(preset.prefix)}
            class="shrink-0 px-2.5 py-1 rounded-full border border-default bg-surface text-xs text-dim hover:text-text hover:border-accent transition-colors"
          >
            {preset.label}
          </button>
        {/each}
      </div>

      <div class="flex items-end gap-2">
        <textarea
          bind:this={textareaEl}
          bind:value={input}
          onkeydown={handleKeydown}
          rows="1"
          placeholder="이드에게 메시지 보내기 (Enter 전송, Shift+Enter 줄바꿈)"
          class="flex-1 min-w-0 px-3 py-2 rounded-lg text-sm bg-bg text-text placeholder:text-faint border border-default focus:border-accent focus:ring-2 focus:ring-accent-ring outline-none resize-none overflow-y-auto transition-colors"
        ></textarea>
        <Button
          variant="primary"
          size="md"
          icon={SendHorizontal}
          loading={streaming}
          disabled={!input.trim() || overLimit}
          onclick={sendMessage}
          aria-label="전송"
        >
          <span class="hidden sm:inline">전송</span>
        </Button>
      </div>

      <!-- 글자수 카운터: 한도(8,000자) 근접 시에만 노출, 초과 시 인라인 안내 -->
      {#if inputLength >= COUNTER_THRESHOLD}
        <p class="mt-1 text-right text-[11px] {overLimit ? 'text-error' : 'text-dim'}" aria-live="polite">
          {inputLength.toLocaleString()} / {MAX_MESSAGE_CHARS.toLocaleString()}자{overLimit
            ? ' — 입력이 너무 깁니다 (8,000자 이내)'
            : ''}
        </p>
      {/if}
    </div>
  </div>
</div>
