# -*- coding: utf-8 -*-
"""로컬 웹 대시보드 (127.0.0.1 전용, 표준 라이브러리만).

exe 더블클릭 진입점: 서버를 띄우고 기본 브라우저를 연다.
사이드바 카테고리(대시보드/도면/BOM/문서/프로젝트/이력/설정)로 분리 조회,
[지금 검사] 실행, 감시 제외 폴더 관리, 확인(/ack) 링크 처리까지 한 포트에서 담당.
외부 노출 없음 — 산출물이 브라우저 밖으로 나가지 않는다.
"""

from __future__ import annotations

import argparse
import html
import os
import threading
from collections import Counter
from datetime import date, datetime, timedelta, timezone
from http.server import ThreadingHTTPServer
from pathlib import Path, PurePosixPath
from urllib.parse import parse_qs, quote, urlparse

from watcher import __version__
from watcher.ack_server import make_handler
from watcher.consistency import CODE_LABEL
from watcher.state import KIND_LABEL, State

# 문서 유형 카테고리 (파일럿: PDF 도면 중심이라 pdf는 도면으로 분류)
CATEGORIES = {
    "도면": (".dxf", ".dwg", ".pdf"),
    "BOM": (".xlsx", ".xls"),
    "문서": (".docx", ".doc", ".hwp"),
}

# 도면 세부 유형: 파일명·폴더명 키워드로 분류 (config drawing_types로 교체 가능).
# 회사마다 부르는 이름이 달라 키워드 사전 방식 — 어디에도 안 걸리면 "기타 도면"
DRAWING_SUBTYPES = {
    "P&ID": ["P&ID", "PID", "PNID", "P&amp;ID", "계장"],
    "단선도": ["단선도", "단결선", "SLD", "SINGLE LINE", "ONELINE", "ONE-LINE"],
    "결선도": ["결선도", "배선도", "WIRING", "CONNECTION"],
    "회로도": ["회로도", "SCHEMATIC", "CIRCUIT"],
    "배치도": ["배치도", "레이아웃", "LAYOUT", "설치도", "ARRANGEMENT", "GA"],
    "외형도": ["외형도", "치수도", "OUTLINE", "DIMENSION"],
}
SUBTYPE_ETC = "기타 도면"


def _drawing_subtype(path: str, mapping: dict | None = None) -> str:
    """경로(파일명+폴더명) 전체에서 키워드를 찾아 도면 세부 유형을 정한다."""
    up = path.upper()
    for sub, keys in (mapping or DRAWING_SUBTYPES).items():
        if any(k.upper() in up for k in keys):
            return sub
    return SUBTYPE_ETC

_E = lambda s: html.escape(str(s), quote=True)  # noqa: E731

CSS = """
/* 디자인 토큰 — 라이트 기본, [data-theme=dark]에서 재정의 (정의부는 대문자 HEX:
   본문 인라인 스타일의 소문자 hex 일괄 치환과 충돌하지 않게 하기 위한 규칙) */
:root{
  --bg:#F2F4F8; --panel:#FFFFFF; --panel2:#F7F8FB; --hover:#EEF0F6;
  --border:#E3E6EE; --border2:#C9CEDB; --line:#EEF0F5;
  --text:#171C26; --text2:#2A303C; --text3:#454D5E;
  --muted:#6B7484; --muted2:#98A0B0;
  --link:#4353E8; --accent:#5B6CF5; --accent2:#7C8CF8; --on-accent:#FFFFFF;
  --ok:#0F8A3D; --ok-strong:#0F7A37;
  --warn:#B97800; --warn2:#9A6700; --warn3:#A66C08;
  --bad:#C62F2F; --bad2:#B02A2A;
  --tag-add:#E2F5E9; --tag-mod:#FBF0D9; --tag-rm:#FBE7E7; --tag-done:#E5E9FD;
  --note-warn:#FFF8E5; --note-info:#E8ECFE;
  --shadow:0 1px 3px rgba(23,28,38,.06), 0 1px 2px rgba(23,28,38,.04);
  --shadow-lg:0 8px 24px rgba(23,28,38,.10);
}
[data-theme=dark]{
  --bg:#0C0E12; --panel:#14171D; --panel2:#1A1E26; --hover:#212634;
  --border:#232833; --border2:#333B4A; --line:#1E242F;
  --text:#E6E9EF; --text2:#CBD1DC; --text3:#ADB5C4;
  --muted:#8B93A3; --muted2:#68717F;
  --link:#93A0FA; --accent:#7C8CF8; --accent2:#5D6EF5; --on-accent:#FFFFFF;
  --ok:#3FB950; --ok-strong:#4ADE80;
  --warn:#E3B341; --warn2:#E0B75E; --warn3:#EAB94E;
  --bad:#F47067; --bad2:#F47067;
  --tag-add:rgba(63,185,80,.14); --tag-mod:rgba(227,179,65,.14);
  --tag-rm:rgba(244,112,103,.15); --tag-done:rgba(124,140,248,.16);
  --note-warn:#2E2612; --note-info:#1B2140;
  --shadow:0 1px 3px rgba(0,0,0,.45);
  --shadow-lg:0 10px 28px rgba(0,0,0,.55);
}
*{margin:0;box-sizing:border-box;
  font-family:'Pretendard','Malgun Gothic','Segoe UI Variable','Segoe UI',sans-serif}
html{color-scheme:light}
html[data-theme=dark]{color-scheme:dark}
body{background:var(--bg);color:var(--text);display:flex;min-height:100vh;
  -webkit-font-smoothing:antialiased;transition:background .25s}
a{text-decoration:none;color:inherit}
::selection{background:var(--accent);color:var(--on-accent)}
.side{width:216px;background:var(--panel);border-right:1px solid var(--border);
  padding:18px 12px;position:fixed;top:0;bottom:0;display:flex;flex-direction:column}
.logo{font-weight:800;font-size:17px;color:var(--link);padding:6px 10px 16px;
  letter-spacing:-.2px}
.logo small{display:block;color:var(--muted);font-weight:400;font-size:11px;
  margin-top:3px;letter-spacing:0}
/* 사이드바 상시 검색 — 어느 화면에서든 바로 검색 (2026-08-29 요청) */
.side-search{margin:0 6px 12px}
.side-search input{width:100%;border:1px solid var(--border);border-radius:9px;
  padding:7px 11px;font-size:12.5px;background:var(--panel2);color:var(--text)}
.side-search input:focus{outline:2px solid var(--accent2);border-color:var(--accent)}
.nav{flex:1}
.nav a{display:flex;align-items:center;gap:9px;padding:7px 12px;border-radius:9px;
  color:var(--text3);font-size:13px;margin-bottom:2px;
  transition:background .15s,color .15s}
.nav a svg{width:15px;height:15px;flex:none;opacity:.75}
.nav a.on svg,.nav a:hover svg{opacity:1}
.nav a:hover{background:var(--hover);color:var(--text)}
.nav a.on{background:linear-gradient(135deg,var(--accent),var(--accent2));
  color:var(--on-accent);font-weight:700;box-shadow:var(--shadow)}
.theme-btn{margin:10px 6px 2px;padding:8px 12px;border-radius:10px;font-size:12.5px;
  border:1px solid var(--border);background:var(--panel2);color:var(--muted);
  cursor:pointer;transition:background .15s,color .15s;
  display:flex;align-items:center;gap:8px;justify-content:center}
.theme-btn svg{width:14px;height:14px;flex:none}
.theme-btn:hover{background:var(--hover);color:var(--text)}
.main{margin-left:216px;flex:1;padding:22px 28px;max-width:1200px}
.topbar{display:flex;justify-content:space-between;align-items:center;
  margin-bottom:18px;gap:12px}
.topbar h1{font-size:20px;font-weight:800;letter-spacing:-.3px}
.btn{background:linear-gradient(135deg,var(--accent),var(--accent2));
  color:var(--on-accent);border:none;border-radius:9px;
  padding:8px 16px;font-size:13px;cursor:pointer;font-weight:700;
  transition:filter .15s,transform .05s;
  box-shadow:0 3px 12px rgba(92,108,245,.28)}
.btn:hover{filter:brightness(1.08)}
.btn:active{transform:translateY(1px)}
.btn.gray{background:var(--panel2);color:var(--text2);border:1px solid var(--border);
  box-shadow:none}
.btn.red{background:var(--bad);color:#fff;box-shadow:none}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;
  margin-bottom:12px}
.card{background:var(--panel);border-radius:12px;padding:13px 16px;
  border:1px solid var(--border);box-shadow:var(--shadow);
  transition:box-shadow .2s,transform .2s,border-color .15s}
a.card:hover{box-shadow:var(--shadow-lg);transform:translateY(-1px);
  border-color:var(--accent)}
.card .k{color:var(--muted);font-size:11.5px;margin-bottom:4px}
.card .v{font-size:27px;font-weight:800;letter-spacing:-.8px;line-height:1.1;
  font-variant-numeric:tabular-nums}
.card .v small{font-size:12px;color:var(--muted);font-weight:400;letter-spacing:0}
.card .v.warn{color:var(--warn)}.card .v.bad{color:var(--bad)}.card .v.ok{color:var(--ok)}
.panel{background:var(--panel);border-radius:12px;padding:13px 16px;margin-bottom:12px;
  border:1px solid var(--border);box-shadow:var(--shadow);overflow-x:auto}
.panel h2{font-size:11.5px;margin-bottom:10px;color:var(--muted);font-weight:700;
  text-transform:uppercase;letter-spacing:.7px}
.grid2{display:grid;grid-template-columns:3fr 2fr;gap:12px;align-items:start}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th{color:var(--muted);font-weight:600;text-align:left;padding:6px 10px;
  border-bottom:1px solid var(--border);font-size:10.5px;letter-spacing:.6px;
  text-transform:uppercase;background:var(--panel2);white-space:nowrap}
td{padding:6px 10px;border-bottom:1px solid var(--line);
  font-variant-numeric:tabular-nums}
tr{transition:background .1s}
tr:hover td{background:var(--panel2)}
.tag{display:inline-block;border-radius:999px;padding:2px 10px;font-size:12px;
  font-weight:600;white-space:nowrap}
.tag.added{background:var(--tag-add);color:var(--ok-strong)}
.tag.modified{background:var(--tag-mod);color:var(--warn3)}
.tag.removed{background:var(--tag-rm);color:var(--bad2)}
.tag.na{background:var(--hover);color:var(--muted)}
.tag.done{background:var(--tag-done);color:var(--link)}
.bar{height:8px;border-radius:999px;background:var(--border);overflow:hidden}
.bar i{display:block;height:100%;
  background:linear-gradient(90deg,var(--accent),var(--accent2))}
.proj{display:flex;align-items:center;gap:10px;padding:7px 0}
.proj .nm{width:110px;font-weight:600;font-size:13.5px}
.proj .bar{flex:1}
.proj .pc{width:110px;text-align:right;color:var(--muted);font-size:12.5px}
.muted{color:var(--muted2);font-size:12.5px}
form.inline{display:inline}
input[type=text],input[type=password]{border:1px solid var(--border2);
  border-radius:8px;padding:8px 10px;font-size:13.5px;
  background:var(--panel);color:var(--text)}
input[type=text]:focus,input[type=password]:focus{outline:2px solid var(--accent2);
  outline-offset:0;border-color:var(--accent)}
input::placeholder{color:var(--muted2)}
button{font-family:inherit}
details summary{color:var(--muted);cursor:pointer}
.chart{display:flex;align-items:flex-end;gap:6px;height:120px;padding-top:6px}
.chart .col{flex:1;display:flex;flex-direction:column;justify-content:flex-end;
  align-items:center;gap:4px;height:100%}
.chart .col i{width:100%;border-radius:6px 6px 0 0;
  background:linear-gradient(180deg,var(--accent2),var(--accent))}
.chart .col b{font-size:10.5px;color:var(--muted2);font-weight:400}
select.projsel{border:1px solid var(--border2);border-radius:10px;padding:8px 12px;
  font-size:13.5px;background:var(--panel);color:var(--text);max-width:280px}
.filterbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.filterbar label{font-size:12.5px;color:var(--muted)}
code{background:var(--panel2);border:1px solid var(--line);border-radius:6px;
  padding:1px 5px;font-size:12.5px}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:999px}
::-webkit-scrollbar-track{background:transparent}
/* 패널 내부 스크롤 — 항목이 많아도 잘라내지 않고 그 자리에서 내려 본다
   (2026-08-28 요청). 표 머리글은 스크롤 중에도 고정 */
.scrolly{max-height:420px;overflow-y:auto;overscroll-behavior:contain}
.scrolly.sm{max-height:280px}
.scrolly th{position:sticky;top:0;z-index:1}

/* 프로젝트 카드 홈 (2026-08-27) */
.pcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
.pcard{background:var(--panel2);border:1px solid var(--border);border-radius:12px;
  padding:14px;display:flex;flex-direction:column;gap:4px;
  transition:box-shadow .2s,transform .2s}
.pcard:hover{box-shadow:var(--shadow-lg);transform:translateY(-1px)}
.pc-head{display:flex;justify-content:space-between;align-items:center;gap:8px;
  margin-bottom:2px}
.pc-head a{font-weight:700;font-size:14.5px;color:var(--link)}
.pfile{font-size:12.5px;padding:3px 0;white-space:nowrap;overflow:hidden;
  text-overflow:ellipsis}
.pc-more{margin-top:auto;padding-top:8px;font-size:12px;color:var(--muted);
  align-self:flex-end}
.pc-more:hover{color:var(--link)}
/* 도움말 가독성 (2026-08-27) */
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
.step{display:flex;gap:12px;background:var(--panel2);border:1px solid var(--border);
  border-radius:12px;padding:14px;font-size:13.5px;line-height:1.65}
.step b{flex:0 0 30px;height:30px;border-radius:999px;background:var(--accent);
  color:var(--on-accent);display:flex;align-items:center;justify-content:center;
  font-size:15px}
details.qa{border-bottom:1px solid var(--line);padding:4px 0}
details.qa:last-child{border-bottom:none}
details.qa summary{padding:9px 4px;font-size:13.5px;font-weight:600;color:var(--text2);
  list-style:none;cursor:pointer}
details.qa summary::before{content:'▸ ';color:var(--muted2)}
details.qa[open] summary::before{content:'▾ '}
details.qa p,details.qa ul{font-size:13.5px;line-height:1.75;color:var(--text3);
  padding:2px 8px 10px 20px;margin:0}
details.qa ul{padding-left:38px}
dl.terms{display:grid;grid-template-columns:110px 1fr;gap:8px 14px;font-size:13.5px;
  line-height:1.6;margin:0}
dl.terms dt{font-weight:700;color:var(--text2)}
dl.terms dd{margin:0;color:var(--text3)}
/* 좁은 창 대응: 사이드바가 상단 가로 메뉴로 접히고, 2단 패널은 1단으로 */
@media (max-width: 920px){
  body{display:block}
  .side{position:static;width:auto;border-right:none;
    border-bottom:1px solid var(--border);
    padding:10px 14px;display:flex;flex-direction:row;align-items:center;gap:12px;
    flex-wrap:wrap}
  .logo{padding:0;font-size:15px}
  .logo small{display:none}
  .side-search{margin:0;flex:1;min-width:150px}
  .nav{display:flex;flex-wrap:wrap;gap:2px;flex:unset}
  .nav a{padding:6px 10px;font-size:13px;margin-bottom:0;white-space:nowrap}
  .theme-btn{margin:0}
  .main{margin-left:0;padding:14px 12px}
  .grid2{grid-template-columns:1fr}
  .topbar{flex-wrap:wrap;gap:10px}
  .cards{grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
  .card .v{font-size:22px}
}
"""

# 사이드바 선형(스트로크) 아이콘 — 문자 기호(▦⚙)가 촌스럽다는 피드백으로 교체
# (2026-08-29). 외부 리소스 금지 원칙이라 인라인 SVG, currentColor로 테마 자동 추종.
def _ic(inner: str) -> str:
    return (f'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
            f'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" '
            f'aria-hidden="true">{inner}</svg>')


_NAV_ICON = {
    "dash": _ic('<rect x="3" y="3" width="7" height="7" rx="1.5"/>'
                '<rect x="14" y="3" width="7" height="7" rx="1.5"/>'
                '<rect x="14" y="14" width="7" height="7" rx="1.5"/>'
                '<rect x="3" y="14" width="7" height="7" rx="1.5"/>'),
    "issues": _ic('<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0'
                  ' 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>'
                  '<line x1="12" y1="9" x2="12" y2="13"/>'
                  '<line x1="12" y1="17" x2="12.01" y2="17"/>'),
    "brief": _ic('<rect x="2" y="4" width="20" height="16" rx="2"/>'
                 '<polyline points="22,6 12,13 2,6"/>'),
    "dwg": _ic('<rect x="3" y="3" width="18" height="18" rx="2"/>'
               '<line x1="3" y1="9" x2="21" y2="9"/>'
               '<line x1="9" y1="21" x2="9" y2="9"/>'),
    "bom": _ic('<line x1="8" y1="6" x2="21" y2="6"/>'
               '<line x1="8" y1="12" x2="21" y2="12"/>'
               '<line x1="8" y1="18" x2="21" y2="18"/>'
               '<line x1="3" y1="6" x2="3.01" y2="6"/>'
               '<line x1="3" y1="12" x2="3.01" y2="12"/>'
               '<line x1="3" y1="18" x2="3.01" y2="18"/>'),
    "doc": _ic('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 '
               '2-2V8z"/><polyline points="14,2 14,8 20,8"/>'
               '<line x1="8" y1="13" x2="16" y2="13"/>'
               '<line x1="8" y1="17" x2="13" y2="17"/>'),
    "proj": _ic('<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 '
                '2-2h5l2 3h9a2 2 0 0 1 2 2z"/>'),
    "organize": _ic('<polygon points="13,2 3,14 12,14 11,22 21,10 12,10 13,2"/>'),
    "history": _ic('<circle cx="12" cy="12" r="10"/>'
                   '<polyline points="12,6 12,12 16,14"/>'),
    "settings": _ic('<line x1="4" y1="21" x2="4" y2="14"/>'
                    '<line x1="4" y1="10" x2="4" y2="3"/>'
                    '<line x1="12" y1="21" x2="12" y2="12"/>'
                    '<line x1="12" y1="8" x2="12" y2="3"/>'
                    '<line x1="20" y1="21" x2="20" y2="16"/>'
                    '<line x1="20" y1="12" x2="20" y2="3"/>'
                    '<line x1="1" y1="14" x2="7" y2="14"/>'
                    '<line x1="9" y1="8" x2="15" y2="8"/>'
                    '<line x1="17" y1="16" x2="23" y2="16"/>'),
    "help": _ic('<circle cx="12" cy="12" r="10"/>'
                '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>'
                '<line x1="12" y1="17" x2="12.01" y2="17"/>'),
    "theme": _ic('<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>'),
    "search": _ic('<circle cx="11" cy="11" r="8"/>'
                  '<line x1="21" y1="21" x2="16.65" y2="16.65"/>'),
}

