<script lang="ts">
  // 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
  //   · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
  //   · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
  //   · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
  // 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
  import { api } from '$lib/api';
  import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
  import { addToast } from '$lib/stores/toast';
  import {
    AUX_NODES,
    FLOW_NODES,
    MACHINE_META,
    type FlowNodeDef,
    type FlowMachine,
    etaShort,
    flowStageLabel,
    formatAgeSec,
    formatRate,
  } from '$lib/utils/queueDisplay';
  import type {
    FailedItem,
    FailedListResponse,
    MachineCurrentItem,
    MachineOverview,
    QueueOverview,
    QueueStageRow,
    RetryResponse,
    SkipResponse,
  } from '$lib/types/queue';

  let { overview }: { overview: QueueOverview } = $props();

  // ─── 노드 통계 합성 ───
  interface NodeStats {
    def: FlowNodeDef;
    /** 다중 stage 노드(청크·임베딩)는 같은 문서가 양쪽 큐에 있어 max — 합산 = 이중계산 */
    pending: number;
    processing: number;
    failed: number; // 실패는 행 단위 사실이라 합산
    done1h: number;
    created1h: number;
    doneToday: number;
    oldestAgeSec: number | null;
    etaMinutes: number | null;
    inflowDominant: boolean;
    perStage: QueueStageRow[];
  }

  const stageBy = $derived(new Map(overview.stages.map((s) => [s.stage, s])));

  function nodeStats(def: FlowNodeDef): NodeStats {
    const rows = def.stages
      .map((s) => stageBy.get(s))
      .filter((r): r is QueueStageRow => r != null);
    const pending = rows.reduce((m, r) => Math.max(m, r.pending), 0);
    const done1h = rows.reduce((m, r) => Math.max(m, r.done_1h), 0);
    const created1h = rows.reduce((m, r) => Math.max(m, r.created_1h), 0);
    const oldest = rows.reduce<number | null>(
      (m, r) => (r.oldest_pending_age_sec == null ? m : Math.max(m ?? 0, r.oldest_pending_age_sec)),
      null,
    );
    return {
      def,
      pending,
      processing: rows.reduce((s, r) => s + r.processing, 0),
      failed: rows.reduce((s, r) => s + r.failed, 0),
      done1h,
      created1h,
      doneToday: rows.reduce((m, r) => Math.max(m, r.done_today), 0),
      oldestAgeSec: oldest,
      etaMinutes: pending > 0 && done1h > 0 ? Math.round((pending / done1h) * 60) : null,
      inflowDominant: pending > 0 && created1h > done1h,
      perStage: rows,
    };
  }

  const mainNodes = $derived(FLOW_NODES.map(nodeStats));
  const auxAll = $derived(AUX_NODES.map(nodeStats));
  const auxActive = $derived(
    auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday > 0),
  );
  const auxIdle = $derived(
    auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday === 0),
  );
  const totalFailed = $derived(overview.totals.failed);

  // ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
  let selected = $state<string | null>(null);
  let failOpen = $state(false);

  function toggleNode(key: string) {
    selected = selected === key ? null : key;
    if (selected) failOpen = false;
  }

  const selectedNode = $derived(
    [...mainNodes, ...auxAll].find((n) => n.def.key === selected) ?? null,
  );

  function nodeCurrent(def: FlowNodeDef): MachineCurrentItem[] {
    return overview.machines.flatMap((m) => m.current.filter((c) => def.stages.includes(c.stage)));
  }

  // ─── 실패 드로어 ───
  let failItems = $state<FailedItem[]>([]);
  let failLoading = $state(false);
  let busy = $state(false);
  let expanded = $state<Record<string, boolean>>({});

  async function openFailures() {
    failOpen = true;
    selected = null;
    await loadFailures();
  }

  async function loadFailures() {
    failLoading = true;
    try {
      const r = await api<FailedListResponse>('/queue/failed');
      failItems = r.items;
    } catch {
      addToast('error', '실패 목록을 불러오지 못했습니다');
    } finally {
      failLoading = false;
    }
  }

  interface FailGroup {
    key: string;
    stage: string;
    pattern: string;
    items: FailedItem[];
  }

  // 그룹핑 = stage + 에러 메시지 prefix(36자) — 같은 원인(ReadTimeout 등) 묶음
  const failGroups = $derived.by(() => {
    const map = new Map<string, FailGroup>();
    for (const it of failItems) {
      const pattern = (it.error_message ?? '(메시지 없음)').slice(0, 36);
      const key = `${it.stage}::${pattern}`;
      const g = map.get(key);
      if (g) g.items.push(it);
      else map.set(key, { key, stage: it.stage, pattern, items: [it] });
    }
    return [...map.values()].sort(
      (a, b) => a.stage.localeCompare(b.stage) || b.items.length - a.items.length,
    );
  });

  async function retryIds(ids: number[]) {
    if (busy || ids.length === 0) return;
    busy = true;
    try {
      const r = await api<RetryResponse>('/queue/retry', {
        method: 'POST',
        body: JSON.stringify({ ids }),
      });
      addToast(
        'success',
        `재시도 ${r.retried}건 큐 재진입${r.not_retried > 0 ? ` (${r.not_retried}건 제외 — 이미 활성/처리됨)` : ''}`,
      );
      await afterAction();
    } catch {
      addToast('error', '재시도 요청 실패');
    } finally {
      busy = false;
    }
  }

  async function skipIds(ids: number[]) {
    if (busy || ids.length === 0) return;
    busy = true;
    try {
      const r = await api<SkipResponse>('/queue/skip', {
        method: 'POST',
        body: JSON.stringify({ ids }),
      });
      addToast('success', `건너뛰기 ${r.skipped}건 처리 (해당 단계 제외)`);
      await afterAction();
    } catch {
      addToast('error', '건너뛰기 요청 실패');
    } finally {
      busy = false;
    }
  }

  async function afterAction() {
    await Promise.all([loadFailures(), refreshQueueOverview()]);
  }

  // ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
  const machineByKey = $derived(
    new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
  );
  const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
  const lanes = $derived(
    LANE_ORDER.map((key) => ({
      key,
      meta: MACHINE_META[key],
      card: machineByKey.get(key) ?? null,
      nodes: mainNodes.filter((n) => n.def.machine === key),
    })),
  );

  // 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
  const split = $derived(overview.summarize_by_machine);
  const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
  const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
  // 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
  const offloadActive = $derived(split.macbook.done_1h > 0);

  // ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
  const bgJobs = $derived(overview.background_jobs ?? []);
  const runningBg = $derived(bgJobs.filter((j) => j.state === 'running'));
  function bgForMachine(key: string) {
    return runningBg.filter((j) => j.machine === key);
  }
  function fmtElapsed(s: number): string {
    if (s < 60) return `${s}s`;
    if (s < 3600) return `${Math.floor(s / 60)}m`;
    return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
  }
  function bgDot(j: { state: string; stale: boolean }): string {
    if (j.state === 'running') return j.stale ? 'bg-warning' : 'bg-success';
    if (j.state === 'failed') return 'bg-error';
    return 'bg-faint';
  }

  // ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
  const eta = $derived(overview.summarize_eta);
  // 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
  const honestEtaLabel = $derived(
    eta.pending === 0
      ? '비어 있음'
      : eta.eta_minutes != null
        ? etaShort(eta.eta_minutes)
        : '소진 불가',
  );
  const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);

  /** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
  function netEtaLabel(n: NodeStats): string | null {
    if (n.pending === 0) return '한가';
    const net = n.done1h - n.created1h;
    if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
    if (n.created1h > n.done1h) return '유입 우세';
    return null;
  }

  // ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
  let now = $state(Date.now());
  $effect(() => {
    const id = setInterval(() => (now = Date.now()), 1000);
    return () => clearInterval(id);
  });
  const ageSec = $derived(
    $queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
  );
  const stale = $derived(ageSec != null && ageSec > 90);
  const freshLabel = $derived(
    ageSec == null
      ? '갱신 대기'
      : ageSec < 60
        ? `갱신 ${ageSec}초 전`
        : `갱신 ${Math.round(ageSec / 60)}분 전`,
  );

  // ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
  const burn = $derived.by(() => {
    const t = overview.trend_24h;
    if (!t || t.length === 0) return null;
    const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
    const w = 300;
    const h = 64;
    const step = w / Math.max(1, t.length - 1);
    const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
    const line = (sel: (b: (typeof t)[number]) => number) =>
      t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
    const doneLine = line((b) => b.done);
    const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
    // 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
    let mi = 0;
    t.forEach((b, i) => {
      if (b.done > t[mi].done) mi = i;
    });
    return {
      w,
      h,
      area,
      doneLine,
      inflowLine: line((b) => b.inflow),
      markX: (mi * step).toFixed(1),
      markHour: t[mi].hour,
      markDone: t[mi].done,
      peak: max,
    };
  });

  // 머신 상태 dot 색 클래스
  function dotClass(state: string): string {
    return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
  }
</script>

<div class="mt-5">
  <!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
  <div class="flex items-center justify-between gap-3 mb-3">
    <div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
    <div class="flex items-center gap-3">
      {#if totalFailed > 0}
        <button
          class="text-[11px] font-semibold text-error hover:underline cursor-pointer"
          onclick={openFailures}
        >실패 {totalFailed}건 처리</button>
      {/if}
      <span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
        <span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
        {freshLabel}{#if stale} · 갱신 지연{/if}
      </span>
    </div>
  </div>

  <!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
  <div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
    <span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
    <span class="text-xs font-bold text-text">요약</span>
    <span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
    <span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
      <span class="text-[10px] font-semibold">정직 ETA</span>
      <span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
    </span>
  </div>

  <!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
  <div class="grid gap-2 mb-3">
    {#each lanes as lane (lane.key)}
      <div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
        <div class="flex items-center gap-2 flex-wrap mb-2">
          <span class="w-2 h-2 rounded-full shrink-0 {dotClass(bgForMachine(lane.key).length > 0 ? 'active' : (lane.card?.state ?? 'idle'))}"></span>
          <span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
          <span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
          <span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
          {#each bgForMachine(lane.key) as j (j.id)}<span class="text-[10px] font-semibold text-success tabular-nums ml-1">생성 중: {j.label ?? j.kind}{#if j.total} {j.processed}/{j.total}{/if}</span>{/each}
          {#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
            <span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
          {/if}
          {#if lane.card?.state === 'deferred'}
            <span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
          {/if}
        </div>
        <div class="flex items-stretch gap-1.5 flex-wrap">
          {#each lane.nodes as n (n.def.key)}
            {@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
            <button
              class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
                {idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
                {selected === n.def.key ? 'node-sel' : ''}"
              onclick={() => toggleNode(n.def.key)}
              title="{n.def.label} — 클릭하면 상세"
            >
              {#if n.failed > 0}
                <span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
              {/if}
              <div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
                {n.def.label}
                {#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
              </div>
              <div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
              <div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
              {#if n.def.key === 'summarize'}
                <div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
                  <span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
                  <span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
                </div>
                <div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
              {/if}
            </button>
          {/each}
          {#if lane.key === 'macbook' && offloadActive}
            <button
              class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
              onclick={() => toggleNode('summarize')}
              title="맥북이 요약을 맥미니에서 가져와 처리 중"
            >
              <div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
              <div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
              <div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
            </button>
          {/if}
        </div>
      </div>
    {/each}
  </div>

  <!-- 번다운 / ETA 패널 -->
  {#if burn}
    <div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
      <div class="flex items-center gap-2 mb-2">
        <span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
        <span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
        {#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
      </div>
      <svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
        <polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
        <polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
        <polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
        {#if offloadActive}
          <line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
        {/if}
      </svg>
      <div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
        {#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
          <span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
        {/each}
      </div>
    </div>
  {/if}

  <!-- 보조 라인 -->
  <p class="text-[10px] text-faint mt-1.5 tabular-nums">
    {#each auxActive as n, i (n.def.key)}
      {i > 0 ? ' · ' : '보조: '}{n.def.label}({n.def.engine}) 대기 {n.pending.toLocaleString()} · {formatRate(n.done1h)}/h{n.failed > 0 ? ` · 실패 ${n.failed}` : ''}
    {/each}
    {#if auxIdle.length > 0}
      {auxActive.length > 0 ? ' — ' : ''}한가: {auxIdle.map((n) => n.def.label).join(' · ')}
    {/if}
    — 뉴스 등 일부 소스는 분류/추출을 건너뜀 (흐름 그림은 대표 경로)
  </p>

  <!-- 상세 패널 (노드 클릭) -->
  {#if selectedNode}
    <div class="border rounded-card mt-3 overflow-hidden bg-surface detail-frame">
      <div class="flex items-center gap-2.5 px-4 py-2.5 text-xs font-bold detail-head">
        {selectedNode.def.label} — {selectedNode.def.engine}
        <span class="text-[10px] font-mono font-medium text-dim bg-surface border border-default rounded px-1.5">{selectedNode.def.sub} · {MACHINE_META[selectedNode.def.machine].label}</span>
        <button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (selected = null)}>닫기</button>
      </div>
      <div class="px-4 pb-3.5">
        <div class="grid grid-cols-2 md:grid-cols-4 gap-2.5 my-2.5">
          <div class="bg-bg border border-default rounded-card px-3 py-2">
            <div class="text-[9px] text-faint uppercase tracking-wide">대기</div>
            <div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.pending.toLocaleString()}</div>
          </div>
          <div class="bg-bg border border-default rounded-card px-3 py-2">
            <div class="text-[9px] text-faint uppercase tracking-wide">처리율 (1h)</div>
            <div class="text-lg font-extrabold tabular-nums text-text">{formatRate(selectedNode.done1h)}<span class="text-[11px] text-dim font-semibold">/h</span></div>
          </div>
          <div class="bg-bg border border-default rounded-card px-3 py-2">
            <div class="text-[9px] text-faint uppercase tracking-wide">오늘 완료</div>
            <div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.doneToday.toLocaleString()}</div>
          </div>
          <div class="bg-bg border border-default rounded-card px-3 py-2">
            <div class="text-[9px] text-faint uppercase tracking-wide">소진 예상</div>
            <div class="text-lg font-extrabold tabular-nums {selectedNode.inflowDominant ? 'text-warning' : 'text-accent'}">
              {#if selectedNode.inflowDominant}유입 우세{:else if selectedNode.etaMinutes != null}{etaShort(selectedNode.etaMinutes)}{:else if selectedNode.pending === 0}한가{:else}—{/if}
            </div>
          </div>
        </div>
        {#if selectedNode.perStage.length > 1}
          {#each selectedNode.perStage as row (row.stage)}
            <div class="flex items-center gap-2.5 py-1.5 border-t border-default text-xs">
              <span class="font-semibold text-text min-w-[72px]">{flowStageLabel(row.stage)}</span>
              <span class="ml-auto text-dim tabular-nums">
                대기 <strong class="text-text">{row.pending.toLocaleString()}</strong>
                · {formatRate(row.done_1h)}/h · 오늘 {row.done_today.toLocaleString()}
                {#if row.failed > 0}· <span class="text-error font-semibold">실패 {row.failed}</span>{/if}
              </span>
            </div>
          {/each}
        {/if}
        <div class="text-[11px] text-dim border-t border-dashed border-default mt-2 pt-2 tabular-nums">
          {#if selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600}
            가장 오래 기다린 항목 {formatAgeSec(selectedNode.oldestAgeSec)}
          {/if}
          {#each nodeCurrent(selectedNode.def) as c, i (c.document_id + c.stage)}
            {i === 0 && !(selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600) ? '' : ' · '}지금: {c.title} ({flowStageLabel(c.stage)})
          {/each}
          {#if selectedNode.failed > 0}
            · <button class="text-error font-semibold cursor-pointer hover:underline" onclick={openFailures}>실패 {selectedNode.failed}건 처리</button>
          {/if}
        </div>
      </div>
    </div>
  {/if}

  <!-- 백그라운드 작업 (큐 밖 스크립트 backfill 등 — processing_queue 가 못 보는 사각지대) -->
  {#if bgJobs.length > 0}
    <div class="mt-3">
      <div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">백그라운드 작업</div>
      <div class="grid gap-2">
        {#each bgJobs as j (j.id)}
          <div class="bg-surface border rounded-card px-3.5 py-2.5 {j.stale ? 'border-warning' : j.state === 'failed' ? 'border-error' : 'border-default'}">
            <div class="flex items-center gap-2 flex-wrap">
              <span class="w-2 h-2 rounded-full shrink-0 {bgDot(j)}"></span>
              <span class="text-[9px] font-bold rounded px-1.5 py-px bg-default text-dim font-mono">{j.kind}</span>
              <span class="text-xs font-semibold text-text truncate">{j.label ?? '작업'}</span>
              <span class="text-[11px] text-dim tabular-nums ml-auto">
                {#if j.total}{j.processed.toLocaleString()}/{j.total.toLocaleString()}{:else}{j.processed.toLocaleString()}건{/if} · {fmtElapsed(j.elapsed_sec)}
              </span>
            </div>
            {#if j.stale}
              <div class="text-[10px] text-warning mt-1.5">heartbeat 끊김 — 프로세스 중단 추정 (재개 필요할 수 있음)</div>
            {:else if j.state === 'failed'}
              <div class="text-[10px] text-error mt-1.5 truncate">실패{#if j.error} · {j.error}{/if}</div>
            {/if}
          </div>
        {/each}
      </div>
    </div>
  {/if}

  <!-- 실패 처리 드로어 -->
  {#if failOpen}
    <div class="border border-error/40 rounded-card mt-3 overflow-hidden bg-surface">
      <div class="flex items-center gap-2.5 px-4 py-2.5 bg-error/5 text-xs font-bold text-text">
        실패 처리
        <span class="text-[10px] font-semibold text-error">영구 실패 {failItems.length}건 — 자동 재시도 3회 소진, 수동 조치 대기</span>
        <button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (failOpen = false)}>닫기</button>
      </div>
      {#if failLoading}
        <p class="text-xs text-dim text-center py-4">불러오는 중…</p>
      {:else if failItems.length === 0}
        <p class="text-xs text-dim text-center py-4">영구 실패 항목 없음</p>
      {:else}
        {#each failGroups as g (g.key)}
          <div class="px-4 py-2.5 border-t border-default">
            <div class="flex items-center gap-2 flex-wrap text-xs font-bold text-text mb-1">
              {flowStageLabel(g.stage)} {g.items.length}건
              <span class="text-[10px] font-mono font-medium text-error bg-error/10 rounded px-1.5 py-px">{g.pattern}{g.items[0]?.error_message && g.items[0].error_message.length > 36 ? '…' : ''}</span>
            </div>
            {#each expanded[g.key] ? g.items : g.items.slice(0, 4) as it (it.id)}
              <div class="flex items-center gap-2.5 py-1 border-t border-dashed border-default/60 text-xs">
                <span class="flex-1 min-w-0 truncate text-text" title={it.title}>{it.title}</span>
                <span class="text-[10px] font-mono text-faint shrink-0 tabular-nums">시도 {it.attempts}/{it.max_attempts}</span>
                <span class="text-[10px] font-mono text-error shrink-0 max-w-[260px] truncate" title={it.error_message ?? ''}>{it.error_message ?? ''}</span>
                <button class="text-[10px] font-bold border border-accent text-accent rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds([it.id])}>재시도</button>
                <button class="text-[10px] font-bold border border-default text-faint rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds([it.id])}>건너뛰기</button>
              </div>
            {/each}
            {#if g.items.length > 4 && !expanded[g.key]}
              <button class="text-[10px] text-dim cursor-pointer hover:text-text mt-1" onclick={() => (expanded = { ...expanded, [g.key]: true })}>… 외 {g.items.length - 4}건 펼치기</button>
            {/if}
            {#if g.items.length > 1}
              <div class="flex gap-2 mt-1.5">
                <button class="text-[10px] font-bold border border-accent text-accent rounded px-2.5 py-0.5 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds(g.items.map((x) => x.id))}>그룹 전체 재시도 ({g.items.length})</button>
                <button class="text-[10px] font-bold border border-default text-faint rounded px-2.5 py-0.5 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds(g.items.map((x) => x.id))}>그룹 전체 건너뛰기</button>
              </div>
            {/if}
          </div>
        {/each}
        <p class="text-[10px] text-faint px-4 py-2 border-t border-default">
          재시도 = 시도 횟수 리셋 후 큐 재진입 (자동 재시도 3회 새로 부여) · 건너뛰기 = 이 단계 완료 처리(후속 단계 연쇄 없음, 감사 마킹) · 같은 오류가 반복되는 항목(빈 텍스트 등)은 건너뛰기 권장
        </p>
      {/if}
    </div>
  {/if}
</div>

<style>
  /* 머신 색 — 디자인 토큰 외 3색 (gpu 청/macmini 보라/macbook 황) — 이 컴포넌트 한정 */
  .mtag-gpu { background: #e7eef6; color: #3b6ea5; }
  .mtag-macmini { background: #efe9f7; color: #8a5fbf; }
  .mtag-macbook { background: #f7eedd; color: #b07a10; }
  /* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
  .mtag-macmini-bar { background: #8a5fbf; }
  .mtag-macbook-bar { background: #b07a10; }
  .node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
  .detail-frame { border-color: #3b6ea5; }
  .detail-head { background: #e7eef6; }
</style>