NAV = [("/", "dash", "대시보드"), ("/issues", "issues", "검토 필요"),
       ("/tags", "search", "태그 검색"), ("/briefs", "brief", "브리핑"),
       ("/cat?c=도면", "dwg", "도면"), ("/cat?c=BOM", "bom", "BOM"),
       ("/cat?c=문서", "doc", "문서"), ("/projects", "proj", "프로젝트"),
       ("/organize", "organize", "정리 제안"),
       ("/history", "history", "전체 이력"), ("/settings", "settings", "설정"),
       ("/help", "help", "도움말")]


def _parse_utc(iso: str) -> datetime:
    """저장된 ISO 문자열(UTC) → 시간대 정보가 있는 datetime. 오프셋이 없으면 UTC로 간주."""
    if not ("+" in iso[10:] or iso.endswith("Z")):
        iso = iso + "+00:00"
    return datetime.fromisoformat(iso)


def _local(iso: str | None, fmt: str = "%Y-%m-%d %H:%M") -> str:
    """UTC ISO 문자열 → 이 PC의 지역 시간 표시.

    UTC 그대로 보여주면 아침 9시 전 변경이 '어제'로 찍히고 '오늘 변경'이
    0건으로 보인다 (2026-08-25 UX 검토)."""
    if not iso:
        return ""
    try:
        return _parse_utc(iso).astimezone().strftime(fmt)
    except ValueError:
        return iso[:16]


def _local_date(iso: str | None) -> str:
    return _local(iso, "%Y-%m-%d")


def _intro(text: str) -> str:
    """화면 제목 아래 한 줄 설명."""
    return f'<p class="muted" style="margin:-8px 0 14px">{text}</p>'


def _page(title: str, active: str, body: str, msg: str = "",
          auto_refresh: bool = False) -> str:
    nav = "".join(
        f'<a href="{href}" class="{"on" if href == active else ""}">'
        f'{_NAV_ICON[icon]}<span>{label}</span></a>'
        for href, icon, label in NAV)
    notice = f'<div class="panel" style="color:var(--link)">{_E(msg)}</div>' if msg else ""
    # 검사 중일 때만 자동 새로고침 — 끝나면 결과 화면으로 저절로 넘어간다
    refresh = '<meta http-equiv="refresh" content="5">' if auto_refresh else ""
    return f"""<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">{refresh}
<title>{_E(title)} — Drev</title>
<script>(function(){{var t=localStorage.getItem('pw-theme');
document.documentElement.dataset.theme=t||'dark';}})();
function pwTheme(){{var h=document.documentElement;
var n=h.dataset.theme==='dark'?'light':'dark';
h.dataset.theme=n;localStorage.setItem('pw-theme',n);}}
function pwFilterRows(inp,tid){{var q=inp.value.toLowerCase();
document.querySelectorAll('#'+tid+' tr[data-name]').forEach(function(tr){{
tr.style.display=tr.dataset.name.indexOf(q)>=0?'':'none';}});}}</script>
<style>{CSS}</style></head><body>
<div class="side"><div class="logo"><b>Drev</b><small>산출물 변경 감시 v{__version__}</small></div>
<form class="side-search" method="get" action="/tags">
<input type="text" name="q" placeholder="태그·파일명 검색" aria-label="검색"></form>
<div class="nav">{nav}</div>
<button class="theme-btn" onclick="pwTheme()">{_NAV_ICON["theme"]}<span>다크/라이트 전환</span></button></div>
<div class="main">{notice}{body}
<p class="muted" style="margin-top:22px">이 화면은 Drev 실행 중에만 동작합니다 —
창을 닫으면 프로그램이 종료됩니다 (검사 진행분은 저장되어 다음 검사가 이어받습니다).
설정의 [프로그램 종료]로도 끌 수 있습니다.</p></div></body></html>"""


def _ext_category(path: str) -> str:
    ext = PurePosixPath(path).suffix.lower()
    for cat, exts in CATEGORIES.items():
        if ext in exts:
            return cat
    return "기타"


def _kind_tag(kind: str) -> str:
    return f'<span class="tag {kind}">{KIND_LABEL[kind]}</span>'


def _ack_tag(r: dict) -> str:
    if r["ack_by"]:
        note = f" — {r['ack_note']}" if r["ack_note"] else ""
        return f'<span class="tag done">확인</span> {_E(r["ack_by"])}{_E(note)}'
    return '<span class="tag na">미확인</span>'


def _path_html(path: str) -> str:
    """긴 경로의 표시용: 파일명 굵게 + 바로 위 폴더 1~2단계만 회색.
    깊은 서버 경로가 화면을 다 차지하던 문제 (2026-08-28 검토). 전체 경로는
    셀의 title 툴팁으로."""
    parts = path.split("/")
    name = parts[-1]
    crumb = "/".join(parts[-3:-1])
    pre = (f'<span class="muted" style="font-size:11.5px">{_E(crumb)}/</span>'
           if crumb else "")
    return f"{pre}<b>{_E(name)}</b>"


def _open_cell(key: str, tok: str) -> str:
    """파일·폴더 바로 열기 버튼 — 목록에서 파일까지 찾아가는 수고 제거 (2026-08-29)."""
    return (f"<td style='white-space:nowrap'>"
            f'<form class="inline" method="post" action="/open">'
            f'<input type="hidden" name="t" value="{tok}">'
            f'<input type="hidden" name="k" value="{_E(key)}">'
            f'<button class="btn gray" name="w" value="f" title="이 PC 기본 '
            f'프로그램으로 파일 열기" style="font-size:11px;padding:2px 7px">'
            f'열기</button> '
            f'<button class="btn gray" name="w" value="d" title="파일이 있는 '
            f'폴더 열기" style="font-size:11px;padding:2px 7px">폴더</button>'
            f'</form></td>')


def _rows_table(rows: list[dict], limit: int = 200,
                table_id: str | None = None, tok: str | None = None) -> str:
    trs = "".join(
        f"<tr data-name=\"{_E((r['path'] + ' ' + (r['project'] or '')).lower())}\">"
        f"<td>#{r['change_id']}</td>"
        f"<td style='white-space:nowrap' title=\"{_E(_local(r['detected_at']))}\">"
        f"{_E(_local(r['detected_at'], '%m-%d %H:%M'))}</td>"
        f"<td>{_kind_tag(r['kind'])}</td>"
        f"<td>{_E(_ext_category(r['path']))}</td>"
        f"<td style='white-space:nowrap'>"
        f"<a href='/?p={quote(r['project'] or '미분류')}' "
        f"style='color:var(--link)'>{_E(r['project'] or '미분류')}</a></td>"
        f"<td title=\"{_E(r['path'])}\">"
        f"<a href='/change?id={r['change_id']}' style='color:var(--link)'>"
        f"{_path_html(r['path'])}</a></td><td>{_ack_tag(r)}</td>"
        + (_open_cell(r['path'], tok) if tok else "") + "</tr>"
        for r in rows[:limit])
    more = (f'<p class="muted" style="margin-top:8px">외 {len(rows) - limit}건 생략</p>'
            if len(rows) > limit else "")
    tid = f' id="{table_id}"' if table_id else ""
    return (f"<table{tid}><tr><th>번호</th><th>일시</th><th>구분</th><th>유형</th>"
            f"<th>프로젝트</th><th>파일</th><th>확인</th>"
            + ("<th></th>" if tok else "")
            + f"</tr>{trs}</table>{more}"
            if rows else '<p class="muted">해당 항목이 없습니다.</p>')


def _trend_chart(rows: list[dict], days: int = 14) -> str:
    today = date.today()
    span = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
    counts = Counter(_local_date(r["detected_at"]) for r in rows)
    mx = max([counts.get(d.isoformat(), 0) for d in span] + [1])
    cols = "".join(
        f'<div class="col"><i style="height:{max(4, round(counts.get(d.isoformat(), 0) / mx * 100))}%"'
        f' title="{d.isoformat()}: {counts.get(d.isoformat(), 0)}건"></i>'
        f"<b>{d.strftime('%d')}</b></div>"
        for d in span)
    return f'<div class="chart">{cols}</div>'


def _proj_of(r: dict) -> str:
    return r["project"] or "미분류"


def _filter_bar(base: str, projects: list[str], current: str | None,
                extra: str = "") -> str:
    """모든 화면 공용 프로젝트 선택 드롭다운. 선택 즉시 해당 프로젝트로 필터."""
    opts = f'<option value="">전체 프로젝트 ({len(projects)}개)</option>'
    for p in projects:
        sel = " selected" if p == current else ""
        opts += f'<option value="{_E(p)}"{sel}>{_E(p)}</option>'
    return (f'<div class="filterbar"><label>프로젝트</label>'
            f'<select class="projsel" onchange="location=\'{base}?{extra}p=\''
            f'+encodeURIComponent(this.value)">{opts}</select></div>')


# 배포 사이트의 버전 안내 파일 주소 — 사이트 개설 시 채운다 (2026-08-29).
# 형식: {"version": "0.28.0", "url": "다운로드 페이지", "notes": "한 줄 요약"}
# config update_url로 덮어쓸 수 있고, update_check: false로 확인 자체를 끌 수 있다.
UPDATE_URL = "https://gmlvy12.github.io/drev-site/version.json"


def check_update(cur_version: str, url: str) -> dict | None:
    """새 버전이 있으면 {version, url, notes}, 아니면 None. 실패도 None(조용).
    버전 숫자 파일 하나만 GET — 산출물·사용 정보는 아무것도 보내지 않는다 (원칙 1)."""
    if not url:
        return None
    import json as _json
    import urllib.request as _rq
    try:
        with _rq.urlopen(url, timeout=5) as r:
            info = _json.load(r)
    except Exception:
        return None

    def _t(v):
        return tuple(int(x) for x in str(v).split(".") if x.isdigit())

    try:
        if _t(info.get("version", "0")) > _t(cur_version):
            return {"version": str(info.get("version")),
                    "url": str(info.get("url", "")),
                    "notes": str(info.get("notes", ""))[:200]}
    except (TypeError, ValueError):
        return None
    return None


class WebApp:
    """라우팅과 데이터 조회. 핸들러 클래스에서 인스턴스 하나를 공유한다."""

    def __init__(self, config_path: str):
        self.config_path = config_path
        self._scan_lock = threading.Lock()
        self.scanning = False
        self._scan_started = None
        self.last_msg = ""
        self.update_info: dict | None = None   # 새 버전 정보 (없으면 None)

    def refresh_update(self) -> dict | None:
        cfg = self.cfg()
        url = cfg.get("update_url") or UPDATE_URL
        self.update_info = check_update(__version__, url)
        return self.update_info

    def pop_msg(self) -> str:
        """알림 메시지는 1회만 표시 — 다음 화면까지 남아 혼란 주던 문제 수정."""
        m, self.last_msg = self.last_msg, ""
        return m

    def last_scan(self) -> tuple[str | None, int]:
        """(마지막 검사 시각 ISO(UTC), 그때 파일 수). 검사 이력 없으면 (None, 0)."""
        if not self.db().exists():
            return None, 0
        with State(self.db()) as st:
            row = st.conn.execute(
                "SELECT id, run_at FROM snapshots ORDER BY id DESC LIMIT 1").fetchone()
            if not row:
                return None, 0
            n = st.conn.execute("SELECT COUNT(*) FROM files WHERE snapshot_id = ?",
                                (row[0],)).fetchone()[0]
            return row[1], n

    def scan_health(self) -> str:
        """대시보드용 '살아있음' 신호 — 마지막 검사 시각/파일 수, 25시간 넘으면 경고.
        조용한 것(변경 0건)과 멈춘 것(검사 자체가 안 돎)을 구분해준다 (2026-08-25 UX 검토)."""
        run_at, n = self.last_scan()
        if run_at is None:
            return ""
        try:
            hours = (datetime.now(timezone.utc)
                     - _parse_utc(run_at)).total_seconds() / 3600
        except ValueError:
            hours = 0
        line = f'마지막 검사: <b>{_local(run_at)}</b> · 파일 {n}개 확인'
        if hours > 25:
            return (f'<div class="panel" style="background:var(--note-warn);color:var(--warn2)">'
                    f'⚠ {line} — <b>검사가 {int(hours)}시간째 없습니다.</b> '
                    f'매일 자동 실행이 멈췄을 수 있습니다 (PC가 꺼져 있었거나 '
                    f'로그오프 상태면 그날 검사는 건너뜁니다). '
                    f'[▶ 지금 검사]를 눌러 확인해보세요.</div>')
        return f'<div class="panel" style="color:var(--text3)">{line}</div>'

    # ------------------------------------------------------------ 데이터
    def cfg(self) -> dict:
        from watcher.cli import load_config
        return load_config(Path(self.config_path))

    def db(self) -> Path:
        return Path(self.cfg()["state_db"])

    def csrf(self) -> str:
        """폼 위조(CSRF) 방지 토큰 — 모든 대시보드 POST에 요구 (2026-08-25 리뷰).
        127.0.0.1 바인딩은 CSRF를 못 막는다: 요청이 사용자 브라우저에서 나간다."""
        if not getattr(self, "_csrf", None):
            with State(self.db()) as st:
                self._csrf = st.csrf_token()
        return self._csrf

    def _tok(self) -> str:
        """POST 폼에 심는 CSRF 토큰 hidden 필드."""
        return f'<input type="hidden" name="t" value="{self.csrf()}">'

    def rows(self) -> list[dict]:
        if not self.db().exists():
            return []
        with State(self.db()) as st:
            return list(reversed(st.history()))  # 최신순

    def baseline_id(self) -> int | None:
        """기준선(첫 검사) 스냅샷 id — 그때의 '추가' 대량 건은 미확인 집계에서 뺀다."""
        if not self.db().exists():
            return None
        with State(self.db()) as st:
            return st.first_snapshot_id()

    @staticmethod
    def is_baseline_add(r: dict, base_id: int | None) -> bool:
        return base_id is not None and r["kind"] == "added" \
            and r.get("snapshot_id") == base_id

    def file_count(self) -> int:
        if not self.db().exists():
            return 0
        with State(self.db()) as st:
            return len(st.latest_snapshot())

    def issue_rows(self, project: str | None = None) -> list[dict]:
        if not self.db().exists():
            return []
        with State(self.db()) as st:
            return st.issues(project)

    # ------------------------------------------------------------ 동작
    def start_scan(self) -> None:
        def work():
            from watcher.cli import cmd_run
            args = argparse.Namespace(config=self.config_path, root=None,
                                      state_db=None, brief_dir=None, dry_run=False)
            try:
                rc = cmd_run(args)
                self.last_msg = "검사 완료 — 목록이 갱신되었습니다." if rc == 0 \
                    else "검사 실패 — 콘솔/로그를 확인하세요. (변경은 유실되지 않음)"
            except Exception as e:
                self.last_msg = f"검사 실패: {e}"
            finally:
                self.scanning = False

        # 확인→시작을 한 락 안에서 — 동시 POST /scan 두 개가 같은 state.db에
        # cmd_run을 겹쳐 돌리는 것 방지 (2026-08-25 리뷰)
        with self._scan_lock:
            t = getattr(self, "_scan_thread", None)
            if self.scanning and t is not None and t.is_alive():
                return
            self.scanning = True
            self._scan_thread = threading.Thread(target=work, daemon=True)
            self._scan_thread.start()

    def scan_state(self) -> str:
        """'idle' | 'scanning' | 'stale'.

        판정 기준은 경과 시간이 아니라 작업 스레드 생존 여부다 — 시간 기준(600초)은
        캡처 생성 등으로 오래 걸리는 정상 검사를 '죽었다'고 오판하고 플래그를 풀어
        동시 실행을 유발했다 (2026-08-25 리뷰). stale = 플래그는 켜져 있는데
        스레드가 죽어 있음 (비정상 종료)."""
        if not self.scanning:
            return "idle"
        t = getattr(self, "_scan_thread", None)
        if t is not None and t.is_alive():
            return "scanning"
        self.scanning = False
        self.last_msg = ("직전 검사가 비정상 종료된 것 같습니다. "
                         "다시 [지금 검사]를 눌러주세요.")
        return "stale"

    def _save_key(self, key: str, value) -> None:
        import yaml
        p = Path(self.config_path)
        with open(p, "rb") as f:
            data = yaml.safe_load(f) or {}
        data[key] = value
        if key == "roots":
            data.pop("root_path", None)  # 구버전 키와의 이중 진실 방지
        p.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
                     encoding="utf-8")

    def save_excludes(self, excludes: list[str]) -> None:
        self._save_key("exclude", excludes)

    def save_roots(self, roots: list[str]) -> None:
        self._save_key("roots", roots)

    def abs_path_of(self, key: str):
        """이력의 경로 키('라벨/상대경로') → 실제 파일 경로."""
        from watcher.cli import _root_labels
        cfg = self.cfg()
        roots = [Path(r) for r in cfg["roots"] if Path(r).is_dir()]
        for root, label in zip(roots, _root_labels(roots)):
            if key.startswith(label + "/"):
                p = root / PurePosixPath(key[len(label) + 1:])
                if p.is_file():
                    return p
        return None

    def save_mail(self, form: dict) -> None:
        cfg = self.cfg()
        old = cfg.get("mail") or {}
        get = lambda k: (form.get(k, [""])[0] or "").strip()  # noqa: E731
        try:  # 숫자가 아니면 500 대신 기존 값 유지 (2026-08-25 리뷰)
            port = int(get("smtp_port") or 25)
        except ValueError:
            port = old.get("smtp_port", 25)
        mail = {
            "enabled": form.get("enabled", [""])[0] == "on",
            "smtp_host": get("smtp_host"),
            "smtp_port": port,
            "starttls": form.get("starttls", [""])[0] == "on",
            "ssl": form.get("use_ssl", [""])[0] == "on",
            "sender": get("sender"),
            "recipients": [r.strip() for r in
                           get("recipients").split(",") if r.strip()],
        }
        if get("username"):
            mail["username"] = get("username")
            # 비밀번호 칸이 비어 있으면 기존 값 유지 (수정 화면에서 항상 다시 안 치게)
            mail["password"] = get("password") or old.get("password", "")
        self._save_key("mail", mail)
        if get("ack_url"):
            self._save_key("ack_url", get("ack_url"))

    def test_mail(self) -> str:
        """저장된 설정으로 테스트 메일 1통. 결과 메시지를 돌려준다."""
        from watcher import mail as mail_mod
        cfg = self.cfg()
        mc = dict(cfg.get("mail") or {})
        mc["enabled"] = True  # 켜기 전에도 시험할 수 있게
        if not mc.get("recipients"):
            return "테스트 실패: 수신자가 비어 있습니다."
        html_body = ("<html><body style='font-family:sans-serif'>"
                     "<h3>Drev 테스트 메일</h3>"
                     "<p>이 메일이 보이면 발송 설정이 정상입니다.</p></body></html>")
        try:
            ok = mail_mod.send_brief(mc, "[Drev] 테스트 메일", html_body)
        except Exception as e:
            return f"테스트 실패: {e}"
        return ("테스트 메일 발송 완료 — 수신함을 확인하세요: "
                + ", ".join(mc["recipients"])) if ok else "테스트 실패: 발송되지 않음"

    # ------------------------------------------------------------ 페이지
    def page_onboarding(self) -> str:
        """최초 가동 화면 — 감시 폴더가 없으면 대시보드 대신 이걸 보여준다.
        입력 하나 + 버튼 하나로 '추가 + 첫 검사'까지 (2026-08-30 사용자 제안)."""
        body = f"""
<div style="max-width:640px;margin:8vh auto 0">
  <div class="panel" style="padding:34px 36px;text-align:center">
    <h1 style="font-size:24px;margin-bottom:10px">Drev 시작하기</h1>
    <p class="muted" style="margin-bottom:22px">감시할 파일서버 폴더 하나만
    알려주세요 — 나머지는 자동입니다. 폴더 안의 파일은 <b>읽기만</b> 하며
    절대 수정·이동·삭제하지 않습니다.</p>
    <form method="post" action="/onboard">{self._tok()}
      <input type="text" name="path" required autofocus
        placeholder="예: \\\\서버\\설계부  또는  D:\\projects"
        style="width:100%;font-size:15px;padding:13px 16px;margin-bottom:12px">
      <button class="btn" style="width:100%;font-size:15px;padding:13px">
        폴더 추가하고 첫 검사 시작</button>
    </form>
    <p class="muted" style="margin-top:16px;font-size:12.5px">
    첫 검사는 파일 수에 따라 몇 분~몇 시간 걸릴 수 있고, 진행률이 화면에
    표시됩니다. 폴더는 나중에 [설정]에서 더 추가하거나 제외할 수 있습니다.</p>
  </div>
</div>"""
        return _page("시작하기", "/", body, self.pop_msg())

    def page_dashboard(self, proj: str | None = None,
                       view: str = "cards") -> str:
        if not self.cfg()["roots"]:
            return self.page_onboarding()
        """홈 화면. view: 'cards'=프로젝트별 변경 카드(기본) / 'classic'=전체 요약.
        큰틀을 사용자가 고를 수 있게 상단 토글로 전환·기억 (2026-08-27 요청)."""
        all_rows = self.rows()
        projects = sorted({_proj_of(r) for r in all_rows})
        rows = [r for r in all_rows if _proj_of(r) == proj] if proj else all_rows
        today = date.today().isoformat()
        today_rows = [r for r in rows if _local_date(r["detected_at"]) == today]
        base_id = self.baseline_id()
        # 기준선(첫 검사) '추가' 대량 건은 실제 변경이 아니므로 미확인 수에서 제외 —
        # 설치 첫날부터 '미확인 1,000건' 빨간불이 뜨던 문제 (2026-08-26 신입 검토)
        unacked = [r for r in rows
                   if not r["ack_by"] and not self.is_baseline_add(r, base_id)]
        iss = self.issue_rows(proj)
        st_state = self.scan_state()
        scan_state = " (검사 중...)" if st_state == "scanning" else ""
        title = _E(proj) if proj else "대시보드"

        issue_panel = ""
        if iss:
            lis = "".join(
                f'<div class="proj" style="align-items:flex-start">'
                f'<span class="tag removed" style="margin-top:2px;white-space:nowrap">'
                f'{_E(CODE_LABEL.get(i["code"], i["code"]))}</span>'
                f'<div style="flex:1;font-size:13px;padding-left:10px">{_E(i["message"])}'
                f'<div class="muted" title="{_E(i["path"])}">'
                f'{_path_html(i["path"])} · {_E(_local_date(i["detected_at"]))}</div>'
                f"</div></div>"
                for i in iss[:30])
            issue_panel = (
                f'<div class="panel"><h2>검토 필요 — 최근 {min(len(iss), 30)}건 '
                f'<a href="/issues{f"?p={quote(proj)}" if proj else ""}" '
                f'style="font-weight:400;font-size:12.5px;text-transform:none">'
                f'· 전체 {len(iss)}건 보기 →</a></h2>'
                f'<div class="scrolly sm">{lis}</div></div>')

        if proj:
            cats = Counter(_ext_category(r["path"]) for r in rows)
            side_panel = ("<h2>문서 유형 구성</h2>" + "".join(
                f'<div class="proj"><span class="nm">{_E(c)}</span>'
                f'<div class="bar"><i style="width:{round(n / max(len(rows), 1) * 100)}%"></i></div>'
                f'<div class="pc">{n}건</div></div>'
                for c, n in cats.most_common())) or "<h2>문서 유형 구성</h2>"
            fourth_card = (f'<div class="card"><div class="k">검토 필요 누적</div>'
                           f'<div class="v {"bad" if iss else "ok"}">{len(iss)}'
                           f'<small> 건</small></div></div>')
        else:
            # 수십 개 규모 대비: 미확인 있는 것 → 최근 움직인 것 순
            stats = self.project_stats()
            stats.sort(key=lambda d: (d["unacked"] > 0, d["last"]), reverse=True)
            proj_bars = ""
            for st_p in stats:  # 전부 — 많으면 패널 안에서 스크롤 (2026-08-28)
                p = st_p["name"]
                pr = [r for r in all_rows if _proj_of(r) == p]
                done = sum(1 for r in pr if r["ack_by"])
                pct = round(done / len(pr) * 100) if pr else 0
                proj_bars += (
                    f'<div class="proj"><a class="nm" href="/?p={quote(p)}" '
                    f'style="color:var(--link)">{_E(p)}</a>'
                    f'<div class="bar"><i style="width:{pct}%"></i></div>'
                    f'<div class="pc">확인 {done}/{len(pr)} ({pct}%)</div></div>')
            more_link = (f'<p style="margin-top:8px"><a href="/projects" '
                         f'style="color:var(--link);font-size:12.5px">프로젝트 '
                         f'화면에서 검색·정렬 →</a></p>'
                         if len(stats) > 8 else "")
            side_panel = ("<h2>주요 프로젝트 (미확인·최근 활동 순)</h2>"
                          + (f'<div class="scrolly sm">{proj_bars}</div>'
                             if proj_bars else
                             '<p class="muted">아직 데이터가 없습니다.</p>')
                          + more_link)
            fourth_card = (f'<div class="card"><div class="k">검토 필요 누적</div>'
                           f'<div class="v {"bad" if iss else "ok"}">{len(iss)}'
                           f'<small> 건</small></div></div>')

            # ── 프로젝트 카드형 본문: 프로젝트별 변경사항을 카드로 (기본 큰틀)
            pcards = ""
            for st_p in stats[:60]:  # 많으면 스크롤 (2026-08-28)
                p = st_p["name"]
                pr = [r for r in all_rows if _proj_of(r) == p]
                today_n = sum(1 for r in pr
                              if _local_date(r["detected_at"]) == today)
                seen_ids: set = set()
                files = ""
                for r in pr:  # 변경 단위 dedup 후 최근 3건
                    if r["change_id"] in seen_ids:
                        continue
                    seen_ids.add(r["change_id"])
                    if len(seen_ids) > 3:
                        break
                    files += (
                        f'<div class="pfile">{_kind_tag(r["kind"])} '
                        f'<a href="/change?id={r["change_id"]}" '
                        f'style="color:var(--link)">'
                        f'{_E(PurePosixPath(r["path"]).name[:36])}</a>'
                        f'<span class="muted"> {_E(_local(r["detected_at"], "%m/%d"))}'
                        f"</span></div>")
                badge = (f'<span class="tag removed">미확인 {st_p["unacked"]}</span>'
                         if st_p["unacked"] else
                         '<span class="tag added">확인 완료</span>')
                if today_n:
                    badge = (f'<span class="tag modified">오늘 {today_n}</span> '
                             + badge)
                pcards += (
                    f'<div class="pcard">'
                    f'<div class="pc-head"><a href="/?p={quote(p)}">{_E(p)}</a>'
                    f'<span>{badge}</span></div>'
                    f'<div class="muted" style="margin-bottom:6px">최근 변경 '
                    f'{_E(_local(st_p["last"]))}</div>'
                    f'{files}'
                    f'<a class="pc-more" href="/?p={quote(p)}">자세히 →</a></div>')
            # A그래픽+B밀도 하이브리드 (2026-08-28 시안 선택): 좌 최근 변경 표 /
            # 우 검토 필요+주요 프로젝트 — 한 화면에서 오늘 상황이 끝나게
            recent_panel = (
                f'<div class="panel"><h2>최근 변경 '
                f'<a href="/history" style="font-weight:400;font-size:11.5px;'
                f'text-transform:none;letter-spacing:0">전체 이력 →</a></h2>'
                f'<div class="scrolly">{_rows_table(rows, 100)}</div></div>')
            right_col = (issue_panel
                         + f'<div class="panel">{side_panel}</div>')
            cards_main = (
                f'<div class="grid2"><div>{recent_panel}</div>'
                f'<div>{right_col}</div></div>'
                f'<div class="panel"><h2>프로젝트별 변경사항 '
                f'<a href="/projects" style="font-weight:400;font-size:12.5px">'
                f'전체 {len(stats)}개 프로젝트 보기 →</a> · '
                f'<a href="/briefs" style="font-weight:400;font-size:12.5px">'
                f'브리핑 보기</a></h2>'
                + (f'<div class="scrolly" style="max-height:600px">'
                   f'<div class="pcards">{pcards}</div></div>' if pcards else
                   '<p class="muted">아직 변경 기록이 없습니다 — [지금 검사]로 시작하세요.</p>')
                + "</div>")

        classic_main = f"""<div class="grid2">
  <div class="panel"><h2>최근 14일 변경 추이</h2>{_trend_chart(rows)}</div>
  <div class="panel">{side_panel}</div>
</div>
<div class="panel"><h2>최근 변경
  <a href="/briefs" style="font-weight:400;font-size:12.5px;text-transform:none">무엇이 어떻게 바뀌었는지 상세 → 브리핑 보기</a></h2>
<div class="scrolly">{_rows_table(rows, 100)}</div></div>"""
        if proj:
            main_body, view_toggle = classic_main, ""
        else:
            main_body = classic_main if view == "classic" else cards_main
            def _vt(v, label):
                on = (view == v) or (v == "cards" and view != "classic")
                st = ("background:var(--accent);color:var(--on-accent)" if on
                      else "background:var(--panel2);color:var(--muted)")
                return (f'<a href="/?view={v}" style="{st};padding:7px 12px;'
                        f'border-radius:8px;font-size:12.5px;border:1px solid '
                        f'var(--border)">{label}</a>')
            view_toggle = (f'<span style="display:inline-flex;gap:4px" '
                           f'title="홈 화면 큰틀 선택 (기억됩니다)">'
                           f'{_vt("cards", "프로젝트 카드")}'
                           f'{_vt("classic", "전체 요약")}</span>')

        # 검사 중 진행률 패널 — 단계·개수·% 막대 (2026-08-28 사용자 요청:
        # "몇 개 검토 중인지, 얼마나 진행됐는지 보이게")
        scan_panel = ""
        if st_state == "scanning":
            from watcher.cli import PROGRESS
            ph, done, total = (PROGRESS["phase"], PROGRESS["done"],
                               PROGRESS["total"])
            if ph and total:
                pct = min(100, done * 100 // max(total, 1))
                prog = (
                    f'<div style="margin-top:8px"><b>{_E(ph)}</b> '
                    f'{done:,} / {total:,}개 ({pct}%)'
                    f'<div style="height:9px;border-radius:5px;'
                    f'background:var(--hover);margin-top:6px">'
                    f'<div style="height:9px;border-radius:5px;'
                    f'background:var(--link);width:{pct}%"></div></div></div>')
            elif ph:
                prog = (f'<div style="margin-top:8px"><b>{_E(ph)}</b> '
                        f'{done:,}개 검토 중… <span class="muted">(전체 수는 '
                        f'확인이 끝나면 표시됩니다)</span></div>')
            else:
                prog = ""
            scan_panel = (
                '<div class="panel" style="background:var(--note-info);'
                'color:var(--link)">검사 중입니다… (검사 항목 수에 따라 오래 '
                '걸릴 수 있습니다) 이 화면은 몇 초마다 자동 갱신됩니다.'
                + prog + '</div>')
        # 새 버전 알림 배너 (2026-08-29 — 중소기업은 인터넷 연결이 기본이라는
        # 사용자 판단으로 기본 ON, config update_check: false로 끔)
        upd = ""
        if self.update_info:
            ui = self.update_info
            upd = (f'<div class="panel" style="border-left:4px solid var(--accent)">'
                   f'⬆ <b>새 버전 v{_E(ui["version"])}</b>가 나왔습니다'
                   + (f' — {_E(ui["notes"])}' if ui.get("notes") else "")
                   + f' <form class="inline" method="post" action="/update/open">'
                     f'{self._tok()}<button class="btn" style="font-size:12px;'
                     f'padding:4px 12px">다운로드 페이지 열기</button></form> '
                     f'<span class="muted">현재 v{__version__}</span></div>')
        body = f"""
<div class="topbar"><h1>{title}{scan_state}</h1>
  <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
  {view_toggle}
  {_filter_bar('/', projects, proj)}
  <form class="inline" method="post" action="/scan">{self._tok()}
    <button class="btn" {'disabled' if st_state == 'scanning' else ''}>▶ 지금 검사</button></form></div></div>
{upd}
{scan_panel}
{_intro(f"{_E(proj)} 프로젝트의 변경과 확인 현황입니다." if proj else
        "감시 폴더에서 일어난 변경과 확인 현황의 요약입니다. 위 드롭다운으로 프로젝트 하나만 골라 볼 수 있습니다.")}
{self.scan_health()}
{'' if all_rows else '''<div class="panel"><h2>처음이신가요? 3단계면 됩니다</h2>
<p>① <a href="/settings" style="color:var(--link)"><b>설정</b></a>에서 감시 폴더가 맞는지 확인 →
② 우상단 <b>[▶ 지금 검사]</b> 클릭 (첫 검사는 기준선 — 비교의 출발점이 되는 첫 기록이라
전부 '추가'로 쌓입니다) →
③ 다음부터는 바뀐 것만 여기와 브리핑에 올라옵니다. 자세한 것은 왼쪽
<a href="/help" style="color:var(--link)"><b>도움말</b></a>.</p></div>'''}
<div class="cards">
  <div class="card" id="card-total"><div class="k">{'프로젝트 변경 누적' if proj else '감시 중인 산출물'}</div>
    <div class="v">{len(rows) if proj else self.file_count()}<small> {'건' if proj else '개'}</small></div></div>
  <div class="card" id="card-today"><div class="k">오늘 변경</div>
    <div class="v {'warn' if today_rows else ''}">{len(today_rows)}<small> 건</small></div></div>
  <a class="card" id="card-unacked" style="text-decoration:none;display:block"
     href="/history?ack=none{f'&amp;p={quote(proj)}' if proj else ''}">
    <div class="k">미확인 변경 <span class="muted">(클릭해 모아보기)</span></div>
    <div class="v {'bad' if unacked else 'ok'}">{len(unacked)}<small> 건</small></div></a>
  {fourth_card}
</div>
{issue_panel if (proj or view == "classic") else ''}
{main_body}"""
        return _page(title, "/", body, self.pop_msg(),
                     auto_refresh=(st_state == "scanning"))

    def page_issues(self, proj: str | None = None) -> str:
        all_rows = self.rows()
        projects = sorted({_proj_of(r) for r in all_rows})
        iss = self.issue_rows(proj)
        # 파일별로 묶고 근거를 표로 — 줄글 반복이 읽기 힘들다는 검토 반영 (2026-08-28)
        groups: dict[str, list] = {}
        for i in iss[:100]:
            groups.setdefault(i["path"], []).append(i)

        def _ev_rows(evs) -> str:
            out = []
            for ev in evs:
                head, sep, rest = ev.partition(": ")
                if sep and len(head) <= 30:
                    out.append(
                        f'<tr><td style="white-space:nowrap;color:var(--muted);'
                        f'font-size:12.5px;width:1%;padding-right:14px">'
                        f'{_E(head)}</td>'
                        f'<td style="font-size:13px">{_E(rest)}</td></tr>')
                else:
                    out.append(f'<tr><td colspan="2" style="font-size:13px">'
                               f'{_E(ev)}</td></tr>')
            return "".join(out)

        blocks = ""
        for path, items_ in groups.items():
            inner = ""
            for i in items_:
                inner += (
                    f'<div style="margin:12px 0 4px">'
                    f'<span class="tag removed">'
                    f'{_E(CODE_LABEL.get(i["code"], i["code"]))}</span> '
                    f'<strong>{_E(i["message"])}</strong> '
                    f'<span class="muted" style="font-size:12px">'
                    f'{_E(_local(i["detected_at"]))}</span></div>'
                    f'<table style="margin:0 0 6px">{_ev_rows(i["evidence"])}'
                    f'</table>'
                    f'<form class="inline" method="post" action="/issue/close">'
                    f'{self._tok()}'
                    f'<input type="hidden" name="id" value="{i["issue_id"]}">'
                    f'<input type="text" name="by" placeholder="처리자 이름(선택)" '
                    f'size="16">'
                    f'<button class="btn gray" style="font-size:12px;'
                    f'padding:4px 10px">처리함 (목록에서 닫기)</button></form>')
            blocks += (
                f'<div class="panel" style="border-left:4px solid var(--bad)">'
                f'<div title="{_E(path)}" style="font-size:14.5px">'
                f'{_path_html(path)} '
                f'<span class="muted" style="font-size:12px">· '
                f'{_E(items_[0]["project"] or "미분류")} · '
                f'{len(items_)}건</span></div>'
                f'{inner}</div>')
        more = (f'<p class="muted">외 {len(iss) - 100}건 생략</p>' if len(iss) > 100 else "")
        empty = ('<div class="panel"><p class="muted">검토 필요가 없습니다 — '
                 '검사에서 의심 정황이 발견되면 여기에 쌓입니다.</p></div>')
        body = (f'<div class="topbar"><h1>검토 필요{f" — {_E(proj)}" if proj else ""}'
                f' <span class="muted">누적 {len(iss)}건</span></h1>'
                + _filter_bar("/issues", projects, proj) + "</div>"
                + _intro("검사가 잡아낸 의심 정황입니다 — 내용은 바뀌었는데 Rev 그대로, "
                         "Rev만 갱신, 도면-BOM 불일치, 도면 간 사양 불일치. 근거와 함께 기록됩니다.")
                + (blocks + more if iss else empty))
        return _page("검토 필요", "/issues", body, self.pop_msg())

    def subtype_map(self) -> dict:
        custom = self.cfg().get("drawing_types")
        return custom if isinstance(custom, dict) and custom else DRAWING_SUBTYPES

    def page_cat(self, cat: str, proj: str | None = None,
                 subtype: str | None = None) -> str:
        all_rows = self.rows()
        projects = sorted({_proj_of(r) for r in all_rows})
        rows = [r for r in all_rows if _ext_category(r["path"]) == cat]
        if proj:
            rows = [r for r in rows if _proj_of(r) == proj]

        chips = ""
        if cat == "도면" and rows:
            mapping = self.subtype_map()
            counts = Counter(_drawing_subtype(r["path"], mapping) for r in rows)
            base_q = f"c={quote(cat)}" + (f"&p={quote(proj)}" if proj else "")
            def chip(label, n, t):
                on = (t or None) == (subtype or None)
                style = ("background:var(--accent);color:#fff" if on
                         else "background:var(--border);color:var(--text2)")
                href = f"/cat?{base_q}" + (f"&t={quote(t)}" if t else "")
                return (f'<a href="{href}" class="pill" '
                        f'style="{style};margin-right:6px">{_E(label)} {n}</a>')
            chips = ('<div style="margin:0 0 14px">'
                     + chip("전체", len(rows), "")
                     + "".join(chip(s, n, s) for s, n in counts.most_common())
                     + '</div>')
            if subtype:
                rows = [r for r in rows
                        if _drawing_subtype(r["path"], mapping) == subtype]

        exts = ", ".join(CATEGORIES.get(cat, ()))
        title_extra = (f' — {_E(proj)}' if proj else '') + \
                      (f' — {_E(subtype)}' if subtype else '')
        body = (f'<div class="topbar"><h1>{_E(cat)}{title_extra}'
                + f' <span class="muted">({_E(exts)})</span></h1>'
                + _filter_bar("/cat", projects, proj, extra=f"c={quote(cat)}&")
                + "</div>"
                + _intro(f"{_E(cat)} 유형 파일({_E(exts)})의 변경 기록만 모아 봅니다."
                         + (" 아래 칩으로 도면 세부 유형(파일명·폴더명 기준)을 골라 볼 수 있습니다."
                            if cat == "도면" else ""))
                + chips
                + f'<div class="panel">{_rows_table(rows, tok=self.csrf())}</div>')
        return _page(cat, f"/cat?c={cat}", body, self.pop_msg())

    def project_stats(self) -> list[dict]:
        """프로젝트별 요약 — 최근 변경 최신순 (수십 개 규모에서 '움직인 것'이 위로)."""
        by: dict[str, list[dict]] = {}
        base_id = self.baseline_id()
        for r in self.rows():
            by.setdefault(r["project"] or "미분류", []).append(r)
        out = []
        for p, rs in by.items():
            out.append({
                "name": p, "total": len(rs),
                "unacked": sum(1 for r in rs if not r["ack_by"]
                               and not self.is_baseline_add(r, base_id)),
                "last": rs[0]["detected_at"],
            })
        out.sort(key=lambda d: d["last"], reverse=True)
        return out

    def page_projects(self) -> str:
        stats = self.project_stats()
        trs = "".join(
            f"<tr data-name='{_E(p['name'].lower())}'>"
            f"<td><a href='/?p={quote(p['name'])}' "
            f"style='color:var(--link);font-weight:600'>{_E(p['name'])}</a></td>"
            f"<td>{_E(_local(p['last']))}</td>"
            f"<td>{p['total']}</td>"
            + (f"<td><span class='tag removed'>{p['unacked']}</span></td>"
               if p["unacked"] else "<td class='muted'>0</td>")
            + "</tr>"
            for p in stats)
        search = ('<input type="text" id="proj-q" placeholder="프로젝트 검색…" '
                  'oninput="pwFilterRows(this,\'proj-table\')" '
                  'style="min-width:220px">') if len(stats) > 8 else ""
        body = (f'<div class="topbar"><h1>프로젝트 '
                f'<span class="muted">{len(stats)}개</span></h1>{search}</div>'
                + _intro("최근에 움직인 프로젝트가 위로 옵니다. 이름을 클릭하면 그 프로젝트만 봅니다.")
                + '<div class="panel">'
                '<table id="proj-table"><tr><th>프로젝트</th><th>최근 변경</th>'
                f"<th>변경 누적</th><th>미확인</th></tr>{trs}</table></div>")
        return _page("프로젝트", "/projects", body)

    def page_history(self, proj: str | None = None,
                     ack_filter: str | None = None) -> str:
        all_rows = self.rows()
        projects = sorted({_proj_of(r) for r in all_rows})
        rows = [r for r in all_rows if _proj_of(r) == proj] if proj else all_rows
        bulk = ""
        if ack_filter == "none":  # 미확인 모아보기 (대시보드 카드에서 진입)
            base_id = self.baseline_id()
            rows = [r for r in rows if not r["ack_by"]]
            n_base = sum(1 for r in rows if self.is_baseline_add(r, base_id))
            base_note = (f" (기준선 첫 등록 {n_base}건 포함 — 대시보드 수치에는 "
                         f"미포함)" if n_base else "")
            title = f"미확인 변경 {len(rows)}건{base_note}"
            intro = ("아직 아무도 [확인함]을 남기지 않은 변경입니다. 파일을 클릭해 "
                     "내용을 보고 확인을 남기세요. 전부 이미 아는 변경이면 아래에서 "
                     "한 번에 확인 처리할 수 있습니다.")
            if rows:
                bulk = (f'<div class="panel"><form method="post" action="/ack/bulk">'
                        f'{self._tok()}'
                        f'<input type="hidden" name="p" value="{_E(proj or "")}">'
                        f'<input type="text" name="by" placeholder="확인자 이름" required '
                        f'style="padding:8px 10px;border:1px solid var(--border2);'
                        f'border-radius:8px">'
                        f'<input type="text" name="note" placeholder="메모(선택, 예: 기준선 정리)" '
                        f'style="padding:8px 10px;border:1px solid var(--border2);'
                        f'border-radius:8px;width:240px">'
                        f'<button class="btn">이 목록 {len(rows)}건 모두 확인 처리</button>'
                        f'<div class="muted" style="margin-top:6px">잘못 눌렀다면 각 변경에 '
                        f"'정정' 메모로 새 확인을 남겨주세요 (기록은 지워지지 않습니다)."
                        f"</div></form></div>")
        else:
            title = f"전체 이력{f' — {_E(proj)}' if proj else ''}"
            intro = "모든 변경 기록과, 누가 언제 어떤 메모로 확인했는지의 전체 이력입니다."
        # 수만 건 규모 대비: 파일명·프로젝트 즉시 검색 (2026-08-29)
        search = ('<input type="text" placeholder="파일명·프로젝트 검색…" '
                  'oninput="pwFilterRows(this,\'hist-t\')" '
                  'style="min-width:220px">') if len(rows) > 20 else ""
        xlsx_btn = (f'<a class="btn gray" style="text-decoration:none" '
                    f'href="/export{f"?p={quote(proj)}" if proj else ""}" '
                    f'title="변경·확인 이력 전체를 엑셀 파일로 저장">'
                    f'엑셀 내려받기</a>')
        body = (f'<div class="topbar"><h1>{title}</h1>'
                f'<div style="display:flex;gap:10px;align-items:center;'
                f'flex-wrap:wrap">{search}{xlsx_btn}'
                + _filter_bar("/history", projects, proj,
                              extra="ack=none&" if ack_filter == "none" else "")
                + "</div></div>" + _intro(intro)
                + f'<div class="panel">'
                f'{_rows_table(rows, 500, table_id="hist-t", tok=self.csrf())}'
                f'</div>' + bulk)
        return _page("전체 이력", "/history", body, self.pop_msg())

    def page_preview(self, key: str) -> str:
        """추출 미리보기: 파일 하나를 즉석 추출해 표제란·귀속 결과를 보여준다."""
        back = '<p><a href="/settings" style="color:var(--link)">← 설정으로</a></p>'
        if not key:
            return _page("추출 미리보기", "/settings",
                         f'<div class="topbar"><h1>추출 미리보기</h1></div>'
                         f'<div class="panel"><p>설정 화면에서 파일을 선택하세요.</p>{back}</div>')
        ap = self.abs_path_of(key)
        if ap is None:
            return _page("추출 미리보기", "/settings",
                         f'<div class="topbar"><h1>추출 미리보기</h1></div>'
                         f'<div class="panel"><p>파일을 찾을 수 없습니다: {_E(key)}</p>'
                         f'<p class="muted">최근 검사에 잡힌 경로(자동완성 목록)를 사용하세요.</p>'
                         f"{back}</div>")
        from watcher.cli import _root_labels
        from watcher.extract import extract
        from watcher.project import ProjectResolver
        cfg = self.cfg()
        try:
            doc = extract(ap)
        except Exception as e:
            return _page("추출 미리보기", "/settings",
                         f'<div class="topbar"><h1>추출 미리보기</h1></div>'
                         f'<div class="panel"><p>추출 실패: {_E(e)}</p>{back}</div>')

        resolver = ProjectResolver.load(cfg["aliases"])
        resolver.root_labels = set(_root_labels(
            [Path(r) for r in cfg["roots"] if Path(r).is_dir()]))
        proj, how = resolver.attribute(key, doc)

        if doc is None:
            body_main = ('<div class="panel"><p>이 파일 형식은 내용 추출 대상이 아닙니다 '
                         '(파일 수준 변경 감지만 됩니다).</p></div>')
        else:
            fields = "".join(
                f"<tr><td><strong>{_E(f.name)}</strong></td><td>{_E(f.value)}</td>"
                f"<td class='muted'>{_E(f.where)}</td></tr>"
                for f in doc.fields.values()) or \
                ('<tr><td colspan="3" style="color:var(--warn3)">표제란을 찾지 못했습니다 — '
                 "도면 양식이 다르면 알려주세요. 파일명·폴더 기준으로는 계속 감시됩니다."
                 "</td></tr>")
            revrows = "".join(
                f"<tr><td>{_E(r.key)}</td><td>{_E(r.values.get('DATE', ''))}</td>"
                f"<td>{_E(r.values.get('DESCRIPTION', ''))}</td></tr>"
                for r in doc.revision_history)
            sample = "".join(f"<li>{_E(t.text)} <span class='muted'>({_E(t.where)})</span></li>"
                             for t in doc.raw_texts[:8])
            n_rows = len(doc.rows)
            body_main = f"""
<div class="panel"><h2>표제란 추출 결과</h2>
<table><tr><th>필드</th><th>값</th><th>위치</th></tr>{fields}</table></div>
{f'<div class="panel"><h2>리비전 블록</h2><table><tr><th>Rev</th><th>일자</th><th>내용</th></tr>{revrows}</table></div>' if revrows else ''}
{f'<div class="panel"><h2>표 데이터</h2><p>{n_rows}행 인식 (BOM 비교 대상)</p></div>' if n_rows else ''}
<div class="panel"><h2>본문 텍스트 표본 (총 {len(doc.raw_texts)}줄)</h2>
<ul style="font-size:13px">{sample}</ul></div>"""

        body = (f'<div class="topbar"><h1>추출 미리보기</h1></div>'
                + _intro(f"{_E(key)} — 이 파일에서 무엇을 읽어내는지 보여줍니다.")
                + f'<div class="panel"><h2>소속 프로젝트 (어느 프로젝트 것인지)</h2>'
                f"<p><strong>{_E(proj)}</strong> <span class='muted'>(근거: {_E(how)})</span></p></div>"
                + body_main + f'<div class="panel">{back}</div>')
        return _page("추출 미리보기", "/settings", body)

    def page_change(self, change_id_s: str, ack_name: str = "") -> str:
        """변경 1건 상세: 무엇이 어떻게 바뀌었나 + 위치 캡처. 목록에서 클릭해 진입.
        ack_name: 최근 확인자 이름(쿠키) — 매번 재입력하지 않게 미리 채운다."""
        try:
            change_id = int(change_id_s)
        except ValueError:
            return _page("변경 상세", "/", '<div class="panel"><p>잘못된 번호입니다.</p></div>')
        with State(self.db()) as st:
            change = st.get_change(change_id)
            detail = st.change_detail(change_id)
            acks = [r for r in st.history() if r["change_id"] == change_id and r["ack_by"]]
            newx = st.load_extract(change.new_sha256) if change else None
            # 짝 상태 + 직접 지정 후보 (2026-08-29 수동 짝짓기)
            mp, prevk, prev_why, cands = None, None, "", []
            if change is not None:
                from watcher.cli import find_prev_version
                mp = st.manual_pair(change.path)
                prevk, _ps, prev_why = find_prev_version(st, change.path)
                ext0 = PurePosixPath(change.path).suffix.lower()
                par0 = str(PurePosixPath(change.path).parent)
                cands = [k for k in st._latest_rows()
                         if k != change.path
                         and PurePosixPath(k).suffix.lower() == ext0]
                # 같은 폴더 파일이 먼저 뜨게 — 대부분의 지정은 같은 폴더 안
                cands.sort(key=lambda k: (str(PurePosixPath(k).parent) != par0, k))
                cands = cands[:400]
        if change is None:
            return _page("변경 상세", "/",
                         f'<div class="panel"><p>변경 #{change_id}이 없습니다.</p></div>')
        # 실무 언어(도면번호·Rev)와 공식 개정 사유 — 표제란·리비전 블록에서
        # 이미 읽어둔 값을 앞세운다 (설계 10년차 검토, 2026-08-28)
        flds = (newx or {}).get("fields") or {}
        dwg = (flds.get("DWG_NO") or {}).get("value")
        cur_rev = (flds.get("REV") or {}).get("value")
        revhist = (newx or {}).get("revision_history") or []
        rev_note = (revhist[-1].get("values", {}).get("DESCRIPTION")
                    if revhist else None)
        # 사람이 직접 남긴 '놓친 변경' 기록 — 상세가 없어도 남길 수 있다 (미탐 대응)
        user_notes = (detail or {}).get("user_notes") or []

        if acks:
            ack_html = "".join(
                f'<span class="tag done">확인</span> {_E(a["ack_by"])}'
                + (f' — {_E(a["ack_note"])}' if a["ack_note"] else "")
                + f' <span class="muted">({_E(_local(a["ack_at"]))})</span><br>'
                for a in acks)
            ack_html += ('<form method="post" action="/change/ack" style="margin-top:10px">'
                         f'{self._tok()}'
                         f'<input type="hidden" name="id" value="{change_id}">'
                         f'<input type="text" name="by" placeholder="이름" required '
                         f'value="{_E(ack_name)}" '
                         'style="border:1px solid var(--border2);border-radius:8px;padding:6px 10px">'
                         '<input type="text" name="note" placeholder="메모(선택)" '
                         'style="border:1px solid var(--border2);border-radius:8px;padding:6px 10px;width:220px">'
                         '<button class="btn gray">확인 추가</button></form>')
        else:
            ack_html = (
                '<span class="tag na">미확인</span>'
                '<form method="post" action="/change/ack" style="margin-top:10px">'
                f'{self._tok()}'
                f'<input type="hidden" name="id" value="{change_id}">'
                f'<input type="text" name="by" placeholder="확인자 이름" required '
                f'value="{_E(ack_name)}" '
                'style="border:1px solid var(--border2);border-radius:8px;padding:8px 10px">'
                '<input type="text" name="note" placeholder="한 줄 메모 (선택)" '
                'style="border:1px solid var(--border2);border-radius:8px;padding:8px 10px;width:260px">'
                '<button class="btn">확인함</button></form>')

        if detail is None:
            reason = ("삭제된 파일은 내용 비교가 없습니다 — 마지막 판까지의 이력은 "
                      "위 파일명으로 전체 이력에서 볼 수 있습니다."
                      if change.kind == "removed" else
                      "첫 등록(기준선)이거나 내용 추출 대상이 아닌 파일입니다.")
            body_main = (f'<div class="panel"><p class="muted">이 변경에는 내용 비교 '
                         f"기록이 없습니다 — {reason}</p></div>")
        else:
            from watcher.brief import (_row_diff_table, item_diff_table,
                                       split_display_items)
            from watcher.diff import ChangeItem
            all_items = [ChangeItem(**d) for d in detail["items"]]
            caps = detail["captures"]
            dismissed = detail.get("dismissed") or {}
            idx_of = {id(it): ix for ix, it in enumerate(all_items)}
            # 오탐으로 숨긴 항목은 화면·요약에서 제외 (복원 가능 — 원칙 5)
            items = [it for ix, it in enumerate(all_items)
                     if str(ix) not in dismissed]
            hidden = [(ix, it) for ix, it in enumerate(all_items)
                      if str(ix) in dismissed]

            def cap_of(i):
                # 신형 키(kind|key) 우선, 구형 저장분은 key만으로 폴백
                return caps.get(f"{i.kind}|{i.key}") or caps.get(i.key)

            def _dismiss_form(i) -> str:
                return (f'<form class="inline" method="post" action="/change/dismiss">'
                        f'{self._tok()}'
                        f'<input type="hidden" name="id" value="{change_id}">'
                        f'<input type="hidden" name="idx" value="{idx_of[id(i)]}">'
                        f'<button class="btn gray" title="오탐이면 숨깁니다 (복원 가능)" '
                        f'style="font-size:11px;padding:2px 8px">오탐 삭제</button></form>')

            # 로컬 화면은 넉넉히(400) — '전체 볼 곳'이 여기다
            notice_items, regular, commons, row_items, omitted = \
                split_display_items(items, cap_of, 400)

            # 보고용 한 줄 요약 — 신입이 선배에게 그대로 전달할 수 있게 (2026-08-26)
            vis_n = sum(1 for i in items if i.kind == "PAGE_VISUAL_CHANGED")
            txt_n = sum(1 for i in items if i.kind.startswith("TEXT_"))
            row_n = sum(1 for i in items if i.kind.startswith("ROW_"))
            rev_i = next((i for i in items
                          if i.kind == "FIELD_CHANGED" and i.key == "REV"), None)
            parts = []
            if rev_i:
                parts.append(f"Rev {rev_i.old}→{rev_i.new}")
            if vis_n:
                parts.append(f"그림 변경 위치 {vis_n}곳")
            if txt_n:
                parts.append(f"글자 변경 {txt_n}건")
            if row_n:
                parts.append(f"표(BOM) 변경 {row_n}건")
            if user_notes:
                parts.append(f"사람 기록 {len(user_notes)}건")
            head = (f"도면 {dwg}" if dwg else PurePosixPath(change.path).name)
            summary_text = (f"{head}: "
                            f"{', '.join(parts) if parts else '내용 비교 기록 참조'}")
            if dwg:
                summary_text += f" ({PurePosixPath(change.path).name})"
            if rev_note:
                summary_text += f" · 개정 사유: {rev_note}"

            body_main = (
                f'<div class="panel"><h2>보고용 요약</h2>'
                f'<p id="chg-summary" style="margin:0">{_E(summary_text)}</p>'
                f'<button class="btn gray" style="margin-top:8px" '
                f'onclick="navigator.clipboard.writeText('
                f"document.getElementById('chg-summary').innerText)"
                f'.then(()=>this.innerText=\'복사됨\')">요약 복사</button></div>')
            body_main += "".join(
                f'<div class="panel" style="background:var(--note-warn);color:var(--warn2)">'
                f'<b>{_E(i.key)}</b> — {_E(i.new or "")}</div>'
                for i in notice_items)  # 검사 한계 고지는 안내 박스로
            if regular:
                body_main += (f'<div class="panel"><h2>바뀐 내용</h2>'
                              f'{item_diff_table(regular, cap_of=cap_of, extra_cell=_dismiss_form)}'
                              f'</div>')
            if commons:
                body_main += (f'<div class="panel"><details><summary style="cursor:pointer;'
                              f'color:var(--muted)">전 페이지 공통 변경(표제란·양식류) '
                              f'{len(commons)}건 — 펼쳐 보기</summary>'
                              f'{item_diff_table(commons, cap_of=cap_of, extra_cell=_dismiss_form)}'
                              f'</details></div>')
            if row_items:
                from watcher.brief import bom_impact_line
                body_main += (f'<div class="panel"><h2>표(BOM) 변경</h2>'
                              f"{bom_impact_line(row_items)}"
                              f"{_row_diff_table(row_items, extra_cell=_dismiss_form)}"
                              f"</div>")
            if omitted:
                body_main += (f'<div class="panel"><p class="muted">외 {omitted}건 생략 — '
                              "변경이 매우 많은 문서라 일부만 표시했습니다."
                              "</p></div>")
            if hidden:
                his = "".join(
                    f'<li style="margin:4px 0"><code>{_E(it.key)}</code>: '
                    f'{_E(it.old or "—")} &rarr; {_E(it.new or "—")} '
                    f'<span class="muted">— {_E(dismissed[str(ix)].get("by", "-"))}'
                    f' 숨김 {_E(_local(dismissed[str(ix)].get("at")))}</span> '
                    f'<form class="inline" method="post" action="/change/restore">'
                    f'{self._tok()}'
                    f'<input type="hidden" name="id" value="{change_id}">'
                    f'<input type="hidden" name="idx" value="{ix}">'
                    f'<button class="btn gray" style="font-size:11px;padding:2px 8px">'
                    f'복원</button></form></li>'
                    for ix, it in hidden)
                body_main += (f'<div class="panel"><details><summary style="cursor:pointer;'
                              f'color:var(--muted)">오탐으로 숨긴 항목 {len(hidden)}건 — '
                              f'펼쳐 보기 / 복원</summary>'
                              f'<ul style="padding-left:18px;font-size:13px">{his}</ul>'
                              f'</details></div>')
            if not body_main:
                body_main = '<div class="panel"><p class="muted">기록된 상세 항목이 없습니다.</p></div>'

        # 놓친 변경 기록 패널 — 프로그램이 인식 못한 변경을 사람이 보완한다
        # (오탐 숨김의 반대쪽. 기록은 /notes 리포트에 모여 감지 개선 신호가 된다)
        nls = "".join(
            f'<li style="margin:6px 0"><span class="tag modified">사람 기록</span> '
            f'<b>{_E(n.get("where") or "위치 미지정")}</b> — {_E(n.get("text", ""))} '
            f'<span class="muted">{_E(n.get("by", "-"))} · '
            f'{_E(_local(n.get("at")))}</span> '
            f'<form class="inline" method="post" action="/change/note/del">'
            f'{self._tok()}'
            f'<input type="hidden" name="id" value="{change_id}">'
            f'<input type="hidden" name="idx" value="{ix}">'
            f'<button class="btn gray" style="font-size:11px;padding:2px 8px">'
            f'삭제</button></form></li>'
            for ix, n in enumerate(user_notes))
        note_panel = (
            '<div class="panel"><h2>놓친 변경 기록</h2>'
            '<p class="muted" style="margin-top:0">프로그램이 잡지 못한 변경을 직접 '
            '남기는 곳입니다. 보고용 요약에 "사람 기록"으로 함께 집계되고, '
            '<a href="/notes" style="color:var(--link)">놓친 변경 리포트</a>에 모여 '
            '감지를 개선하는 신호가 됩니다.</p>'
            + (f'<ul style="padding-left:18px;font-size:13.5px">{nls}</ul>' if nls else "")
            + f'<form method="post" action="/change/note/add">{self._tok()}'
              f'<input type="hidden" name="id" value="{change_id}">'
              '<input type="text" name="where" maxlength="100" '
              'placeholder="위치 (예: 3페이지 우상단)" '
              'style="border:1px solid var(--border2);border-radius:8px;padding:6px 10px">'
              '<input type="text" name="text" maxlength="300" '
              'placeholder="무엇이 바뀌었나" required '
              'style="border:1px solid var(--border2);border-radius:8px;padding:6px 10px;'
              'width:280px">'
              '<button class="btn">기록</button></form></div>')

        # 직접 확인 도구 — 소급 재비교·민감 재비교·전/후 나란히 보기·원본 열기.
        # 감지가 놓쳐도 사람이 원본까지 갈 길을 항상 열어둔다 (미탐 대응, 2026-08-27)
        # 배치 원칙(2026-08-29): ①지금 무엇과 비교되나(상태 한 줄) ②아니면 직접
        # 지정 ③그 다음에 동작 버튼 — 보자마자 이해되는 순서로.
        if prevk:
            if mp and mp["prev_key"] == prevk:
                how = (f'<span class="tag done">사람 지정</span> '
                       f'<span class="muted">{_E(mp["by"])} · '
                       f'{_E(_local(mp["at"], "%m-%d"))}</span> '
                       f'<form class="inline" method="post" '
                       f'action="/change/pair/clear">{self._tok()}'
                       f'<input type="hidden" name="id" value="{change_id}">'
                       f'<button class="btn gray" style="font-size:11px;'
                       f'padding:2px 8px">지정 해제</button></form>')
            else:
                how = '<span class="tag na">자동 인식</span>'
            pair_line = (f'이전 판: <b title="{_E(prevk)}">'
                         f'{_E(PurePosixPath(prevk).name)}</b> {how}')
        else:
            pair_line = (f'<span style="color:var(--warn)">이전 판을 찾지 '
                         f'못했습니다</span> <span class="muted">— '
                         f'{_E(prev_why)}</span>')
        pick_form = (
            f'<form method="post" action="/change/pair/set" '
            f'style="margin-top:6px">{self._tok()}'
            f'<input type="hidden" name="id" value="{change_id}">'
            f'<input type="text" name="prev" list="prevpick" required '
            f'placeholder="이전 판 파일 선택 (입력하면 자동완성)" '
            f'style="width:420px;max-width:100%">'
            f'<datalist id="prevpick">'
            + "".join(f'<option value="{_E(k)}">' for k in cands)
            + '</datalist> <button class="btn">지정하고 비교</button>'
            f'<div class="muted" style="margin-top:4px">같은 확장자만 지정 '
            f'가능하고, 폴더가 달라도 됩니다. 잘못 지정하면 [지정 해제]로 '
            f'되돌립니다.</div></form>')
        if prevk:
            picker = (f'<details style="margin:4px 0 10px"><summary '
                      f'class="muted" style="font-size:12.5px;cursor:pointer">'
                      f'다른 파일을 이전 판으로 직접 지정</summary>'
                      f'{pick_form}</details>')
        else:
            picker = f'<div style="margin:4px 0 10px">{pick_form}</div>'
        has_pair = detail is not None and any(
            d.get("kind") == "NEW_REVISION_OF" for d in detail["items"])
        is_pdf = change.path.lower().endswith(".pdf")
        tools = []
        if change.kind in ("added", "modified"):
            if not has_pair:
                tools.append(
                    f'<form class="inline" method="post" action="/change/recompare">'
                    f'{self._tok()}'
                    f'<input type="hidden" name="id" value="{change_id}">'
                    f'<button class="btn">이전 판 찾아 다시 비교</button></form> '
                    f'<span class="muted">같은 폴더에서 이 파일의 이전 판(날짜·버전 '
                    f'표기 제외 이름 동일)을 찾아 소급 비교합니다.</span><br>')
            if is_pdf:
                tools.append(
                    f'<a class="btn gray" style="text-decoration:none" '
                    f'href="/compare?id={change_id}&amp;page=1">전/후 페이지 나란히 보기'
                    f'</a> <span class="muted">감지 결과와 무관하게 두 판의 전체 '
                    f'페이지를 눈으로 직접 비교합니다.</span><br>')
                tools.append(
                    f'<form class="inline" method="post" action="/change/recompare">'
                    f'{self._tok()}'
                    f'<input type="hidden" name="id" value="{change_id}">'
                    f'<input type="hidden" name="sens" value="1">'
                    f'<button class="btn gray">더 민감하게 다시 비교</button></form> '
                    f'<span class="muted">작은 점·가는 선 수준의 미세 변경까지 '
                    f'잡습니다 — 오탐이 늘면 [오탐 삭제]로 정리하세요.</span><br>')
            tools.append(
                f'<form class="inline" method="post" action="/change/open">'
                f'{self._tok()}'
                f'<input type="hidden" name="id" value="{change_id}">'
                f'<button class="btn gray" name="which" value="new">원본 열기</button> '
                f'<button class="btn gray" name="which" value="old">이전 판 열기</button> '
                f'<button class="btn gray" name="which" value="folder">폴더 열기</button>'
                f'</form> <span class="muted">이 PC의 기본 프로그램으로 엽니다'
                + ('.' if is_pdf else ' — DXF는 CAD에서 두 판을 직접 비교하세요.')
                + '</span>')
        recmp = (f'<div class="panel"><h2>직접 확인 도구</h2>'
                 f'<p style="margin:0 0 2px;font-size:13.5px">{pair_line}</p>'
                 f'{picker}'
                 f'<p style="line-height:2.4;margin:0">{"".join(tools)}</p></div>'
                 if tools else "")
        dwg_line = ""
        if dwg or cur_rev:
            dwg_line = (" · " + (f"도면 <b>{_E(dwg)}</b>" if dwg else "")
                        + (f" Rev <b>{_E(cur_rev)}</b>" if cur_rev else ""))
        body = (f'<div class="topbar"><h1>변경 #{change_id} {_kind_tag(change.kind)}</h1></div>'
                + _intro(f"{_E(change.path)} · 프로젝트 "
                         f"{_E(change.project or '미분류')}{dwg_line}")
                + recmp
                + f'<div class="panel"><h2>확인 상태</h2><p>{ack_html}</p>'
                  '<p class="muted" style="margin-top:8px">잘못 눌렀다면: 확인 기록은 '
                  "지워지지 않으니, '정정' 메모를 담아 새 확인을 남겨주세요.</p></div>"
                + body_main
                + note_panel
                + '<div class="panel"><p><a href="javascript:history.back()" '
                'style="color:var(--link)">← 뒤로</a></p></div>')
        return _page(f"변경 #{change_id}", "/", body, self.pop_msg())

    def export_xlsx(self, proj: str | None = None) -> bytes:
        """변경·확인 이력 전체를 엑셀로 — 주간 보고·결재 첨부용 (2026-08-29 요청).
        디스크에 쓰지 않고 메모리로 만들어 브라우저 다운로드로만 전달."""
        import io

        from openpyxl import Workbook
        with State(self.db()) as st:
            rows = st.history(proj)
            notes: dict[int, list[str]] = {}
            for n in st.list_user_notes():
                notes.setdefault(n["change_id"], []).append(
                    f'[{n.get("where") or "-"}] {n["text"]} ({n.get("by", "-")})')
        wb = Workbook()
        ws = wb.active
        ws.title = "변경 이력"
        header = ["번호", "일시", "구분", "유형", "프로젝트", "폴더", "파일명",
                  "확인자", "확인 일시", "확인 메모", "놓친 변경 기록(사람)"]
        ws.append(header)
        for r in rows:
            p = PurePosixPath(r["path"])
            ws.append([
                r["change_id"], _local(r["detected_at"]),
                KIND_LABEL.get(r["kind"], r["kind"]), _ext_category(r["path"]),
                r["project"] or "미분류", str(p.parent), p.name,
                r["ack_by"] or "", _local(r["ack_at"]) if r["ack_at"] else "",
                r["ack_note"] or "",
                " / ".join(notes.get(r["change_id"], []))])
        widths = [7, 17, 8, 8, 14, 40, 34, 10, 17, 24, 30]
        for i, w in enumerate(widths, 1):
            ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
        buf = io.BytesIO()
        wb.save(buf)
        return buf.getvalue()

    def page_tags(self, q: str) -> str:
        """태그·텍스트 검색 — "M-101 어디 들어가지?"에 답한다 (2026-08-29 설계
        검토 반영). 최신 스냅샷의 추출 내용 전체에서 찾고, 결과에서 바로 열기."""
        q = (q or "").strip()
        form = (f'<form method="get" action="/tags" class="inline">'
                f'<input type="text" name="q" value="{_E(q)}" '
                f'placeholder="태그·텍스트 (예: M-101, 5.5kW)" '
                f'style="min-width:260px" autofocus>'
                f'<button class="btn">검색</button></form>')
        head = (f'<div class="topbar"><h1>태그 검색</h1>{form}</div>'
                + _intro("기기 태그(M-101)나 아무 텍스트를 넣으면, 그 글자가 든 "
                         "도면·BOM·문서를 감시 대상 전체에서 찾아줍니다. 파일을 "
                         "바로 열거나 변경 이력으로 이동할 수 있습니다."))
        if not q:
            return _page("태그 검색", "/tags", head
                         + '<div class="panel"><p class="muted">예: 모터 태그 '
                           '<code>M-101</code>을 검색하면 그 태그가 등장하는 '
                           '도면과 BOM이 전부 나옵니다 — 사양 변경 때 영향 범위 '
                           '확인용.</p></div>', self.pop_msg())
        ql = q.lower()
        results = []
        with State(self.db()) as st:
            rows = st._latest_rows()
            cur = {v[0] for v in rows.values()}
            hit = set()
            for pat in {q, q.upper(), q.lower()}:  # sqlite LIKE는 영문만 대소문자 무시
                hit |= {s for (s,) in st.conn.execute(
                    "SELECT sha256 FROM extracts WHERE doc_json LIKE ?",
                    (f"%{pat}%",)) if s in cur}
            for path, (sha, _sz, _mt) in rows.items():
                name_hit = ql in path.lower()   # 파일명·폴더명 일치도 검색
                n, first = 0, ""
                if sha in hit:
                    d = st.load_extract(sha)
                    if d and d.get("fields") is not None:
                        lines = [t["text"] for t in d.get("raw_texts", [])
                                 if ql in t["text"].lower()]
                        n = (len(lines)
                             + sum(1 for f in d.get("fields", {}).values()
                                   if ql in str(f.get("value", "")).lower())
                             + sum(1 for r0 in d.get("rows", [])
                                   if ql in str(r0.get("values", "")).lower()
                                   or ql in str(r0.get("key", "")).lower()))
                        first = lines[0] if lines else ""
                if not n and not name_hit:
                    continue
                c = st.conn.execute(
                    "SELECT id FROM changes WHERE path=? ORDER BY id DESC LIMIT 1",
                    (path,)).fetchone()
                results.append((path, n, first, c[0] if c else None))
        # 내용 일치(출현 많은 순)가 먼저, 파일명만 일치는 뒤에
        results.sort(key=lambda r: (-r[1], r[0]))
        tok = self.csrf()
        trs = "".join(
            f'<tr><td title="{_E(path)}">'
            + (f"<a href='/change?id={cid}' style='color:var(--link)'>"
               f"{_path_html(path)}</a>" if cid else _path_html(path))
            + f'</td><td>{_E(_ext_category(path))}</td>'
            f'<td style="text-align:right;white-space:nowrap">'
            + (f'{n}곳' if n else '<span class="muted">파일명 일치</span>') + '</td>'
            f'<td class="muted" style="max-width:380px;overflow:hidden;'
            f'text-overflow:ellipsis;white-space:nowrap">{_E(first[:80])}</td>'
            + _open_cell(path, tok) + "</tr>"
            for path, n, first, cid in results[:200])
        if results:
            body = (f'<div class="panel"><h2>"{_E(q)}" — 파일 {len(results)}개에서 '
                    f'발견' + (' <span class="muted">(상위 200개 표시)</span>'
                              if len(results) > 200 else '') + '</h2>'
                    f'<div class="scrolly" style="max-height:560px">'
                    f'<table><tr><th>파일</th><th>유형</th><th>출현</th>'
                    f'<th>첫 일치 내용</th><th></th></tr>{trs}</table></div></div>')
        else:
            body = (f'<div class="panel"><p class="muted">"{_E(q)}"를 찾지 '
                    f'못했습니다 — 파일명과 추출된 내용(DXF·PDF·XLSX)에서 '
                    f'찾습니다. 검사가 아직 안 돈 파일은 검색되지 않습니다.'
                    f'</p></div>')
        return _page("태그 검색", "/tags", head + body, self.pop_msg())

    def page_compare(self, change_id_s: str, page_s: str) -> str:
        """전/후 전체 페이지 나란히 보기 (PDF 전용) — 감지가 놓친 변경도 사람이
        직접 훑을 수 있는 안전망 (미탐 대응, 2026-08-27)."""
        try:
            change_id = int(change_id_s)
        except ValueError:
            return _page("나란히 보기", "/",
                         '<div class="panel"><p>잘못된 번호입니다.</p></div>')
        try:
            pno = max(1, int(page_s or "1"))
        except ValueError:
            pno = 1
        with State(self.db()) as st:
            change = st.get_change(change_id)
            if change is None:
                return _page("나란히 보기", "/",
                             f'<div class="panel"><p>변경 #{change_id}이 없습니다.</p></div>')
            from watcher.cli import find_prev_version
            prev_key, _sha, why = find_prev_version(st, change.path)
        head = ('<div class="topbar"><h1>전/후 나란히 보기</h1></div>'
                + _intro(_E(change.path)))
        back = (f'<div class="panel"><p><a href="/change?id={change_id}" '
                f'style="color:var(--link)">← 변경 상세로</a></p></div>')

        def _fail(msg: str) -> str:
            return _page("나란히 보기", "/",
                         head + f'<div class="panel"><p>{msg}</p></div>' + back)

        if not change.path.lower().endswith(".pdf"):
            return _fail("나란히 보기는 PDF만 지원합니다 — DXF 도면은 변경 상세의 "
                         "[원본 열기]·[이전 판 열기]로 CAD에서 직접 비교해주세요.")
        if prev_key is None:
            return _fail(_E(why))
        new_abs = self.abs_path_of(change.path)
        prev_abs = self.abs_path_of(prev_key)
        if new_abs is None or prev_abs is None:
            return _fail("원본 파일에 접근할 수 없습니다 — 감시 폴더 연결(네트워크 "
                         "드라이브 등)을 확인해주세요.")
        from watcher.capture import page_pair_images
        res = page_pair_images(prev_abs, new_abs, pno)
        if res is None and pno > 1:            # 범위 밖이면 1페이지로 되돌려 시도
            pno, res = 1, page_pair_images(prev_abs, new_abs, 1)
        if res is None:
            return _fail("페이지를 그리지 못했습니다 — 파일이 잠겨 있거나 손상됐을 "
                         "수 있습니다. 잠시 후 다시 시도해주세요.")
        b64_old, b64_new, total = res

        def _nav(p: int, label: str) -> str:
            if 1 <= p <= total:
                return (f'<a class="btn gray" style="text-decoration:none" '
                        f'href="/compare?id={change_id}&amp;page={p}">{label}</a>')
            return f'<span class="btn gray" style="opacity:.4">{label}</span>'

        pager = (f'<div class="panel" style="text-align:center">'
                 f'{_nav(pno - 1, "← 이전 페이지")} '
                 f'<b style="margin:0 10px">{pno} / {total} 페이지</b> '
                 f'{_nav(pno + 1, "다음 페이지 →")}</div>')

        def _col(title: str, name: str, b64: str) -> str:
            return (f'<div style="flex:1;min-width:320px">'
                    f'<h2 style="font-size:14px;margin-top:0">{title} '
                    f'<span class="muted">{_E(name)}</span></h2>'
                    f'<img src="data:image/jpeg;base64,{b64}" alt="{title}" '
                    f'style="width:100%;border:1px solid var(--border2);'
                    f'border-radius:8px"></div>')

        body = (head + pager
                + '<div class="panel"><div style="display:flex;gap:14px;flex-wrap:wrap">'
                + _col("이전 판", PurePosixPath(prev_key).name, b64_old)
                + _col("새 판", PurePosixPath(change.path).name, b64_new)
                + '</div><p class="muted" style="margin-bottom:0">같은 페이지를 두 판에서 '
                  '그대로 그린 그림입니다 — 감지 결과와 무관하게 전체를 직접 비교할 수 '
                  '있습니다. 놓친 변경을 찾으면 변경 상세의 [놓친 변경 기록]에 '
                  '남겨주세요.</p></div>'
                + back)
        return _page(f"나란히 보기 #{change_id}", "/", body)

    def page_notes(self) -> str:
        """놓친 변경 리포트 — 사람이 보완한 기록을 모아, 어떤 파일·양식에서
        프로그램이 자주 놓치는지 본다 (미탐 대응, 2026-08-27)."""
        with State(self.db()) as st:
            notes = st.list_user_notes()
        head = ('<div class="topbar"><h1>놓친 변경 리포트</h1></div>'
                + _intro("프로그램이 못 잡아 사람이 직접 남긴 변경 기록입니다. "
                         "특정 양식·확장자에 몰려 있으면 그쪽 감지를 보완할 신호입니다."))
        if not notes:
            return _page("놓친 변경 리포트", "/", head
                         + '<div class="panel"><p class="muted">아직 기록이 없습니다 — '
                           '변경 상세 화면의 [놓친 변경 기록]에서 남길 수 있습니다.'
                           '</p></div>')
        by_ext = Counter(PurePosixPath(n["path"]).suffix.lower() or "(확장자 없음)"
                         for n in notes)
        by_proj = Counter(n["project"] for n in notes)
        stat = ('<div class="panel"><h2>어디서 많이 놓쳤나</h2>'
                '<p style="margin:6px 0">파일 유형: '
                + " ".join(f'<span class="tag na">{_E(k)} {v}건</span>'
                           for k, v in by_ext.most_common())
                + '</p><p style="margin:6px 0">프로젝트: '
                + " ".join(f'<span class="tag done">{_E(k)} {v}건</span>'
                           for k, v in by_proj.most_common(10))
                + '</p></div>')
        trs = "".join(
            f'<tr><td class="muted" style="white-space:nowrap">'
            f'{_E(_local(n.get("at")))}</td>'
            f'<td><a href="/change?id={n["change_id"]}" style="color:var(--link)">'
            f'{_E(PurePosixPath(n["path"]).name)}</a><br>'
            f'<span class="muted">{_E(n["project"])}</span></td>'
            f'<td><b>{_E(n.get("where") or "위치 미지정")}</b> — '
            f'{_E(n.get("text", ""))}<br>'
            f'<span class="muted">{_E(n.get("by", "-"))}</span></td></tr>'
            for n in notes[:200])
        table = (f'<div class="panel"><h2>기록 {len(notes)}건'
                 + (f' <span class="muted">(최근 200건 표시)</span>'
                    if len(notes) > 200 else '')
                 + f'</h2><table><tr><th>언제</th><th>파일</th><th>내용</th></tr>'
                 f'{trs}</table></div>')
        return _page("놓친 변경 리포트", "/", head + stat + table, self.pop_msg())

    def brief_dir(self) -> Path:
        return Path(self.cfg()["brief_dir"])

    def page_briefs(self) -> str:
        """브리핑 목록: 클릭하면 화면에서 바로 열린다."""
        bdir = self.brief_dir()
        briefs = sorted(bdir.glob("brief_*.html"),
                        key=lambda p: p.stat().st_mtime, reverse=True) \
            if bdir.is_dir() else []
        trs = []
        for b in briefs[:120]:
            stem = b.stem[len("brief_"):]          # 20260822_P24-031 | weekly_20260824
            weekly = stem.startswith("weekly_")
            if weekly:
                stem = stem[len("weekly_"):]
            date_s, _, proj = stem.partition("_")
            date_fmt = f"{date_s[:4]}-{date_s[4:6]}-{date_s[6:8]}" if len(date_s) == 8 else date_s
            label = (f"{date_fmt} 주간 요약" if weekly else
                     f"{date_fmt}{' — ' + proj if proj else ' (변경 없음 기록)'}")
            size_mb = b.stat().st_size / 1e6
            size_s = (f"{size_mb:.1f}MB · 사진 포함, 여는 데 잠시 걸림"
                      if size_mb >= 1 else f"{b.stat().st_size // 1024}KB")
            trs.append(
                f"<tr><td><a href='/brief?f={quote(b.name)}' "
                f"style='color:var(--link);font-weight:600'>{_E(label)}</a></td>"
                f"<td class='muted'>{_E(size_s)}</td>"
                f"<td class='muted'>{_E(b.name)}</td></tr>")
        table = ("<table><tr><th>브리핑</th><th>크기</th><th>파일명</th></tr>"
                 + "".join(trs) + "</table>") if trs else \
            '<p class="muted">아직 브리핑이 없습니다 — [지금 검사]를 실행하면 생깁니다.</p>'
        body = ('<div class="topbar"><h1>브리핑</h1></div>'
                + _intro("검사 때마다 만들어지는 일일 보고서입니다. 클릭하면 바로 열립니다 — "
                         "변경 상세(무엇이 어떻게 바뀌었나)와 위치 캡처는 여기에 있습니다.")
                + f'<div class="panel">{table}</div>')
        return _page("브리핑", "/briefs", body)

    def brief_file(self, name: str) -> Path | None:
        """브리핑 파일 안전 확인 (폴더 밖 접근 차단)."""
        if not name.startswith("brief_") or not name.endswith(".html") or "/" in name \
                or "\\" in name or ".." in name:
            return None
        p = self.brief_dir() / name
        return p if p.is_file() else None

    def _attributions(self) -> dict:
        """최신 스냅샷의 각 파일 → (프로젝트, 근거). 표제란은 저장된 추출본에서 읽는다."""
        from watcher.cli import _root_labels
        from watcher.extract.base import Document
        from watcher.project import ProjectResolver
        if not self.db().exists():
            return {}
        cfg = self.cfg()
        resolver = ProjectResolver.load(cfg["aliases"])
        resolver.root_labels = set(_root_labels(
            [Path(r) for r in cfg["roots"] if Path(r).is_dir()]))
        out = {}
        with State(self.db()) as st:
            snap = st.latest_snapshot()  # {path: sha256}
            for path, sha in snap.items():
                ed = st.load_extract(sha)
                doc = Document.from_dict(ed) if ed else None
                out[path] = resolver.attribute(path, doc)
        return out

    def page_organize(self) -> str:
        from watcher.cli import _root_labels
        from watcher.organize import suggest_moves
        from watcher.project import ProjectResolver
        cfg = self.cfg()
        resolver = ProjectResolver.load(cfg["aliases"])
        labels = set(_root_labels([Path(r) for r in cfg["roots"] if Path(r).is_dir()]))
        resolver.root_labels = labels

        def folder_project(folder: str):
            # 폴더 경로에 '실제 프로젝트 번호/별칭'이 있을 때만 그 프로젝트로 인정.
            # (폴더구조 폴백은 자기 이름을 반환해 순환·소음이 되므로 쓰지 않는다)
            return resolver._find_in(folder)

        sugg = suggest_moves(self._attributions(), folder_project)
        if sugg:
            rows = "".join(
                f'<tr><td>{_E(PurePosixPath(s.path).name)}</td>'
                f'<td><span class="tag done">{_E(s.project)}</span> '
                f'<span class="muted">({_E(s.how)})</span></td>'
                f'<td>{_E(s.current_folder)}</td>'
                f'<td class="muted">{_E(s.reason)}</td></tr>'
                for s in sugg)
            table = ('<table><tr><th>파일</th><th>소속 프로젝트</th>'
                     f'<th>현재 폴더</th><th>제안</th></tr>{rows}</table>')
        else:
            table = ('<p class="muted">정리 제안이 없습니다 — 소속이 확실한 파일들이 '
                     '모두 제 프로젝트 폴더에 있거나, 아직 판단 근거가 부족합니다.</p>')
        body = ('<div class="topbar"><h1>정리 제안</h1></div>'
                + _intro("표제란·파일명으로 프로젝트가 확실한데 다른 폴더에 있는 파일을 "
                         "알려드립니다. 옮기는 것은 담당자가 판단해 직접 하세요 — "
                         "이 프로그램은 파일을 절대 이동·수정하지 않습니다.")
                + f'<div class="panel">{table}</div>'
                + '<div class="panel"><p class="muted">왜 제안만 하나요? 자동으로 옮기면 '
                  '오분류 한 건이 도면 유실 사고가 됩니다. 근거를 보고 사람이 결정하는 것이 '
                  '안전합니다.</p></div>')
        return _page("정리 제안", "/organize", body)

    def page_help(self) -> str:
        body = """
<div class="topbar"><h1>도움말</h1></div>
{intro}
<div class="panel">
<p style="font-size:14.5px;line-height:1.7;margin:0">지정한 폴더를 하루 1회 훑어
<b>도면·BOM·문서가 무엇이 어떻게 바뀌었는지</b>를 근거(전/후 사진)와 함께 정리합니다.<br>
파일은 <b>읽기만</b> 하고 절대 수정하지 않으며, 회사 밖으로 내보내지 않습니다.
평소처럼 폴더에 저장만 하면 됩니다.</p></div>

<div class="panel"><h2>아침 5분, 이렇게 쓰세요</h2>
<div class="steps">
  <div class="step"><b>1</b><div><strong>대시보드 확인</strong><br>
    "마지막 검사" 시각과 <span style="color:var(--bad)">미확인 변경</span> 숫자를 봅니다.
    0건이면 끝 — 감시는 정상입니다.</div></div>
  <div class="step"><b>2</b><div><strong>변경 클릭 → 내용 보기</strong><br>
    파일명을 클릭하면 무엇이 어떻게 바뀌었는지 전/후 사진과 함께 나옵니다.
    잘못 잡힌 항목은 [오탐 삭제]로 걷어냅니다.</div></div>
  <div class="step"><b>3</b><div><strong>[확인함] 남기기</strong><br>
    봤다는 기록입니다. [요약 복사]로 팀에 전달하면 보고 끝.</div></div>
</div></div>

<div class="panel"><h2>자주 묻는 질문</h2>
<details class="qa"><summary>변경이 0건인데 고장인가요?</summary>
<p>대시보드의 "마지막 검사" 시각이 최근이면 정상입니다 — 정말 변경이 없었던 것입니다.
브리핑에도 "파일 N개 검사함"이 남습니다.</p></details>
<details class="qa"><summary>두 판을 넣었는데 비교가 안 돼요</summary>
<p>같은 도면의 판으로 인정되는 조건은 3가지입니다:</p>
<ul><li>① <b>같은 폴더</b>에 있고</li>
<li>② <b>같은 확장자</b>(PDF↔PDF, DXF↔DXF)이고</li>
<li>③ 날짜·버전 표기(<code>_20260827</code>, <code>_rev2</code>, <code>(2차수정)</code>,
<code>_승인용</code> 등)를 뺀 <b>이름이 같아야</b> 합니다.</li></ul>
<p>여러 판을 한꺼번에 넣어도 날짜순으로 줄줄이 비교됩니다. 비교가 빠진 판은
그 변경을 클릭해 <b>[이전 판 찾아 다시 비교]</b> 버튼을 누르면 소급 비교됩니다.</p></details>
<details class="qa"><summary>"미분류" 프로젝트가 떠요</summary>
<p>어느 프로젝트 것인지 판정 못 한 파일입니다. 폴더명이 프로젝트 번호와 다르면 생깁니다 —
aliases.yaml에 별칭 한 줄을 추가하면 해결됩니다 (예: <code>P24-031: [SKH M15]</code>).</p></details>
<details class="qa"><summary>[확인함]·[처리함]·[오탐 삭제]를 잘못 눌렀어요</summary>
<p>오탐 삭제는 화면 아래 "숨긴 항목"에서 <b>[복원]</b>하면 됩니다.
확인 기록은 지워지지 않으니 '정정' 메모로 새 확인을 남기세요.
검토 필요(처리함)는 같은 내용이 재검출되면 다시 올라옵니다.</p></details>
<details class="qa"><summary>글자를 못 읽는다는 PDF가 있어요</summary>
<p>CAD에서 SHX 글꼴로 출력한 PDF나 스캔본은 글자가 그림이라 내용 비교가 안 됩니다
(화면에 그 사유가 표시됩니다). CAD에서 TrueType 글꼴로 다시 출력하거나 DXF를 쓰면 됩니다.</p></details>
</div>

<div class="panel"><h2>용어 한 장</h2>
<dl class="terms">
<dt>Rev</dt><dd>도면 개정 번호 — 표제란의 REV 칸(A, B, C…). 고쳐서 다시 내면 올라갑니다</dd>
<dt>표제란</dt><dd>도면 우하단 정보 칸 — 도면번호·프로젝트·Rev·날짜</dd>
<dt>BOM</dt><dd>자재 목록표 — 어떤 부품이 몇 개 들어가는지 적은 엑셀</dd>
<dt>태그</dt><dd>기기 이름표 — M-101(모터), X-12(릴레이)처럼 도면과 BOM을 잇는 식별자</dd>
<dt>기준선</dt><dd>비교의 출발점이 되는 첫 기록 — 첫 검사 때 전부 '추가'로 쌓이는 이유</dd>
<dt><span class="tag added">추가</span></dt><dd>처음 나타난 파일</dd>
<dt><span class="tag modified">수정</span></dt><dd>내용이 바뀐 파일 — 이전 판과 비교됩니다</dd>
<dt><span class="tag removed">삭제</span></dt><dd>사라진 파일</dd>
<dt><span class="tag na">미확인</span></dt><dd>아직 아무도 [확인함]을 안 남긴 변경</dd>
<dt>검토 필요</dt><dd>의심 정황 — Rev 미갱신, 도면-BOM 불일치 등. 처리 후 [처리함]으로 닫습니다</dd>
</dl></div>

<div class="panel"><h2>관리자용</h2>
<details class="qa"><summary>자동 실행이 잘 도는지 확인하려면</summary>
<p>대시보드 맨 위 "마지막 검사" 시각을 보세요 — 25시간 넘게 없으면 노란 경고가 뜹니다.
실행 기록은 프로그램 폴더의 run_daily.log. 자동 실행은 그 시각에 PC가 켜져 있고
로그인돼 있어야 돌며, 꺼져 있던 날은 건너뜁니다.</p></details>
<details class="qa"><summary>네트워크 폴더 감시</summary>
<p>자동 실행에서도 접근되려면 Z: 드라이브 문자 대신 &#92;&#92;서버&#92;공유 형식(UNC)으로
등록하세요. "폴더를 찾을 수 없습니다"는 오타 또는 접근 권한 문제입니다.</p></details>
<details class="qa"><summary>메일로 브리핑 보내기</summary>
<p>검사 후 briefs 폴더의 <b>.eml 파일을 더블클릭</b>하면 받는사람·본문·사진이 채워진
메일 초안이 열립니다 — [보내기]만 누르면 됩니다. 프로그램이 스스로 보내지 않습니다.</p></details>
<details class="qa"><summary>백업 · 다른 PC로 이전</summary>
<p><code>config.yaml</code>(설정)과 <code>state.db</code>(모든 이력) 두 파일만 복사하면
끝입니다. 검사 중이 아닐 때 복사하세요.</p></details>
<details class="qa"><summary>오래된 기록 정리</summary>
<p>보존 기간(설정, 기본 90일)이 지나면 전/후 사진·상세만 지워지고
변경 이력·확인 기록은 계속 남습니다.</p></details>
<details class="qa"><summary>프로그램이 변경을 놓쳤을 때</summary>
<p>변경 상세 화면에서 ①[더 민감하게 다시 비교]로 미세 변경까지 재검사,
②[전/후 페이지 나란히 보기]로 두 판을 눈으로 직접 비교, ③그래도 못 잡은 변경은
[놓친 변경 기록]에 남기세요. 기록은 <a href="/notes">놓친 변경 리포트</a>에 모여
어떤 양식에서 자주 놓치는지 보여줍니다.</p></details>
</div>

<div class="panel"><h2>알아두면 좋은 규칙</h2>
<ul style="line-height:1.9;padding-left:20px;margin:0">
<li><b>날짜 붙인 새 파일도 비교됩니다</b> — 판넬도면_20260810 옆에 _20260820을 넣으면
같은 도면의 새 판으로 자동 인식. 띄어쓰기·대소문자 차이는 무시합니다</li>
<li><b>이름이 달라 못 엮은 판은 직접 지정</b> — 변경 상세의 [이전 판 직접 지정]에서
파일을 골라 짝지으면 즉시 비교되고, 이후에도 그 짝을 기억합니다</li>
<li><b>폴더가 곧 프로젝트</b> — 감시 폴더 바로 아래 첫 폴더명이 프로젝트가 되고,
표제란에 프로젝트 번호가 있으면 그게 우선</li>
<li><b>저장만 누른 파일은 안 잡힙니다</b> — 내용이 실제로 바뀌어야 변경</li>
<li><b>잠긴 파일은 건너뜁니다</b> — "읽지 못한 파일"로 표시되고 다음 검사에서 재시도.
삭제로 오인하지 않습니다</li>
</ul></div>"""
        return _page("도움말", "/help", body.format(
            intro=_intro("궁금한 항목을 클릭해 펼쳐 보세요.")))

    def page_settings(self) -> str:
        cfg = self.cfg()
        roots = list(cfg["roots"])
        multi = len(roots) > 1
        root_rows = "".join(
            f"<tr><td>{_E(r)}</td>"
            f"<td class='muted' style='width:120px'>{_E(Path(r).name) if multi else ''}</td>"
            f"<td style='width:90px'>"
            + (f'<form class="inline" method="post" action="/root/del">{self._tok()}'
               f'<input type="hidden" name="path" value="{_E(r)}">'
               f'<button class="btn gray">제거</button></form>' if len(roots) > 1 else
               '<span class="muted">최소 1개</span>')
            + "</td></tr>"
            for r in roots) or '<tr><td class="muted" colspan="3">감시 폴더 없음</td></tr>'
        excludes = list(cfg.get("exclude") or [])
        items = "".join(
            f"<tr><td>{_E(e)}</td><td style='width:90px'>"
            f'<form class="inline" method="post" action="/exclude/del">{self._tok()}'
            f'<input type="hidden" name="path" value="{_E(e)}">'
            f'<button class="btn gray">제거</button></form></td></tr>'
            for e in excludes) or '<tr><td class="muted" colspan="2">제외 폴더 없음</td></tr>'
        mail = cfg.get("mail") or {}
        from watcher.cli import schedule_status
        sched_on = schedule_status()
        extractable = (".dxf", ".pdf", ".xlsx")
        # 감시 확장자 편집 — PLC(.gxw 등)·한글(.hwp) 등 어떤 형식이든 파일 수준
        # 감시는 사용자가 직접 추가 (2026-08-29 PLC 검토에서 범용화)
        exts = [str(e).lower() for e in (cfg.get("extensions") or [])]
        ext_rows = "".join(
            f"<tr><td><code>{_E(e)}</code> "
            + ('<span class="muted">(내용 비교 지원)</span>' if e in extractable
               else '<span class="muted">(변경 사실만 감지)</span>')
            + "</td><td style='width:90px'>"
            + (f'<form class="inline" method="post" action="/ext/del">{self._tok()}'
               f'<input type="hidden" name="ext" value="{_E(e)}">'
               f'<button class="btn gray">제거</button></form>' if len(exts) > 1
               else '<span class="muted">최소 1개</span>')
            + "</td></tr>" for e in exts)
        snap_paths = []
        if self.db().exists():
            with State(self.db()) as st:
                snap_paths = sorted(st.latest_snapshot().keys())
        file_options = "".join(
            f'<option value="{_E(p)}">' for p in snap_paths
            if PurePosixPath(p).suffix.lower() in extractable)[:40000]
        body = f"""
<div class="topbar"><h1>설정</h1></div>
{_intro("무엇을 감시할지 정합니다. 저장 즉시 적용되며, 감시 대상 파일은 절대 수정되지 않습니다.")}
<div class="panel"><h2>감시 폴더 (각각 추가/제거 가능)</h2>
<table><tr><th>경로</th><th>{'구분 라벨' if multi else ''}</th><th></th></tr>{root_rows}</table>
<form method="post" action="/root/add" style="margin-top:10px">{self._tok()}
  <input type="text" name="path" placeholder="폴더 전체 경로 (예: \\\\fileserver\\도면서버 또는 D:\\projects)" size="46">
  <button class="btn">폴더 추가</button></form>
<p class="muted" style="margin-top:6px">여러 폴더를 감시하면 경로 앞에 폴더명 라벨이 붙어 구분됩니다.
폴더를 제거하면 그 안의 파일들은 삭제 알림 없이 감시에서만 빠집니다. 다음 검사부터 적용.</p></div>
<div class="panel"><h2>감시 제외 폴더 (하위 포함 감시 안 함)</h2>
<table>{items}</table>
<form method="post" action="/exclude/add" style="margin-top:10px">{self._tok()}
  <input type="text" name="path" placeholder="루트 기준 폴더 경로 (예: 참고자료/외주)" size="42">
  <button class="btn">추가</button></form>
<p class="muted" style="margin-top:6px">다음 검사부터 적용됩니다. 제외해도 삭제 알림은
나가지 않고, 감시에서만 조용히 빠집니다. 경로는 / 또는 \\ 어느 쪽으로 적어도 됩니다.</p></div>
<div class="panel"><h2>감시 확장자 (어떤 파일을 지켜볼지)</h2>
<table>{ext_rows}</table>
<form method="post" action="/ext/add" style="margin-top:10px">{self._tok()}
  <input type="text" name="ext" placeholder="예: gxw (PLC), hwp — 점은 안 붙여도 됨" size="30">
  <button class="btn">추가</button></form>
<p class="muted" style="margin-top:6px">내용 비교(글자·표·그림 diff)는 dxf·pdf·xlsx만 지원합니다.
그 외 확장자는 <b>"언제 어떤 파일이 바뀌었나"</b>(파일 수준)와 판 짝짓기·이력·확인·바로 열기가
됩니다 — PLC 프로그램(.gxw, .xgwx 등)도 이렇게 감시할 수 있습니다. 다음 검사부터 적용.</p></div>
<div class="panel"><h2>매일 자동 실행 (아침 브리핑)</h2>
<p style="margin-top:0">현재 상태: {'<span class="tag done">등록됨</span> 매일 07:00 자동 검사'
                                    if sched_on else '<span class="tag na">등록 안 됨</span> — 지금은 [지금 검사]를 눌러야만 검사합니다'}</p>
{'<form class="inline" method="post" action="/schedule/unregister">' + self._tok()
 + '<button class="btn gray">자동 실행 해제</button></form>' if sched_on else
 '<form class="inline" method="post" action="/schedule/register">' + self._tok()
 + '<button class="btn">매일 07:00 자동 실행 등록</button></form>'}
<p class="muted" style="margin-top:6px">등록하면 Windows 작업 스케줄러에 ProjectWatcher 작업이 생깁니다.
그 시각에 <b>PC가 켜져 있고 로그인된 상태</b>여야 실행되며, 꺼져 있던 날은 건너뜁니다.
실행 기록은 이 폴더의 run_daily.log에 남습니다. 감시 폴더가 네트워크 위치면
Z:\\ 드라이브 문자 대신 \\\\서버\\공유 형식으로 등록해야 자동 실행에서도 접근됩니다.</p></div>
<div class="panel"><h2>업데이트</h2>
<p style="margin-top:0">현재 버전: <b>v{__version__}</b>
{f' · <span style="color:var(--accent)">새 버전 v{_E(self.update_info["version"])} 사용 가능 — 대시보드 배너에서 여세요</span>' if self.update_info else ''}</p>
<form class="inline" method="post" action="/update/check">{self._tok()}
  <button class="btn gray">지금 업데이트 확인</button></form>
<p class="muted" style="margin-top:6px">시작할 때 자동으로 확인합니다 — 버전 숫자 파일
하나만 조회하며 도면·사용 정보는 아무것도 보내지 않습니다. 끄려면 config.yaml에
<code>update_check: false</code>.</p></div>
<div class="panel"><h2>프로그램 종료</h2>
<form class="inline" method="post" action="/quit">{self._tok()}
  <button class="btn red">프로그램 종료</button></form>
<p class="muted" style="margin-top:6px">창을 닫는 것과 같습니다 — 검사 중이어도 즉시
종료되며, 진행분은 저장되어 다음 검사가 이어받습니다.</p></div>
<div class="panel"><h2>기록 보존 기간</h2>
<form method="post" action="/retention/save">{self._tok()}
  변경 상세(전/후 사진 포함)를
  <input type="text" name="days" size="5" value="{int(cfg.get('retention_days', 90) or 0)}">
  일 지나면 정리 (0 = 정리 안 함)
  <button class="btn gray">저장</button></form>
<p class="muted" style="margin-top:6px">지워지는 것: 기간이 지난 변경의 전/후 사진·상세
목록, 옛 검사들의 파일 목록. <b>남는 것: 변경 이력·확인 기록·최신 검사 상태</b> —
오래 잠자던 파일이 나중에 바뀌어도 감지·비교는 그대로 됩니다.</p></div>
<div class="panel"><h2>추출 미리보기 (온보딩 점검)</h2>
<p class="muted" style="margin-top:0">도면을 하나 골라, 표제란 추출과 소속 프로젝트 판정이
제대로 되는지 그 자리에서 확인합니다. 파일럿 첫날 고객 도면으로 꼭 한 번 돌려보세요.</p>
<form method="get" action="/preview" style="margin-top:8px">
  <input type="text" name="path" list="files" size="52"
    placeholder="파일 선택 또는 경로 입력 (예: 도면서버/P24-031/DWG-E-001.dxf)">
  <datalist id="files">{file_options}</datalist>
  <button class="btn">추출해 보기</button></form></div>
<div class="panel"><h2>메일 브리핑</h2>
<p style="margin-top:0"><b>기본 방식 — 메일 초안 만들기:</b> 검사가 끝나면 briefs 폴더에
브리핑 HTML과 함께 <b>메일 초안 파일(.eml)</b>이 만들어집니다. 더블클릭하면 Outlook 등
메일 프로그램에서 받는사람·제목·본문(사진 포함)이 채워진 채 열리고, <b>[보내기]만 누르면
됩니다.</b> 서버 설정이 필요 없고, 이 프로그램이 스스로 메일을 보내는 일은 없습니다.</p>
<form method="post" action="/mail/save">{self._tok()}
<table style="min-width:0">
<tr><td style="width:130px">받는 사람들</td><td><input type="text" name="recipients" size="52"
  value="{_E(', '.join(mail.get('recipients', [])))}"
  placeholder="a@회사, b@회사 (쉼표로 구분)">
  <div class="muted" style="margin-top:2px">메일 초안의 '받는 사람' 칸에 미리 채워집니다.
  비워두면 보낼 때 직접 입력.</div></td></tr>
<tr><td>확인 링크 주소</td><td><input type="text" name="ack_url" size="34"
  value="{_E(cfg['ack_url'])}">
  <div class="muted" style="margin-top:2px">현재 버전은 보안상 이 PC(127.0.0.1)에서만
  화면이 열립니다 — <b>확인 링크는 이 PC에서 누를 때만 동작</b>하고, 메일 수신자
  PC에서는 열리지 않습니다. 수신자는 회신으로 확인을 남기고, 기록은 이 화면에서
  담당자가 남기는 운영을 권장합니다.</div></td></tr>
</table>
<div style="border-top:1px solid var(--border);margin:12px 0 10px;padding-top:10px">
<span class="muted"><b>선택 — SMTP 자동 발송</b> (사내 메일서버가 있는 경우만.
모르면 위의 초안 방식을 쓰세요)</span></div>
<table style="min-width:0">
<tr><td style="width:130px">자동 발송 사용</td><td>
  <input type="checkbox" name="enabled" {'checked' if mail.get('enabled') else ''}> 켜기
  <span class="muted">(꺼져 있어도 브리핑·메일 초안은 briefs 폴더에 저장됩니다)</span></td></tr>
<tr><td>SMTP 서버</td><td><input type="text" name="smtp_host" size="24"
  value="{_E(mail.get('smtp_host', ''))}" placeholder="mail.회사도메인">
  포트 <input type="text" name="smtp_port" size="5" value="{_E(mail.get('smtp_port', 25))}">
  <label><input type="checkbox" name="starttls"
  {'checked' if mail.get('starttls') else ''}> STARTTLS(587)</label>
  <label><input type="checkbox" name="use_ssl"
  {'checked' if mail.get('ssl') else ''}> SSL(465)</label>
  <div class="muted" style="margin-top:2px">하이웍스·네이버웍스 등 그룹웨어는 대부분
  SSL(465)입니다. 그룹웨어는 '앱 비밀번호' 발급이 필요할 수 있습니다.</div></td></tr>
<tr><td>보내는 주소</td><td><input type="text" name="sender" size="30"
  value="{_E(mail.get('sender', ''))}" placeholder="watcher@회사도메인"></td></tr>
<tr><td>로그인(선택)</td><td><input type="text" name="username" size="18"
  value="{_E(mail.get('username', ''))}" placeholder="계정">
  <input type="password" name="password" size="18" placeholder="비밀번호(변경 시만 입력)">
  <div class="muted" style="margin-top:4px">비밀번호는 이 PC의 config.yaml에 저장됩니다 —
  서버 PC의 파일 접근 권한에 유의하세요.</div></td></tr>
</table>
<div style="margin-top:10px">
  <button class="btn">저장</button>
  <button class="btn gray" formaction="/mail/test">저장된 설정으로 테스트 발송 (최대 10초)</button>
</div></form></div>"""
        return _page("설정", "/settings", body, self.pop_msg())


def make_web_handler(app: WebApp, db_path: Path):
    AckHandler = make_handler(db_path)

    class Handler(AckHandler):
        def _html(self, code: int, page: str,
                  set_cookie: str | None = None) -> None:
            data = page.encode("utf-8")
            self.send_response(code)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            if set_cookie:
                self.send_header("Set-Cookie", set_cookie)
            self.end_headers()
            self.wfile.write(data)

        def _redirect(self, to: str, set_cookie: str | None = None) -> None:
            self.send_response(303)
            self.send_header("Location", to)
            if set_cookie:
                self.send_header("Set-Cookie", set_cookie)
            self.end_headers()

        def _cookie(self, name: str) -> str:
            from http.cookies import SimpleCookie
            from urllib.parse import unquote
            c = SimpleCookie(self.headers.get("Cookie", ""))
            # 저장 시 quote()로 넣으므로 읽을 때 되돌린다 (한글 이름이 %EA..로 보이던 문제)
            return unquote(c[name].value) if name in c else ""

        def do_GET(self) -> None:
            url = urlparse(self.path)
            q = parse_qs(url.query)
            if url.path == "/ack":
                return super().do_GET()
            proj = q.get("p", [None])[0] or None
            if url.path == "/":
                # 홈 큰틀: URL 파라미터 > 쿠키 > 기본(카드) — 선택은 기억된다
                view = (q.get("view", [None])[0]
                        or self._cookie("pw_view") or "cards")
                ck = (f"pw_view={view}; Path=/; Max-Age=31536000; SameSite=Strict"
                      if q.get("view") else None)
                return self._html(200, app.page_dashboard(proj, view=view),
                                  set_cookie=ck)
            if url.path == "/issues":
                return self._html(200, app.page_issues(proj))
            if url.path == "/cat":
                return self._html(200, app.page_cat(q.get("c", ["도면"])[0], proj,
                                                    q.get("t", [None])[0] or None))
            if url.path == "/projects":
                return self._html(200, app.page_projects())
            if url.path == "/project":  # 구버전 링크 호환 → 프로젝트 필터 대시보드
                return self._html(200, app.page_dashboard(q.get("name", [None])[0]))
            if url.path == "/history":
                return self._html(200, app.page_history(
                    proj, ack_filter=q.get("ack", [None])[0]))
            if url.path == "/settings":
                return self._html(200, app.page_settings())
            if url.path == "/preview":
                return self._html(200, app.page_preview(q.get("path", [""])[0]))
            if url.path == "/organize":
                return self._html(200, app.page_organize())
            if url.path == "/change":
                return self._html(200, app.page_change(
                    q.get("id", [""])[0], ack_name=self._cookie("ack_by")))
            if url.path == "/compare":
                return self._html(200, app.page_compare(
                    q.get("id", [""])[0], q.get("page", ["1"])[0]))
            if url.path == "/notes":
                return self._html(200, app.page_notes())
            if url.path == "/tags":
                return self._html(200, app.page_tags(q.get("q", [""])[0]))
            if url.path == "/export":
                data = app.export_xlsx(proj)
                fname = ("변경이력_" + date.today().strftime("%Y%m%d")
                         + (f"_{proj}" if proj else "") + ".xlsx")
                self.send_response(200)
                self.send_header("Content-Type",
                                 "application/vnd.openxmlformats-officedocument"
                                 ".spreadsheetml.sheet")
                self.send_header("Content-Disposition",
                                 f"attachment; filename*=UTF-8''{quote(fname)}")
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)
                return
            if url.path == "/briefs":
                return self._html(200, app.page_briefs())
            if url.path == "/brief":
                bf = app.brief_file(q.get("f", [""])[0])
                if bf is None:
                    return self._html(404, _page("없음", "", "<h1>브리핑이 없습니다.</h1>"))
                return self._html(200, bf.read_text(encoding="utf-8"))
            if url.path == "/help":
                return self._html(200, app.page_help())
            self._html(404, _page("없음", "", "<h1>페이지가 없습니다.</h1>"))

        def do_POST(self) -> None:
            url = urlparse(self.path)
            if url.path == "/ack":
                return super().do_POST()
            length = int(self.headers.get("Content-Length", 0))
            q = parse_qs(self.rfile.read(length).decode("utf-8"))
            # CSRF 검증 — /ack는 자체 변경별 토큰을 쓰므로 제외 (2026-08-25 리뷰)
            if q.get("t", [""])[0] != app.csrf():
                return self._html(403, _page(
                    "요청 차단", "",
                    "<h1>잘못된 요청입니다</h1>"
                    "<p>보안 토큰이 맞지 않습니다. 대시보드 화면을 새로고침한 뒤 "
                    "다시 시도해주세요.</p>"))
            if url.path == "/scan":
                if not app.cfg()["roots"]:
                    app.last_msg = ("먼저 [설정]에서 감시할 폴더를 추가해주세요 "
                                    "— 그다음 [지금 검사]가 동작합니다.")
                    return self._redirect("/settings")
                app.start_scan()
                return self._redirect("/")
            if url.path == "/update/check":
                cfg_url = app.cfg().get("update_url") or UPDATE_URL
                if not cfg_url:
                    app.last_msg = ("업데이트 채널이 아직 설정되지 않았습니다 — "
                                    "배포 사이트가 열리면 자동으로 연결됩니다.")
                elif app.refresh_update():
                    app.last_msg = (f"새 버전 v{app.update_info['version']}가 "
                                    f"있습니다 — 대시보드 배너에서 여세요.")
                else:
                    app.last_msg = f"지금이 최신 버전입니다 (v{__version__})."
                return self._redirect("/settings")
            if url.path == "/update/open":
                if app.update_info and app.update_info.get("url"):
                    import webbrowser
                    webbrowser.open(app.update_info["url"])
                    app.last_msg = "다운로드 페이지를 기본 브라우저로 열었습니다."
                return self._redirect("/")
            if url.path == "/quit":
                # 즉시 종료 — 검사 중이어도 (진행분 저장·스냅샷 롤백으로 안전).
                # 브라우저 폴백 모드에서 창 X 대신 쓰는 종료 스위치
                self._html(200, _page("종료됨", "", '<div class="panel">'
                           "<p>Drev를 종료했습니다 — 이 탭은 닫으셔도 "
                           "됩니다. 다시 시작하려면 프로그램을 더블클릭하세요."
                           "</p></div>"))
                threading.Timer(0.6, os._exit, args=(0,)).start()
                return
            if url.path == "/onboard":
                # 최초 가동: 폴더 추가 + 첫 검사까지 한 번에
                path = q.get("path", [""])[0].strip().strip('"')
                if not path:
                    app.last_msg = "폴더 경로를 입력해주세요."
                    return self._redirect("/")
                if not Path(path).is_dir():
                    app.last_msg = (f"폴더를 찾을 수 없습니다: {path} — 경로를 "
                                    f"복사해 붙여넣어 보세요. 네트워크 폴더는 "
                                    f"\\\\서버\\공유 형식이 안전합니다.")
                    return self._redirect("/")
                app.save_roots([str(Path(path))])
                app.start_scan()
                app.last_msg = ("감시를 시작했습니다 — 첫 검사(기준선)가 진행 "
                                "중입니다. 이 화면에서 진행률을 볼 수 있습니다.")
                return self._redirect("/")
            if url.path == "/root/add":
                path = q.get("path", [""])[0].strip().strip('"')
                roots = list(app.cfg()["roots"])
                if not path:
                    app.last_msg = "폴더 경로를 입력해주세요."
                elif not Path(path).is_dir():
                    app.last_msg = f"폴더를 찾을 수 없습니다: {path}"
                elif str(Path(path)) in roots:
                    app.last_msg = "이미 감시 중인 폴더입니다."
                else:
                    roots.append(str(Path(path)))
                    app.save_roots(roots)
                    app.last_msg = f"감시 폴더 추가됨: {path} — [지금 검사]로 기준선을 만드세요."
                return self._redirect("/settings")
            if url.path == "/root/del":
                path = q.get("path", [""])[0]
                roots = [r for r in app.cfg()["roots"] if r != path]
                if roots:
                    app.save_roots(roots)
                    app.last_msg = f"감시 폴더 제거됨: {path}"
                else:
                    app.last_msg = "감시 폴더는 최소 1개 필요합니다."
                return self._redirect("/settings")
            if url.path == "/change/ack":
                try:
                    cid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/")
                by = (q.get("by", [""])[0] or "").strip()
                note = (q.get("note", [""])[0] or "").strip()
                if by:
                    with State(db_path) as st:
                        st.add_ack(cid, by, note)
                # 확인자 이름을 기억해 다음부터 미리 채운다 (매번 재입력 불편 해소)
                cookie = (f"ack_by={quote(by)}; Path=/; Max-Age=31536000; "
                          f"SameSite=Strict") if by else None
                return self._redirect(f"/change?id={cid}", set_cookie=cookie)
            if url.path == "/mail/save":
                app.save_mail(q)
                app.last_msg = "메일 설정이 저장되었습니다."
                return self._redirect("/settings")
            if url.path == "/mail/test":
                app.save_mail(q)  # 화면에 입력된 값 그대로 저장 후 시험
                app.last_msg = app.test_mail()
                return self._redirect("/settings")
            if url.path == "/exclude/add":
                # 백슬래시 입력도 수용 (매칭은 / 기준이라 조용히 무시되던 문제)
                path = "/".join(q.get("path", [""])[0].strip().split("\\")).strip("/")
                cfg = app.cfg()
                excludes = list(cfg.get("exclude") or [])
                if path and path not in excludes:
                    excludes.append(path)
                    app.save_excludes(excludes)
                return self._redirect("/settings")
            if url.path in ("/ext/add", "/ext/del"):
                e = (q.get("ext", [""])[0] or "").strip().lower().lstrip("*")
                if e and not e.startswith("."):
                    e = "." + e
                exts = [str(x).lower()
                        for x in (app.cfg().get("extensions") or [])]
                if url.path.endswith("add"):
                    if not (2 <= len(e) <= 11 and e[1:].isalnum()):
                        app.last_msg = "확장자 형식이 이상합니다 — 예: gxw 또는 .gxw"
                    elif e in exts:
                        app.last_msg = f"이미 감시 중인 확장자입니다: {e}"
                    else:
                        app._save_key("extensions", exts + [e])
                        app.last_msg = (f"감시 확장자 추가됨: {e} — 다음 검사부터 "
                                        f"이 형식의 파일도 감시합니다.")
                else:
                    if len(exts) <= 1:
                        app.last_msg = "감시 확장자는 최소 1개 필요합니다."
                    else:
                        app._save_key("extensions", [x for x in exts if x != e])
                        app.last_msg = f"감시 확장자 제거됨: {e}"
                return self._redirect("/settings")
            if url.path == "/exclude/del":
                path = q.get("path", [""])[0]
                excludes = [e for e in (app.cfg().get("exclude") or []) if e != path]
                app.save_excludes(excludes)
                return self._redirect("/settings")
            if url.path == "/change/recompare":
                try:
                    cid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/")
                from watcher.cli import recompare_change
                sens = q.get("sens", [""])[0] == "1"
                with State(db_path) as st:
                    ok, msg = recompare_change(st, cid, app.abs_path_of,
                                               sensitive=sens)
                app.last_msg = msg
                return self._redirect(f"/change?id={cid}")
            if url.path in ("/change/note/add", "/change/note/del"):
                try:
                    cid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/")
                with State(db_path) as st:
                    if url.path == "/change/note/add":
                        # 한 줄 메모 용도 — 과도한 붙여넣기로 DB가 붓지 않게 자른다
                        text = (q.get("text", [""])[0] or "").strip()[:300]
                        if text:
                            st.add_user_note(cid,
                                             (q.get("where", [""])[0] or "").strip()[:100],
                                             text, self._cookie("ack_by") or "-")
                            app.last_msg = ("놓친 변경을 기록했습니다 — 요약에 "
                                            "'사람 기록'으로 함께 집계됩니다.")
                    else:
                        try:
                            st.delete_user_note(cid, int(q.get("idx", [""])[0]))
                            app.last_msg = "기록을 삭제했습니다."
                        except ValueError:
                            pass
                return self._redirect(f"/change?id={cid}")
            if url.path in ("/change/pair/set", "/change/pair/clear"):
                try:
                    cid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/")
                from watcher.cli import recompare_change
                with State(db_path) as st:
                    ch = st.get_change(cid)
                    if ch is None:
                        return self._redirect("/")
                    if url.path.endswith("clear"):
                        st.clear_manual_pair(ch.path)
                        app.last_msg = ("이전 판 지정을 해제했습니다 — 자동 "
                                        "인식 규칙으로 돌아갑니다.")
                    else:
                        prev = (q.get("prev", [""])[0] or "").strip()
                        rows = st._latest_rows()
                        my_ext = PurePosixPath(ch.path).suffix.lower()
                        if prev not in rows:
                            app.last_msg = ("지정 실패: 목록에서 파일을 골라주세요 "
                                            "(감시 중인 파일만 가능).")
                        elif prev == ch.path:
                            app.last_msg = "지정 실패: 자기 자신은 이전 판이 될 수 없습니다."
                        elif PurePosixPath(prev).suffix.lower() != my_ext:
                            app.last_msg = ("지정 실패: 같은 확장자 파일만 이전 판으로 "
                                            "지정할 수 있습니다 (비교 소음 방지).")
                        else:
                            st.set_manual_pair(ch.path, prev,
                                               self._cookie("ack_by") or "-")
                            ok, msg = recompare_change(st, cid, app.abs_path_of)
                            app.last_msg = f"이전 판을 직접 지정했습니다. {msg}"
                return self._redirect(f"/change?id={cid}")
            if url.path == "/open":
                # 목록에서 파일·폴더 바로 열기 (경로 키 기반, 2026-08-29).
                # abs_path_of가 감시 폴더 안 실존 파일로만 해석 — 임의 경로 불가
                key = q.get("k", [""])[0]
                p = app.abs_path_of(key)
                if p is None:
                    app.last_msg = ("파일에 접근할 수 없습니다 — 삭제되었거나 "
                                    "감시 폴더 연결을 확인해주세요.")
                else:
                    target = p.parent if q.get("w", ["f"])[0] == "d" else p
                    try:
                        os.startfile(str(target))
                        app.last_msg = f"열었습니다: {target.name}"
                    except OSError as e:
                        app.last_msg = f"열지 못했습니다: {e}"
                ref = self.headers.get("Referer") or "/"
                if not ref.startswith(("http://127.0.0.1", "http://localhost", "/")):
                    ref = "/"
                return self._redirect(ref)
            if url.path == "/change/open":
                try:
                    cid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/")
                which = q.get("which", ["new"])[0]
                with State(db_path) as st:
                    ch = st.get_change(cid)
                    prev_key = None
                    if ch is not None and which == "old":
                        from watcher.cli import find_prev_version
                        prev_key, _s, _why = find_prev_version(st, ch.path)
                if ch is None:
                    return self._redirect("/")
                target = None
                if which == "old":
                    target = app.abs_path_of(prev_key) if prev_key else None
                    if target is None:
                        app.last_msg = "이전 판을 찾지 못해 열 수 없습니다."
                else:
                    p = app.abs_path_of(ch.path)
                    if p is None:
                        app.last_msg = ("파일에 접근할 수 없습니다 — 감시 폴더 "
                                        "연결을 확인해주세요.")
                    else:
                        target = p.parent if which == "folder" else p
                if target is not None:
                    # 127.0.0.1 전용 화면에서 이 PC의 기본 프로그램으로 여는 것 —
                    # 파일 자체는 읽기만 한다 (원칙 2)
                    try:
                        os.startfile(str(target))
                        app.last_msg = f"열었습니다: {target.name}"
                    except OSError as e:
                        app.last_msg = f"열지 못했습니다: {e}"
                return self._redirect(f"/change?id={cid}")
            if url.path in ("/change/dismiss", "/change/restore"):
                try:
                    cid = int(q.get("id", [""])[0])
                    idx = int(q.get("idx", [""])[0])
                except ValueError:
                    return self._redirect("/")
                with State(db_path) as st:
                    if url.path == "/change/dismiss":
                        st.dismiss_change_item(cid, idx,
                                               self._cookie("ack_by") or "-")
                        app.last_msg = ("항목을 오탐으로 숨겼습니다 — 아래 "
                                        "'숨긴 항목'에서 복원할 수 있습니다.")
                    else:
                        st.restore_change_item(cid, idx)
                        app.last_msg = "항목을 복원했습니다."
                return self._redirect(f"/change?id={cid}")
            if url.path == "/ack/bulk":
                by = (q.get("by", [""])[0] or "").strip()
                note = (q.get("note", [""])[0] or "").strip()
                p = (q.get("p", [""])[0] or "") or None
                if by:
                    with State(db_path) as st:
                        rows = st.history(p)
                        seen: dict[int, bool] = {}
                        for r in rows:  # 변경 단위로 접어 미확인만
                            seen[r["change_id"]] = seen.get(r["change_id"], False) \
                                or bool(r["ack_by"])
                        ids = [cid for cid, acked in seen.items() if not acked]
                        n = st.add_ack_bulk(ids, by, note or "일괄 확인")
                    app.last_msg = f"{n}건을 일괄 확인 처리했습니다."
                return self._redirect("/history?ack=none"
                                      + (f"&p={quote(p)}" if p else ""))
            if url.path == "/issue/close":
                try:
                    iid = int(q.get("id", [""])[0])
                except ValueError:
                    return self._redirect("/issues")
                with State(db_path) as st:
                    st.resolve_issue(iid, (q.get("by", [""])[0] or "").strip())
                app.last_msg = "검토 필요를 처리함으로 표시했습니다. 같은 내용이 다시 검출되면 새로 올라옵니다."
                return self._redirect("/issues")
            if url.path == "/retention/save":
                try:
                    days = int((q.get("days", [""])[0] or "").strip())
                    if days < 0:
                        raise ValueError
                    app._save_key("retention_days", days)
                    app.last_msg = (f"보존 기간 저장됨: {days}일"
                                    if days else "보존 기간 저장됨: 정리 안 함")
                except ValueError:
                    app.last_msg = "보존 기간은 0 이상의 숫자로 입력해주세요."
                return self._redirect("/settings")
            if url.path == "/schedule/register":
                from watcher.cli import schedule_register
                ok, msg = schedule_register()
                app.last_msg = msg
                return self._redirect("/settings")
            if url.path == "/schedule/unregister":
                from watcher.cli import schedule_unregister
                ok, msg = schedule_unregister()
                app.last_msg = msg
                return self._redirect("/settings")
            self._html(404, _page("없음", "", "<h1>페이지가 없습니다.</h1>"))

    return Handler


class _QuietServer(ThreadingHTTPServer):
    """브라우저가 새로고침·이탈로 연결을 끊을 때의 무해한 예외를 조용히 삼킨다.
    검사 중 5초 자동 갱신 화면에서 WinError 10053 트레이스백이 콘솔을 도배해
    사용자가 오류로 오인하던 문제 (2026-08-28 실PC 4만 파일 검사 로그)."""

    def handle_error(self, request, client_address) -> None:
        import sys as _sys
        exc = _sys.exception()
        if isinstance(exc, (ConnectionAbortedError, ConnectionResetError,
                            BrokenPipeError, TimeoutError)):
            return
        super().handle_error(request, client_address)


def make_web_server(config_path: str, host: str = "127.0.0.1",
                    port: int = 8765) -> ThreadingHTTPServer:
    app = WebApp(config_path)
    db_path = Path(app.cfg()["state_db"])
    srv = _QuietServer((host, port), make_web_handler(app, db_path))
    srv.app = app  # run_web 등 바깥에서 앱 상태(업데이트 확인 등)에 접근용
    return srv


def run_web(config_path: str = "config.yaml", open_browser: bool = True) -> int:
    import sys

    # 주의: 여기서 os를 지역 import하면 함수 전체에서 전역 os가 가려져
    # 비-frozen(포터블) 실행이 UnboundLocalError로 죽는다 (2026-08-30 실측)
    if getattr(sys, "frozen", False):
        os.chdir(Path(sys.executable).parent)
    # 첫 실행: config가 없으면 GUI 폴더 선택으로 생성 (tkinter는 대화상자만 사용)
    if not Path(config_path).exists():
        made = False
        try:
            import tkinter as tk
            from watcher.gui import _ensure_config
            r = tk.Tk()
            r.withdraw()
            made = _ensure_config(r)
            r.destroy()
        except Exception:
            pass
        if not made and not Path(config_path).exists():
            if sys.stdin is None or not sys.stdin.isatty():
                # 창 모드/포터블: tkinter(폴더 선택창)도 콘솔도 없다 — 임베디드
                # 파이썬엔 tkinter가 없어 첫 실행이 조용히 죽던 문제 (2026-08-30
                # 실PC). 빈 설정으로 화면을 띄우고 '설정→감시 폴더 추가'로 잇는다
                Path(config_path).write_text("roots: []\n", encoding="utf-8")
                print("첫 실행: 빈 설정 생성 — 화면의 [설정]에서 감시 폴더를 "
                      "추가하세요.")
            else:
                from watcher.cli import run_wizard
                return run_wizard()

    from watcher.cli import load_config
    cfg = load_config(Path(config_path))
    port = urlparse(cfg["ack_url"]).port or 8765
    url = f"http://127.0.0.1:{port}/"
    try:
        server = make_web_server(config_path, port=port)
    except OSError:
        # 포트 선점 — 우리 인스턴스인지 확인 후 안내 (다른 프로그램이면 오도 방지)
        ours = False
        try:
            import urllib.request
            with urllib.request.urlopen(url, timeout=3) as r:
                body0 = r.read(4096).decode("utf-8", "replace")
                ours = ("Drev" in body0 or "Project Watcher" in body0)
        except Exception:
            pass
        if ours:
            # 이미 실행 중 — 조용히 브라우저만 열면 "왜 웹으로 뜨지? 폴더
            # 추가가 왜 안 되지?" 혼란이 된다 (2026-08-30 실사용). 기존
            # 인스턴스의 화면을 '창'으로 열어 더블클릭=창을 일관되게 한다.
            print(f"이미 실행 중입니다: {url} — 기존 화면을 창으로 엽니다.")
            if open_browser and os.environ.get("PW_BROWSER") != "1":
                try:
                    import webview
                    webview.create_window("Drev — 산출물 변경 감시", url,
                                          width=1280, height=880,
                                          min_size=(900, 600))
                    webview.start()
                    return 0
                except Exception:
                    pass
            if open_browser:
                import webbrowser
                webbrowser.open(url)
            return 0
        print(f"오류: 포트 {port}를 다른 프로그램이 사용 중입니다. "
              f"설정(ack_url)의 포트를 바꾸거나 해당 프로그램을 종료해주세요.",
              file=sys.stderr)
        return 1
    # 창 모드 (2026-08-29): 브라우저 대신 자체 창(WebView2)에 화면을 담는다 —
    # 일반 프로그램처럼 보이고, X 닫기 = 즉시 종료(검사 중 포함 — 사용자 결정:
    # 멈춘 것 같아 끄는 경우가 있으므로. 진행분은 저장되고 스냅샷은 롤백되어
    # 다음 검사가 이어받는다). PW_BROWSER=1 또는 WebView2 불가 PC는 브라우저 폴백.
    # 시작 시 새 버전 확인 (백그라운드, 기본 ON — update_check: false로 끔)
    if cfg.get("update_check", True):
        threading.Thread(target=server.app.refresh_update, daemon=True).start()
    if open_browser and os.environ.get("PW_BROWSER") != "1":
        threading.Thread(target=server.serve_forever, daemon=True).start()
        print(f"Drev 창 실행: {url}")
        try:
            import time as _t

            import webview
            webview.create_window("Drev — 산출물 변경 감시", url,
                                  width=1280, height=880, min_size=(900, 600))
            _t0 = _t.time()
            webview.start()
            if _t.time() - _t0 < 8:
                # 창이 뜨자마자 닫힘 = 사용자가 아니라 창 엔진(WebView2)이 죽은
                # 것 — 앱까지 죽이지 말고 브라우저로 생존한다 (2026-08-30
                # "다시 꺼지네" 방어). 진짜 X 닫기는 8초 넘게 쓴 뒤가 보통.
                raise RuntimeError("창이 비정상적으로 즉시 닫힘")
            print("창이 닫혀 종료합니다.")
            os._exit(0)
        except SystemExit:
            raise
        except Exception as e:
            print(f"창 모드를 열 수 없어({e}) 브라우저로 엽니다.",
                  file=sys.stderr)
            import webbrowser
            webbrowser.open(url)
        import time as _time
        try:  # 브라우저 폴백: 설정의 [프로그램 종료] 버튼으로 끈다
            while True:
                _time.sleep(3600)
        except KeyboardInterrupt:
            pass
        finally:
            server.server_close()
        return 0

    print(f"Drev 화면: {url}  (이 창을 닫으면 화면이 종료됩니다)")
    if open_browser:
        import webbrowser
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
    return 0
