# -*- coding: utf-8 -*-
"""CLI: run / init / ack-serve / history.

사용:
  python -m watcher.cli init                # config.example.yaml → config.yaml
  python -m watcher.cli run [--dry-run]     # 스캔 → 브리핑 생성 (+ 설정 시 메일)
  python -m watcher.cli ack-serve           # 5초 확인 로컬 웹 서버
  python -m watcher.cli history [--project] # 변경·확인 이력 조회
"""

from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass
from dataclasses import field as dc_field
from datetime import datetime, timedelta
from html import escape as html_escape
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse

import yaml

from watcher import __version__
from watcher.brief import brief_filename, build_brief
from watcher.consistency import CODE_LABEL, check_project_consistency, check_revision
from watcher.diff import ChangeItem, diff_documents
from watcher.extract import extract
from watcher.extract.base import Document, clean_text
from watcher.mail import send_brief
from watcher.project import ProjectResolver
from watcher.scan import DEFAULT_EXTENSIONS, is_excluded, pair_key, scan
from watcher.state import KIND_LABEL, State

# PyInstaller exe에서는 예시 설정이 임시 해제 폴더(_MEIPASS)에 번들된다
if getattr(sys, "frozen", False):
    PROJECT_DIR = Path(getattr(sys, "_MEIPASS", "."))
else:
    PROJECT_DIR = Path(__file__).resolve().parent.parent

_CONFIG_DEFAULTS = {
    "extensions": list(DEFAULT_EXTENSIONS),
    "state_db": "state.db",
    "brief_dir": "briefs",
    "mail": {"enabled": False},
    "aliases": "aliases.yaml",
    "projects": {},
    "ack_url": "http://127.0.0.1:8765",
    "exclude": [],
}


def load_config(path: Path) -> dict:
    try:
        with open(path, "rb") as f:
            cfg = yaml.safe_load(f) or {}
    except FileNotFoundError:
        print(f"오류: 설정 파일이 없습니다: {path}\n"
              f"  먼저 실행: python -m watcher.cli init", file=sys.stderr)
        raise SystemExit(2)
    # 'mail:'처럼 값 없이 키만 있으면 None이 되어 setdefault로는 못 막는다 → 명시 치환
    for key, default in _CONFIG_DEFAULTS.items():
        if cfg.get(key) is None:
            cfg[key] = default
    # 감시 폴더: roots 목록 우선, 구버전 root_path(단일 문자열)도 수용
    roots = cfg.get("roots")
    if not roots:
        rp = cfg.get("root_path")
        roots = [rp] if rp else []
    cfg["roots"] = [str(r) for r in roots if r]
    return cfg


def write_initial_config(root: str, dst: Path = Path("config.yaml")) -> None:
    """첫 설정 생성: 예시 설정의 감시 폴더만 입력값으로 바꿔 저장 (위저드·GUI 공용)."""
    text = (PROJECT_DIR / "config.example.yaml").read_text(encoding="utf-8")
    safe = "''".join(str(root).split("'"))  # YAML 단일따옴표 이스케이프
    text = re.sub(r'^\s*-\s*"tests/fixtures"\s*$', lambda _: f"  - '{safe}'",
                  text, count=1, flags=re.M)
    dst.write_text(text, encoding="utf-8")


def _root_labels(roots: list[Path]) -> list[str]:
    """감시 폴더별 라벨(경로 접두어). 폴더명 기준, 중복 시 ~2, ~3."""
    labels, seen = [], {}
    for r in roots:
        base = r.name or str(r)
        seen[base] = seen.get(base, 0) + 1
        labels.append(base if seen[base] == 1 else f"{base}~{seen[base]}")
    return labels


def _excludes_for(label: str, all_labels: list[str], excludes: list[str]) -> list[str]:
    """제외 목록을 해당 감시 폴더 몫으로 변환.
    '라벨/상대경로' 형식은 그 폴더에만, 라벨 없는 형식은 모든 폴더에 공통 적용."""
    out = []
    for e in excludes:
        e = e.strip("/")
        head = e.split("/", 1)[0]
        if head == label and "/" in e:
            out.append(e.split("/", 1)[1])
        elif head not in all_labels:
            out.append(e)
    return out


def cmd_init(args: argparse.Namespace) -> int:
    dst = Path(args.config)
    if dst.exists():
        print(f"이미 있음: {dst} (덮어쓰지 않음)")
        return 1
    src = PROJECT_DIR / "config.example.yaml"
    dst.write_bytes(src.read_bytes())
    print(f"생성됨: {dst} — root_path를 감시할 루트 폴더로 바꿔주세요.")
    return 0


# 추출 결과 형식 세대 — 추출 결과의 모양이 달라질 때 올린다
# (v2: PDF 엔진 pdfium 교체 / v3: XLSX 전 시트 / v4: 머리글 최다 열 선택)
_CACHE_VER = 4

# 검사 진행 상황 — 같은 프로세스의 웹 대시보드가 읽어 진행률을 보여준다
# (2026-08-28 사용자 요청: "몇 개 검토 중인지 / 몇 %인지 보이게").
# total이 0이면 전체 수를 아직 모르는 단계 → 개수만 표시
PROGRESS = {"phase": "", "done": 0, "total": 0}


def _set_progress(phase: str, done: int = 0, total: int = 0) -> None:
    PROGRESS["phase"], PROGRESS["done"], PROGRESS["total"] = phase, done, total


def _extract_docs(state: State, abs_of: dict, records) -> tuple[dict, dict]:
    """현재 스냅샷의 문서 추출 (내용 해시 캐시). 실패는 (경로→사유)로 수집."""
    docs: dict[str, Document] = {}
    failures: dict[str, str] = {}
    total = len(records)
    for i, r in enumerate(records, 1):
        _set_progress("2/3 내용 추출", i, total)
        if i % 100 == 0:
            print(f"  내용 추출 {i}/{total}…", flush=True)
        cached = state.load_extract(r.sha256)
        # 실패 캐시: 암호·손상 파일을 매 검사마다 다시 여는 낭비 방지 (2026-08-29
        # — 4만 파일 실서버에서 수백 건이 매일 재시도되던 것). 파일이 바뀌면
        # 해시가 달라져 자동으로 재시도된다
        if cached is not None and cached.get("extract_failed") is not None \
                and cached.get("cache_ver") == _CACHE_VER:
            failures[r.path] = cached["extract_failed"]
            continue
        # 구버전 스키마(notes 없음)나 다른 추출 엔진 세대의 저장분은 재추출 —
        # 엔진이 다르면 줄 나뉨이 달라 섞어 비교하면 소음이 난다 (2026-08-26 pdfium 교체)
        if cached is not None and "notes" in cached \
                and cached.get("cache_ver") == _CACHE_VER:
            docs[r.path] = Document.from_dict(cached)
            continue
        try:
            doc = extract(abs_of[r.path])
        except Exception as e:  # 손상 파일 등 — 파일 수준 감지는 이미 됐음
            # 예외 메시지에도 깨진 인코딩이 실려 올 수 있다 — 그대로 DB·브리핑에
            # 들어가면 저장이 죽는다 (2026-08-27 회사 실파일)
            failures[r.path] = clean_text(str(e))
            print(f"  경고: 추출 실패 {r.path}: {failures[r.path]}", file=sys.stderr)
            # 잠김·순단(OSError)은 일시적일 수 있어 캐시하지 않는다
            if not isinstance(e, OSError):
                state.save_extract(r.sha256, {
                    "extract_failed": failures[r.path], "cache_ver": _CACHE_VER})
            continue
        if doc is not None:
            state.save_extract(r.sha256, {**doc.to_dict(), "cache_ver": _CACHE_VER})
            docs[r.path] = doc
    return docs, failures


def unreadable_note(old_doc: Document | None, new_doc: Document | None) -> str | None:
    """구/신 어느 쪽이든 추출기가 '비교 불가' 고지를 남겼으면 그 사유.
    이 경우 diff·Rev 검사를 하지 않는다 — 근거 없는 비교 결과를 내지 않기 위해 (PRINCIPLE 4)."""
    if new_doc is not None and new_doc.notes:
        return new_doc.notes[0]
    if old_doc is not None and old_doc.notes:
        return f"(이전 판) {old_doc.notes[0]}"
    return None


def _report_and_send(args, cfg, state: State, rd) -> None:
    """귀속 → 이슈 저장 → 콘솔 출력 → 브리핑 저장 → 메일. 실패 시 호출부가 롤백."""
    root = rd.root_display
    changes, docs, details = rd.changes, rd.docs, rd.details
    issues, scan_errors, labels = rd.issues, rd.scan_errors, rd.labels
    snapshot_id, captures = rd.snapshot_id, rd.captures
    resolver = ProjectResolver.load(cfg["aliases"])
    resolver.root_labels = set(labels)
    involved = {c.path for c in changes} | {i.path for i in issues}
    proj_of = {}
    for p in involved:
        doc = docs.get(p)
        if doc is None:  # 삭제된 파일: 구버전 추출본으로 표제란 귀속 시도
            c = next((c for c in changes if c.path == p and c.old_sha256), None)
            old = state.load_extract(c.old_sha256) if c else None
            doc = (Document.from_dict(old)
                   if old and old.get("fields") is not None else None)
        proj_of[p] = resolver.attribute(p, doc)

    for c in changes:
        if c.id is not None:
            state.set_change_project(c.id, proj_of[c.path][0])

    # 일관성 이슈를 DB에 남긴다 — 대시보드 화면·누적 카운터용.
    # 브리핑·콘솔에는 '새로 검출된 것'만 싣는다 (미해결 이슈가 변경 있는 날마다
    # 메일로 재알림되던 소음 제거 — 누적은 화면에서 본다, 2026-08-26)
    if issues and snapshot_id is not None:
        fresh = state.save_issues(snapshot_id,
                                  [(i, proj_of[i.path][0]) for i in issues])
        issues = [i for i, _ in fresh]

    # 변경 상세(diff+캡처)도 저장 — 화면에서 변경 행을 클릭하면 바로 보이도록
    from dataclasses import asdict
    for c in changes:
        if c.id is not None and c.path in details:
            caps = {k[1]: v for k, v in (captures or {}).items() if k[0] == c.path}
            state.save_change_details(
                c.id, [asdict(i) for i in details[c.path]], caps)

    if scan_errors:
        print(f"읽지 못한 파일 {len(scan_errors)}건 (다음 실행에서 재시도):")
        for p, err in scan_errors:
            print(f"  [실패] {p}: {err}")
    if not changes:
        print("변경 없음.")
    else:
        print(f"변경 {len(changes)}건:")
        for c in changes:
            old = (c.old_sha256 or "-")[:12]
            new = (c.new_sha256 or "-")[:12]
            print(f"  [{KIND_LABEL[c.kind]}] {c.path}  (sha256 {old} -> {new})")
            for i in details.get(c.path, []):
                print(f"      {i.kind} {i.key}: {i.old or '—'} -> {i.new or '—'}  @ {i.where}")
    if issues:
        print(f"검토 필요 {len(issues)}건:")
        for iss in issues:
            print(f"  [{CODE_LABEL[iss.code]}] {iss.path} — {iss.message}")
            for ev in iss.evidence:
                print(f"      근거: {ev}")

    groups: dict[str, dict] = {}
    for c in changes:
        g = groups.setdefault(proj_of[c.path][0], {"changes": [], "issues": []})
        g["changes"].append(c)
    for iss in issues:
        g = groups.setdefault(proj_of[iss.path][0], {"changes": [], "issues": []})
        g["issues"].append(iss)

    ack_tokens = {c.id: state.ack_token(c.id) for c in changes if c.id is not None}
    ack_host = urlparse(cfg["ack_url"]).hostname or ""
    mail_on = bool(cfg["mail"].get("enabled")) and not args.dry_run
    if mail_on and ack_host in ("127.0.0.1", "localhost"):
        print("주의: ack_url이 127.0.0.1이라 메일 수신자의 확인 링크는 동작하지 않습니다."
              " config의 ack_url을 이 PC의 사내 주소로 바꿔주세요.", file=sys.stderr)

    run_at = datetime.now()
    brief_dir = Path(args.brief_dir or cfg["brief_dir"])
    brief_dir.mkdir(parents=True, exist_ok=True)
    scanned = len(rd.records)  # 브리핑 '파일 N개 검사' 표기용
    # 도면번호·Rev·개정 사유 — 실무 언어로 브리핑 (설계 10년차 검토, 2026-08-28)
    doc_info: dict[str, dict] = {}
    for c in changes:
        d = docs.get(c.path)
        if d is None:
            continue
        f_dwg, f_rev = d.fields.get("DWG_NO"), d.fields.get("REV")
        note = (d.revision_history[-1].values.get("DESCRIPTION")
                if d.revision_history else None)
        if f_dwg or f_rev or note:
            doc_info[c.path] = {"dwg": f_dwg.value if f_dwg else None,
                                "rev": f_rev.value if f_rev else None,
                                "note": note or None}
    if not groups:
        brief_path = brief_dir / brief_filename(run_at)
        brief_path.write_text(build_brief([], run_at, str(root),
                                          scan_errors=scan_errors,
                                          scanned_count=scanned), encoding="utf-8")
        print(f"브리핑 저장: {brief_path}")
    for proj in sorted(groups):
        g = groups[proj]
        brief_path = brief_dir / brief_filename(run_at, proj)
        html = build_brief(g["changes"], run_at, str(root), details=details,
                           issues=g["issues"], project=proj, ack_base=cfg["ack_url"],
                           ack_tokens=ack_tokens, scan_errors=scan_errors,
                           captures=captures, scanned_count=scanned,
                           doc_info=doc_info)
        brief_path.write_text(html, encoding="utf-8")
        srcs = {proj_of[c.path][1] for c in g["changes"]}
        print(f"[{proj}] 변경 {len(g['changes'])}건 / 검토 필요 {len(g['issues'])}건 "
              f"(귀속 근거: {', '.join(sorted(srcs)) or '—'}) → {brief_path}")

        if args.dry_run:
            continue
        proj_cfg = cfg["projects"].get(proj) or {}
        recipients = proj_cfg.get("recipients") or cfg["mail"].get("recipients", [])
        mail_cfg = {**cfg["mail"], "recipients": recipients}
        subject = (f"[Drev] [{proj}] {run_at.strftime('%Y-%m-%d')} "
                   f"변경 {len(g['changes'])}건 / 검토 필요 {len(g['issues'])}건")
        # 기본: 보내기만 하면 되는 메일 초안(.eml) 생성 — 전송은 사람이 한다 (원칙 1)
        if cfg["mail"].get("draft", True):
            from watcher.mail import write_eml
            try:
                eml = write_eml(mail_cfg, subject, html,
                                brief_path.with_suffix(".eml"))
                print(f"  메일 초안 저장: {eml} (더블클릭 → [보내기]만 누르면 됩니다)")
            except Exception as e:
                print(f"  메일 초안 생성 실패(브리핑은 정상 저장됨): {e}",
                      file=sys.stderr)
        if send_brief(mail_cfg, subject, html):
            print(f"  메일 자동 발송 완료: {', '.join(recipients)}")
    if args.dry_run:
        print("(dry-run: 메일 초안·발송 없음)")


def _local_date_of(iso: str):
    """저장된 UTC ISO → 이 PC 지역 날짜 (주간 집계용)."""
    if not ("+" in iso[10:] or iso.endswith("Z")):
        iso = iso + "+00:00"
    return datetime.fromisoformat(iso).astimezone().date()


def write_weekly_summary(state: State, cfg: dict, brief_dir: Path,
                         run_at: datetime) -> Path | None:
    """이번 주 첫 검사 때 지난주(월~일) 프로젝트별 요약 브리핑 생성 (2026-08-26).
    파일명은 이번 주 월요일 날짜로 고정 — 월요일에 PC가 꺼져 있었어도
    화요일 검사가 대신 만든다 (요일 조건이었다가 공휴일에 영구 생략되던 문제 수정).
    이미 이번 주 것이 있으면 생략. 돌려주는 값: 만든 파일 경로 또는 None."""
    if not cfg.get("weekly_summary", True):
        return None
    monday = (run_at - timedelta(days=run_at.weekday())).date()
    out = brief_dir / f"brief_weekly_{monday.strftime('%Y%m%d')}.html"
    if out.exists():
        return None
    start = monday - timedelta(days=7)
    end = monday - timedelta(days=1)

    by_change: dict[int, dict] = {}
    for r in state.history():  # (변경×확인) 조인 → 변경 단위로 접기
        d = by_change.setdefault(r["change_id"], dict(r, acked=False))
        if r["ack_by"]:
            d["acked"] = True
    week = [d for d in by_change.values()
            if start <= _local_date_of(d["detected_at"]) <= end]
    iss_week: Counter = Counter()
    for proj, det in state.conn.execute(
            "SELECT project, detected_at FROM issues"):
        if start <= _local_date_of(det) <= end:
            iss_week[proj or "미분류"] += 1

    projects = sorted({d["project"] or "미분류" for d in week} | set(iss_week))
    trs, tot = [], Counter()
    for p in projects:
        pr = [d for d in week if (d["project"] or "미분류") == p]
        row = Counter(d["kind"] for d in pr)
        unack = sum(1 for d in pr if not d["acked"])
        iss = iss_week.get(p, 0)
        tot.update(row)
        tot["unack"] += unack
        tot["iss"] += iss
        trs.append(f"<tr><td style='{_W_TD}'>{html_escape(p)}</td>"
                   f"<td style='{_W_TD}'>{row.get('added', 0)}</td>"
                   f"<td style='{_W_TD}'>{row.get('modified', 0)}</td>"
                   f"<td style='{_W_TD}'>{row.get('removed', 0)}</td>"
                   f"<td style='{_W_TD};color:#cf222e'>{unack}</td>"
                   f"<td style='{_W_TD};color:#9a6700'>{iss}</td></tr>")
    body = (f"<table style='border-collapse:collapse;width:100%'>"
            f"<tr><th style='{_W_TH}'>프로젝트</th><th style='{_W_TH}'>추가</th>"
            f"<th style='{_W_TH}'>수정</th><th style='{_W_TH}'>삭제</th>"
            f"<th style='{_W_TH}'>미확인</th><th style='{_W_TH}'>검토 필요</th></tr>"
            + "".join(trs)
            + f"<tr><td style='{_W_TD};font-weight:700'>합계</td>"
              f"<td style='{_W_TD}'>{tot.get('added', 0)}</td>"
              f"<td style='{_W_TD}'>{tot.get('modified', 0)}</td>"
              f"<td style='{_W_TD}'>{tot.get('removed', 0)}</td>"
              f"<td style='{_W_TD};color:#cf222e'>{tot.get('unack', 0)}</td>"
              f"<td style='{_W_TD};color:#9a6700'>{tot.get('iss', 0)}</td></tr>"
            "</table>") if projects else \
        "<p style='color:#1a7f37'>지난주 변경이 없습니다 — 감시는 정상 동작했습니다.</p>"
    title = f"주간 요약 {start.strftime('%m/%d')}~{end.strftime('%m/%d')}"
    doc = (f"<!DOCTYPE html><html lang='ko'><head><meta charset='utf-8'>"
           f"<title>{title}</title></head>"
           f"<body style=\"font-family:'Malgun Gothic',Segoe UI,sans-serif;"
           f"max-width:720px;margin:0 auto;padding:24px;color:#1f2328\">"
           f"<h2>산출물 감시 {title}</h2>"
           f"<p style='color:#57606a'>미확인 변경은 대시보드에서, 상세는 해당 주의 "
           f"일일 브리핑에서 확인하세요.</p>{body}"
           f"<p style='color:#8c959f;font-size:12px;margin-top:20px'>Project Watcher"
           f" — 매주 월요일 첫 검사 때 자동 생성됩니다.</p></body></html>")
    out.write_text(doc, encoding="utf-8")
    mc = cfg.get("mail") or {}
    if mc.get("draft", True):
        try:
            from watcher.mail import write_eml
            write_eml(mc, f"[Drev] {title}", doc, out.with_suffix(".eml"))
        except Exception as e:
            print(f"경고: 주간 요약 메일 초안 생성 실패(요약 HTML은 정상): {e}",
                  file=sys.stderr)
    return out


_W_TH = ("border:1px solid #d0d7de;padding:7px 12px;background:#f6f8fa;"
         "text-align:left;font-size:13px")
_W_TD = "border:1px solid #d0d7de;padding:7px 12px;font-size:13.5px"


@dataclass
class RunData:
    """검사 파이프라인 한 회분의 공유 상태 — 단계 함수들이 이 묶음만 주고받는다.
    (파라미터 11개짜리 시그니처와 args 밀수를 없애기 위한 것, 2026-08-26 리팩토링)"""
    cfg: dict
    roots: list
    labels: list
    root_display: str
    prev_files: dict = dc_field(default_factory=dict)
    records: list = dc_field(default_factory=list)
    abs_of: dict = dc_field(default_factory=dict)
    scan_errors: list = dc_field(default_factory=list)
    snapshot_id: int | None = None
    changes: list = dc_field(default_factory=list)
    docs: dict = dc_field(default_factory=dict)
    extract_failures: dict = dc_field(default_factory=dict)
    details: dict = dc_field(default_factory=dict)
    prev_abs: dict = dc_field(default_factory=dict)   # 판 짝짓기 시 이전 판 파일 경로
    issues: list = dc_field(default_factory=list)
    captures: dict = dc_field(default_factory=dict)


def _scan_roots(rd: RunData, args) -> list:
    """루트별 스캔 → rd.records/abs_of/scan_errors 채움.
    돌려주는 값: 감시 제외로 조용히 이월할 이전 스냅샷 경로들."""
    # 진행률 분모: 직전 스냅샷 파일 수(재검사) — 첫 검사는 미상이라 개수만 표시
    total_est = len(rd.prev_files)
    for r_path, label in zip(rd.roots, rd.labels):
        errs: list[tuple[str, str]] = []
        excludes = _excludes_for(label, rd.labels, rd.cfg["exclude"])
        prev_root = None
        if not getattr(args, "full", False):
            # 크기·수정시각이 같으면 재해싱 생략 (라벨 접두를 벗겨 폴더 몫만)
            pfx = label + "/"
            prev_root = {k[len(pfx):]: v for k, v in rd.prev_files.items()
                         if k.startswith(pfx)}
        def _tick(n: int, _label=label, _base=len(rd.records)) -> None:
            _set_progress("1/3 파일 확인", _base + n, total_est)
            if n % 500 == 0:
                print(f"  파일 확인 중({_label}): {n}개…", flush=True)

        for rec in scan(r_path, rd.cfg["extensions"], errors=errs,
                        excludes=excludes, prev=prev_root, progress=_tick):
            key = f"{label}/{rec.path}"
            rd.records.append(type(rec)(key, rec.sha256, rec.size, rec.mtime))
            rd.abs_of[key] = r_path / PurePosixPath(rec.path)
        rd.scan_errors += [(f"{label}/{p}", e) for p, e in errs]

    # 감시 제외로 새로 빠진 기존 파일은 '삭제'로 보고하지 않고 조용히 이월한다 —
    # 제외는 "더 이상 안 본다"이지 파일이 사라진 게 아니다 (2026-08-25 리뷰)
    current_paths = {r.path for r in rd.records}
    excluded_carry = []
    for r_path, label in zip(rd.roots, rd.labels):
        excludes = _excludes_for(label, rd.labels, rd.cfg["exclude"])
        if not excludes:
            continue
        pfx = label + "/"
        excluded_carry += [
            k for k in rd.prev_files
            if k.startswith(pfx) and k not in current_paths
            and is_excluded(k[len(pfx):], excludes)]
    return excluded_carry


def _compare_modified(state: State, rd: RunData) -> None:
    """덮어쓰기 수정 파일: 구버전 추출본과 내용 비교 + Rev 일관성 검사."""
    for c in rd.changes:
        if c.kind != "modified":
            continue
        old_dict = state.load_extract(c.old_sha256)
        new_doc = rd.docs.get(c.path)
        if old_dict is not None and old_dict.get("extract_failed"):
            old_dict = None  # 구버전이 추출 불가였던 파일 — 내용 비교 불가로 처리
        if old_dict is None or new_doc is None:
            # 검사 불가를 브리핑에 명시 — '이상 없음'과 '못 봄'을 구분
            reason = rd.extract_failures.get(
                c.path, "구버전 추출본 없음 — 이번 판을 기준으로 저장")
            rd.details.setdefault(c.path, []).append(ChangeItem(
                "CHECK_SKIPPED", "내용 비교",
                None, f"수행 못 함: {reason}", "추출 단계"))
            continue
        if old_dict.get("cache_ver") != _CACHE_VER:
            # 구버전(다른 추출 엔진 세대) 기록과 새 추출본을 섞어 비교하면
            # 줄 나뉨 차이가 가짜 diff 수백 건이 된다 (2026-08-26 리뷰)
            rd.details[c.path] = [ChangeItem(
                "CHECK_SKIPPED", "내용 비교", None,
                "수행 못 함: 이전 판의 추출 기록이 구버전 형식이라 비교를 생략하고 "
                "이번 판을 새 기준으로 저장합니다. 다음 변경부터 정상 비교됩니다",
                "추출 단계")]
            continue
        old_doc = Document.from_dict(old_dict)
        note = unreadable_note(old_doc, new_doc)
        if note:  # 텍스트를 못 읽는 판이 끼면 엉터리 diff 대신 사유를 보고
            rd.details[c.path] = [ChangeItem(
                "CHECK_SKIPPED", "내용 비교", None,
                f"수행 못 함: {note}", "추출 단계")]
            continue
        items = diff_documents(old_doc, new_doc)
        if c.path.lower().endswith(".pdf"):
            # 덮어쓰기라 이전 판 파일이 없어 그림 비교 불가 — '이상 없음'과
            # '못 봄'을 구분해 고지 (2026-08-25 리뷰, 원칙 4)
            items.append(ChangeItem(
                "CHECK_SKIPPED", "시각 비교", None,
                "수행 못 함: 파일이 같은 이름으로 덮어써져 이전 판 파일이 "
                "없음 — 그림(배선·도형) 변경은 비교하지 못했고 텍스트 변경만 "
                "비교함. 날짜를 붙인 새 파일로 저장하면 그림 비교도 됩니다",
                "시각 비교 단계"))
        if items:
            rd.details[c.path] = items
        rd.issues += check_revision(c.path, old_doc, new_doc, items)


def find_prev_version(state: State, key: str):
    """현재 스냅샷에서 key 파일의 '이전 판'. 사람이 직접 지정한 짝이 있으면
    그것이 자동 규칙(같은 폴더·확장자·stem, 더 오래된 것)보다 우선한다 —
    재비교·나란히 보기·이전 판 열기가 전부 이 함수를 쓰므로 한 곳에서 일괄 적용
    (2026-08-29). 돌려주는 값: (prev_key, prev_sha, 실패사유) — 실패 시 앞 둘은 None."""
    rows = state._latest_rows()
    if key not in rows:
        return None, None, "이 파일이 현재 감시 목록에 없습니다 (삭제되었거나 폴더가 바뀜)."
    mp = state.manual_pair(key)
    if mp and mp["prev_key"] in rows:
        return mp["prev_key"], rows[mp["prev_key"]][0], ""
    # 지정된 판이 감시 목록에서 사라졌으면 자동 규칙으로 폴백
    my_sha, _sz, my_mt = rows[key]
    p = PurePosixPath(key)
    stem, ext = pair_key(p.name), p.suffix.lower()
    sibs = [(k, r) for k, r in rows.items()
            if k != key
            and PurePosixPath(k).parent == p.parent
            and PurePosixPath(k).suffix.lower() == ext
            and pair_key(PurePosixPath(k).name) == stem
            and (r[2], k) < (my_mt, key)]
    if not sibs:
        return None, None, ("같은 폴더에서 이전 판을 찾지 못했습니다 — 날짜·버전 "
                            "표기를 뺀 파일 이름이 같아야 하고, 이 파일보다 "
                            "수정시각이 오래된 판이 있어야 합니다.")
    prev_key, (prev_sha, _s2, _m2) = max(sibs, key=lambda kv: (kv[1][2], kv[0]))
    return prev_key, prev_sha, ""


def recompare_change(state: State, change_id: int, abs_of_key,
                     sensitive: bool = False) -> tuple[bool, str]:
    """소급 재비교 — 과거에 짝지어지지 못한 변경(기준선 등록분, 구버전에서 놓친 판)을
    현재 폴더의 이전 판과 비교해 상세를 채운다 (2026-08-27 사용자 문제: 여러 판 중
    일부만 비교됨). abs_of_key: 경로 키 → 실제 Path. 돌려주는 값: (성공, 메시지)."""
    from dataclasses import asdict

    from watcher.capture import capture_pair, visual_page_diff

    ch = state.get_change(change_id)
    if ch is None:
        return False, "변경을 찾을 수 없습니다."
    key = ch.path
    prev_key, prev_sha, why = find_prev_version(state, key)
    if prev_key is None:
        return False, why
    my_sha = state._latest_rows()[key][0]
    p = PurePosixPath(key)
    ext = p.suffix.lower()
    new_abs, prev_abs = abs_of_key(key), abs_of_key(prev_key)
    if new_abs is None or prev_abs is None:
        return False, "파일에 접근할 수 없습니다 (잠김 또는 권한)."

    def _doc(sha, ap):
        d = state.load_extract(sha)
        if d is not None and "notes" in d and d.get("cache_ver") == _CACHE_VER:
            return Document.from_dict(d)
        doc = extract(ap)
        state.save_extract(sha, {**doc.to_dict(), "cache_ver": _CACHE_VER})
        return doc

    try:
        old_doc, new_doc = _doc(prev_sha, prev_abs), _doc(my_sha, new_abs)
    except Exception as e:
        return False, f"내용 추출 실패: {e}"

    mp = state.manual_pair(key)
    how = ("사람 지정 짝" if mp and mp["prev_key"] == prev_key
           else "파일명 짝짓기")
    items = [ChangeItem("NEW_REVISION_OF", "이전 판",
                        PurePosixPath(prev_key).name, p.name,
                        f"{how} — 소급 재비교")]
    caps: dict = {}
    note = unreadable_note(old_doc, new_doc)
    if note:
        items.append(ChangeItem("CHECK_SKIPPED", "내용 비교", None,
                                f"수행 못 함: {note}", "추출 단계"))
    else:
        if ext == ".pdf":
            vis = visual_page_diff(prev_abs, new_abs, sensitive=sensitive)
            if vis is None:
                items.append(ChangeItem(
                    "CHECK_SKIPPED", "시각 비교", None,
                    "수행 못 함: 페이지 렌더 실패 — 텍스트 변경만 비교함",
                    "시각 비교 단계"))
            else:
                for k, where in vis[0]:
                    items.append(ChangeItem(
                        "PAGE_VISUAL_CHANGED", k, None,
                        "도면 그림/배선 변경 감지 (전/후 확대 사진 참조)", where))
                for k, cap in vis[1].items():
                    caps[f"PAGE_VISUAL_CHANGED|{k}"] = cap
        items += diff_documents(old_doc, new_doc)
        n = 0
        for it in items:  # 텍스트 변경 위치 캡처 (파일당 4장 제한)
            if n >= 4:
                break
            if it.kind == "TEXT_CHANGED" and it.new:
                cap = capture_pair(new_abs, it.new, prev_abs, it.old)
            elif it.kind == "TEXT_ADDED" and it.new:
                cap = capture_pair(new_abs, it.new, prev_abs, None)
            else:
                continue
            if cap:
                caps[f"{it.kind}|{it.key}"] = cap
                n += 1
    state.save_change_details(change_id, [asdict(i) for i in items], caps)
    n_content = sum(1 for i in items if i.kind != "NEW_REVISION_OF")
    mode = " (민감 모드 — 미세 변경까지, 오탐은 [오탐 삭제]로 정리)" if sensitive else ""
    return True, (f"이전 판({PurePosixPath(prev_key).name})과 다시 비교했습니다 — "
                  f"항목 {n_content}건.{mode}")


def _pair_revisions(state: State, rd: RunData) -> None:
    """판 짝짓기: 날짜/버전을 파일명에 붙여 '새 파일'로 저장하는 문화 대응.
    추가된 파일과 같은 폴더에서 stem(날짜·버전 제외 이름)이 같은 이전 판을 찾아
    비교하고, PDF면 페이지 시각 변경(그림·배선)도 감지한다."""
    # 첫 검사(기준선)에는 무거운 그림 비교를 하지 않는다 — 수년치 판이 쌓인
    # 서버에서 모든 과거 쌍을 페이지 렌더로 비교하면 몇 시간~며칠이 걸린다
    # (2026-08-27 회사 서버 5,211개 실측). 텍스트 짝짓기·diff는 유지, 그림은
    # 이후 일일 검사에서 새 판이 올 때부터. 과거 도면이 필요하면 화면의
    # [더 민감하게 다시 비교]로 파일 단위 소급 비교가 가능하다.
    baseline = not rd.prev_files
    noted = False
    for ci, c in enumerate(rd.changes, 1):
        _set_progress("3/3 변경 비교·기록", ci, len(rd.changes))
        if c.kind != "added" or c.path in rd.details:
            continue
        p = PurePosixPath(c.path)
        stem, ext = pair_key(p.name), p.suffix.lower()
        if not stem:
            continue
        cands = [
            (pp, row) for pp, row in rd.prev_files.items()
            if pp != c.path
            and PurePosixPath(pp).parent == p.parent
            and PurePosixPath(pp).suffix.lower() == ext
            and pair_key(PurePosixPath(pp).name) == stem]
        if cands:
            prev_path, (prev_sha, _sz, _mt) = max(cands, key=lambda kv: kv[1][2])
        else:
            # 같은 검사에서 두 판이 한꺼번에 처음 등장한 경우 — 직전 스냅샷에는
            # 없지만 이번 스캔의 형제 파일과 짝지어 비교한다 (2026-08-26 실사용:
            # 구판·신판을 동시에 넣으면 비교가 안 되던 문제. '하나 빼고 검사 후
            # 다시 넣기' 우회도 이제 불필요). 자기보다 오래된(수정시각) 판만 구판 후보.
            me = next((r for r in rd.records if r.path == c.path), None)
            if me is None:
                continue
            sibs = [r for r in rd.records
                    if r.path != c.path
                    and PurePosixPath(r.path).parent == p.parent
                    and PurePosixPath(r.path).suffix.lower() == ext
                    and pair_key(PurePosixPath(r.path).name) == stem
                    and (r.mtime, r.path) < (me.mtime, me.path)]
            if not sibs:
                continue  # 자신이 가장 오래된 판 — 신판 쪽에서 짝지어진다
            prev_rec = max(sibs, key=lambda r: (r.mtime, r.path))
            prev_path, prev_sha = prev_rec.path, prev_rec.sha256
        old_dict = state.load_extract(prev_sha)
        new_doc = rd.docs.get(c.path)
        if old_dict is None or new_doc is None \
                or old_dict.get("extract_failed"):
            continue
        if old_dict.get("cache_ver") != _CACHE_VER:
            # 이전 판이 제외 폴더 등으로 재추출되지 않은 경우의 엔진 혼합 방지
            rd.details[c.path] = [ChangeItem(
                "CHECK_SKIPPED", "내용 비교", None,
                "수행 못 함: 이전 판의 추출 기록이 구버전 형식이라 비교를 생략합니다",
                "추출 단계")]
            continue
        old_doc = Document.from_dict(old_dict)
        items = [ChangeItem("NEW_REVISION_OF", "이전 판",
                            PurePosixPath(prev_path).name, p.name,
                            "파일명 짝짓기 (날짜·버전 표기 제외 동일)")]
        note = unreadable_note(old_doc, new_doc)
        if note:  # 텍스트를 못 읽는 판이 끼면 엉터리 diff 대신 사유를 보고
            items.append(ChangeItem("CHECK_SKIPPED", "내용 비교", None,
                                    f"수행 못 함: {note}", "추출 단계"))
            rd.details[c.path] = items
            continue
        # 페이지 시각 변경 감지 (PDF 도면, 이전 판 파일이 남아 있을 때) —
        # 배선·도형처럼 텍스트 비교가 못 보는 변경 (원칙 7, 2026-08-24 개정)
        if baseline and ext == ".pdf":
            if not noted:
                noted = True
                print("첫 검사(기준선): 과거 판끼리의 그림(페이지) 비교는 "
                      "건너뜁니다 — 텍스트 비교만 기록하고, 필요한 도면은 화면의 "
                      "[더 민감하게 다시 비교]로 소급 비교할 수 있습니다.",
                      flush=True)
        elif prev_path in rd.abs_of and ext == ".pdf":
            from watcher.capture import visual_page_diff
            print(f"  그림 비교: {p.name} ← {PurePosixPath(prev_path).name}",
                  flush=True)
            vis = visual_page_diff(rd.abs_of[prev_path], rd.abs_of[c.path])
            if vis is None:  # '비교 못 함'을 '변경 없음'과 구분해 고지 (원칙 4)
                items.append(ChangeItem(
                    "CHECK_SKIPPED", "시각 비교", None,
                    "수행 못 함: 페이지 렌더 실패 — 그림(배선·도형) 변경은 "
                    "비교하지 못했고 텍스트 변경만 비교함", "시각 비교 단계"))
            else:
                vis_regions, vis_caps = vis
                for key, where in vis_regions:
                    items.append(ChangeItem(
                        "PAGE_VISUAL_CHANGED", key, None,
                        "도면 그림/배선 변경 감지 (전/후 확대 사진 참조)", where))
                for key, cap in vis_caps.items():
                    rd.captures[(c.path, f"PAGE_VISUAL_CHANGED|{key}")] = cap
        items += diff_documents(old_doc, new_doc)
        rd.details[c.path] = items
        if prev_path in rd.abs_of:  # 이전 판 파일이 아직 있으면 전 사진 가능
            rd.prev_abs[c.path] = rd.abs_of[prev_path]
        rd.issues += check_revision(c.path, old_doc, new_doc, items)


def _collect_text_captures(rd: RunData) -> None:
    """변경 위치 캡처 (도면 유형만, 파일당 4장 제한 — 브리핑 용량 보호).
    전/후 쌍: 이전 판 파일이 남아 있으면(판 짝짓기) 전 사진도, 덮어쓰기면 후만."""
    if not rd.prev_files:
        return  # 첫 검사(기준선): 렌더 캡처 생략 — 그림 비교와 같은 이유(소요 시간)
    from watcher.capture import capture_pair
    for cpath, citems in rd.details.items():
        ap = rd.abs_of.get(cpath)
        if ap is None:
            continue
        old_ap = rd.prev_abs.get(cpath)
        n = 0
        for it in citems:
            if n >= 4:
                break
            if it.kind == "TEXT_CHANGED" and it.new:
                cap = capture_pair(ap, it.new, old_ap, it.old)
            elif it.kind == "TEXT_ADDED" and it.new:
                cap = capture_pair(ap, it.new, old_ap, None)
            else:
                continue
            if cap:
                # kind를 키에 포함 — 같은 key의 TEXT_CHANGED/ADDED가
                # 서로의 사진을 덮어쓰지 않게 (2026-08-25 리뷰)
                rd.captures[(cpath, f"{it.kind}|{it.key}")] = cap
                n += 1


def _backup_db(state: State, db_path: Path) -> None:
    """주 1회 state.db 자동 백업 — backups/ 아래 4개 슬롯 순환(약 한 달 보관).
    모든 이력이 파일 하나라 유실이 곧 신뢰 붕괴 (2026-08-29). 삭제 API 없이
    같은 슬롯을 덮어쓰는 방식이라 읽기 전용 원칙과도 충돌 없음."""
    import sqlite3
    bdir = db_path.parent / "backups"
    bdir.mkdir(exist_ok=True)
    week = datetime.now().isocalendar()[1]
    dst = bdir / f"state_backup_{week % 4 + 1}.db"
    if dst.exists() and (datetime.now().timestamp()
                         - dst.stat().st_mtime) < 6 * 86400:
        return  # 이번 주 몫은 이미 있음
    with sqlite3.connect(dst) as dc:
        state.conn.backup(dc)
    print(f"이력 백업: {dst.name} (주 1회, 4개 순환)")


def _housekeep(state: State, cfg: dict, args) -> None:
    """검사 뒤 정리: 보존 기간 지난 상세 정리 + DB 주간 백업 + 월요일 주간 요약.
    전부 실패해도 검사 결과에는 영향 없음 — 단, 원인은 stderr에 남긴다."""
    try:
        _backup_db(state, Path(args.state_db or cfg["state_db"]))
    except Exception as e:
        print(f"경고: 이력 백업 실패(검사 결과는 정상): {e}", file=sys.stderr)
    try:
        days = int(cfg.get("retention_days", 90) or 0)
        if days > 0:
            state.prune_details(days)
    except Exception as e:
        print(f"경고: 오래된 기록 정리 실패(검사 결과는 정상): {e}", file=sys.stderr)
    if not args.dry_run:
        try:
            wp = write_weekly_summary(
                state, cfg, Path(args.brief_dir or cfg["brief_dir"]),
                datetime.now())
            if wp:
                print(f"주간 요약 저장: {wp}")
        except Exception as e:
            print(f"경고: 주간 요약 생성 실패(검사 결과는 정상): {e}", file=sys.stderr)


def cmd_run(args: argparse.Namespace) -> int:
    cfg = load_config(Path(args.config))
    if cfg.get("tag_pattern"):  # 회사별 태그 관례 재정의 (2026-08-26)
        from watcher.extract import base as _tag_base
        try:
            _tag_base.set_tag_pattern(str(cfg["tag_pattern"]))
        except re.error as e:
            print(f"경고: tag_pattern 정규식 오류({e}) — 기본 패턴 사용", file=sys.stderr)
    roots_cfg = [args.root] if args.root else cfg["roots"]
    if not roots_cfg:
        print("오류: 감시 폴더가 없습니다 — 설정 화면 또는 config.yaml의 roots에 추가하세요.",
              file=sys.stderr)
        return 2
    roots = [Path(r) for r in roots_cfg]
    for m in (r for r in roots if not r.is_dir()):
        print(f"경고: 감시 폴더가 없음(건너뜀): {m}", file=sys.stderr)
    roots = [r for r in roots if r.is_dir()]
    if not roots:
        print("오류: 존재하는 감시 폴더가 하나도 없습니다.", file=sys.stderr)
        return 2

    _set_progress("준비 중")
    # 경로 키는 항상 '폴더라벨/상대경로' — 폴더를 더하고 빼도 기존 키가 흔들리지 않는다
    labels = _root_labels(roots)
    db_path = Path(args.state_db or cfg["state_db"])
    with State(db_path) as state:
        rd = RunData(cfg=cfg, roots=roots, labels=labels,
                     root_display=", ".join(str(r) for r in roots))
        rd.prev_files = state._latest_rows()  # 해시 단축·판 짝짓기용: 직전 스냅샷
        excluded_carry = _scan_roots(rd, args)
        artifact_dirs = {PurePosixPath(r.path).parent for r in rd.records}

        rd.snapshot_id, rd.changes = state.record_run(
            rd.records, carry_paths=[p for p, _ in rd.scan_errors] + excluded_carry,
            known_prefixes=labels)
        print(f"Drev v{__version__} — 감시 폴더 {len(roots)}개: {rd.root_display}")
        print(f"산출물 폴더 {len(artifact_dirs)}개, 파일 {len(rd.records)}개 스캔 "
              f"(스냅샷 #{rd.snapshot_id})")

        try:
            rd.docs, rd.extract_failures = _extract_docs(state, rd.abs_of, rd.records)
            _compare_modified(state, rd)
            _pair_revisions(state, rd)
            # 프로젝트 단위 교차 검사는 변경이 있던 날만 (매일 같은 소음 방지)
            if rd.changes:
                rd.issues += check_project_consistency(sorted(rd.docs.items()))
            _collect_text_captures(rd)
            _report_and_send(args, cfg, state, rd)
            _housekeep(state, cfg, args)
        except Exception as e:
            # 보고를 못 했으면 스냅샷을 되돌린다 — 다음 실행이 같은 변경을 다시
            # 감지·보고하도록. 커밋만 하고 죽으면 알림이 영구 유실된다 (PRINCIPLE 5)
            state.delete_run(rd.snapshot_id)
            print(f"오류: 보고 단계 실패 — 이번 스냅샷을 롤백했습니다. "
                  f"다음 실행에서 변경을 다시 감지합니다.\n  사유: {e}", file=sys.stderr)
            _set_progress("")
            return 1
    _set_progress("")
    return 0


def cmd_ack_serve(args: argparse.Namespace) -> int:
    from watcher.ack_server import serve
    cfg = load_config(Path(args.config))
    db_path = Path(args.state_db or cfg["state_db"])
    # 포트는 ack_url이 기준 — 메일 링크와 서버가 어긋나지 않게 한 곳에서만 정한다
    port = args.port or urlparse(cfg["ack_url"]).port or 8765
    serve(db_path, port=port)
    return 0


def cmd_history(args: argparse.Namespace) -> int:
    cfg = load_config(Path(args.config))
    db_path = Path(args.state_db or cfg["state_db"])
    with State(db_path) as state:
        rows = state.history(args.project)
    if not rows:
        print("이력 없음." + (f" (프로젝트 {args.project})" if args.project else ""))
        return 0
    for r in rows:
        ack = (f"확인: {r['ack_by']} ({r['ack_at'][:16]})"
               + (f" — {r['ack_note']}" if r['ack_note'] else "")) \
            if r["ack_by"] else "미확인"
        print(f"#{r['change_id']:>4} {r['detected_at'][:16]} "
              f"[{KIND_LABEL[r['kind']]}] [{r['project'] or '미분류'}] {r['path']}  | {ack}")
    return 0


def _ask(prompt: str) -> str:
    try:
        return input(prompt).strip().strip('"')
    except EOFError:
        return ""


def _wizard_make_config(cfg_path: Path) -> bool:
    print("처음 실행이시네요. 딱 하나만 물어볼게요.\n")
    print("감시할 루트 폴더 경로를 입력하세요.")
    print(r"  예: \\fileserver\projects  또는  D:\projects")
    for _ in range(3):
        root = _ask("\n루트 폴더: ")
        if not root:
            print("입력이 없어 설정을 만들지 않고 종료합니다.")
            return False
        if Path(root).is_dir():
            break
        print(f"  폴더를 찾을 수 없습니다: {root}  (다시 입력해주세요)")
    else:
        return False
    write_initial_config(root, cfg_path)
    print(f"\n설정 저장됨: {cfg_path.resolve()}")
    print("(메일 발송은 꺼진 상태로 시작합니다 — 브리핑은 briefs 폴더에 저장됩니다)")
    return True


_TASK_NAME = "ProjectWatcher"


def _daily_bat_text() -> str:
    """매일 실행 배치 내용. exe면 exe를, 소스 실행이면 현재 파이썬을 그대로 쓴다 —
    다른 PC의 개발 경로를 하드코딩하지 않는다 (2026-08-25 UX 검토)."""
    if getattr(sys, "frozen", False):
        run_line = f"\"{Path(sys.executable).name}\" run"  # exe 이름 변경에도 안전
    elif Path(sys.executable).name.lower().startswith("drev"):
        # 포터블: 루트의 pythonw 사본(Drev.exe)은 무인자일 때 화면을 띄우는
        # 자동부트가 있어 자동 실행엔 python 원본을 쓴다 (2026-08-30 검토)
        root = Path(sys.executable).parent
        run_line = (f'"{root / "python" / "python.exe"}" '
                    f'"{root / "app" / "entry.py"}" run')
    else:
        repo = Path(__file__).resolve().parent.parent
        run_line = (f'set PYTHONPATH={repo};%PYTHONPATH%\r\n'
                    f'"{sys.executable}" -m watcher.cli run')
    return ('@echo off\r\ncd /d "%~dp0"\r\n'
            f"{run_line} >> run_daily.log 2>&1\r\n")


def schedule_status() -> bool:
    """작업 스케줄러에 ProjectWatcher가 등록돼 있는가."""
    try:
        q = subprocess.run(["schtasks", "/Query", "/TN", _TASK_NAME],
                           capture_output=True, timeout=15)
        return q.returncode == 0
    except Exception:
        return False


def schedule_register(time_str: str = "07:00") -> tuple[bool, str]:
    """매일 자동 실행 등록. run_daily.bat을 (없거나 낡았으면) 새로 쓰고 등록한다."""
    bat = Path.cwd() / "run_daily.bat"
    text = _daily_bat_text()
    try:
        # cmd는 배치를 ANSI(cp949)로 읽는다 — utf-8로 쓰면 경로에 한글이 있을 때 깨짐.
        # cp949로 표현 불가한 문자는 조용히 지우지 말고(bat 파손) 등록을 거부한다
        if not bat.exists() or bat.read_text(encoding="mbcs",
                                             errors="ignore") != text:
            bat.write_text(text, encoding="mbcs", errors="strict")
    except UnicodeEncodeError:
        return False, ("등록 실패: 설치 경로에 한글/영문 외 특수문자가 있어 "
                       "자동 실행 배치를 만들 수 없습니다 — 폴더 경로를 바꿔주세요.")
    except OSError as e:
        return False, f"run_daily.bat 생성 실패: {e}"
    try:
        r = subprocess.run(["schtasks", "/Create", "/TN", _TASK_NAME,
                            "/TR", f'"{bat}"', "/SC", "DAILY",
                            "/ST", time_str, "/F"],
                           capture_output=True, timeout=15)
    except Exception as e:
        return False, f"등록 실패: {e}"
    if r.returncode == 0:
        return True, (f"등록 완료 — 매일 {time_str}에 자동 검사합니다. "
                      "PC가 꺼져 있거나 로그아웃 상태인 날은 건너뜁니다.")
    return False, ("등록 실패 (권한 문제일 수 있음) — 프로그램을 마우스 오른쪽 클릭 → "
                   "'관리자 권한으로 실행'한 뒤 다시 시도해보세요.")


def schedule_unregister() -> tuple[bool, str]:
    try:
        r = subprocess.run(["schtasks", "/Delete", "/TN", _TASK_NAME, "/F"],
                           capture_output=True, timeout=15)
    except Exception as e:
        return False, f"해제 실패: {e}"
    return (r.returncode == 0,
            "자동 실행을 해제했습니다." if r.returncode == 0 else "해제 실패 — 이미 없거나 권한 문제입니다.")


def _wizard_schedule(exe_dir: Path) -> None:
    if schedule_status():
        return  # 이미 등록됨
    ans = _ask("\n매일 아침 07:00 자동 실행을 등록할까요? (y/n): ").lower()
    if ans != "y":
        print("나중에 화면의 [설정] → 자동 실행에서 등록할 수 있습니다.")
        return
    ok, msg = schedule_register()
    print(msg)


def run_wizard() -> int:
    """더블클릭(인수 없음) 진입점: 설정 → 스캔 → 자동 실행 등록까지 안내."""
    if getattr(sys, "frozen", False):
        os.chdir(Path(sys.executable).parent)
    print("=" * 46)
    print(" Drev — 산출물 변경 감시자")
    print("=" * 46)
    cfg_path = Path("config.yaml")
    first_time = not cfg_path.exists()
    if first_time and not _wizard_make_config(cfg_path):
        _ask("\n종료하려면 Enter를 누르세요...")
        return 1

    print("\n--- 스캔 실행 ---")
    args = argparse.Namespace(config="config.yaml", root=None, state_db=None,
                              brief_dir=None, dry_run=False)
    rc = cmd_run(args)
    if rc == 0:
        if first_time:
            print("\n첫 실행은 기준선 확보라 전체 파일이 '추가'로 기록됩니다.")
            print("내일부터는 바뀐 것만 브리핑에 올라옵니다.")
        _wizard_schedule(Path.cwd())
        cfg = load_config(cfg_path)
        brief_dir = Path(cfg["brief_dir"])
        if brief_dir.is_dir() and sys.stdin.isatty():
            try:
                os.startfile(brief_dir)  # 브리핑 폴더를 탐색기로 보여준다
            except OSError:
                pass
        print(f"\n브리핑 위치: {brief_dir.resolve()}")
    _ask("\n종료하려면 Enter를 누르세요...")
    return rc


def main(argv: list[str] | None = None) -> int:
    if (argv if argv is not None else sys.argv[1:]) == []:
        # 더블클릭 진입점: 웹 대시보드(브라우저). 실패 시 콘솔 위저드 폴백
        try:
            from watcher.web import run_web
            return run_web()
        except SystemExit:
            raise
        except Exception as e:
            print(f"화면 실행 실패({e}) — 콘솔 모드로 전환합니다.", file=sys.stderr)
            return run_wizard()
    parser = argparse.ArgumentParser(prog="watcher", description="프로젝트 산출물 변경 감시자")
    parser.add_argument("--config", default="config.yaml")
    sub = parser.add_subparsers(dest="command", required=True)

    p_init = sub.add_parser("init", help="config.yaml 생성")
    p_init.set_defaults(func=cmd_init)

    p_run = sub.add_parser("run", help="스캔 1회 실행")
    p_run.add_argument("--root", help="config의 root_path 대신 사용할 루트")
    p_run.add_argument("--state-db", help="config의 state_db 대신 사용할 DB 경로")
    p_run.add_argument("--brief-dir", help="config의 brief_dir 대신 사용할 브리핑 저장 폴더")
    p_run.add_argument("--dry-run", action="store_true", help="메일 발송 없이 브리핑 HTML만 저장")
    p_run.add_argument("--full", action="store_true",
                       help="크기·수정시각 단축 없이 전체 파일을 다시 해싱 (주기 점검용)")
    p_run.set_defaults(func=cmd_run)

    p_ack = sub.add_parser("ack-serve", help="5초 확인 로컬 웹 서버 (127.0.0.1)")
    p_ack.add_argument("--state-db", help="config의 state_db 대신 사용할 DB 경로")
    p_ack.add_argument("--port", type=int, default=None,
                       help="기본값: config ack_url의 포트")
    p_ack.set_defaults(func=cmd_ack_serve)

    p_hist = sub.add_parser("history", help="변경·확인 이력 조회")
    p_hist.add_argument("--state-db", help="config의 state_db 대신 사용할 DB 경로")
    p_hist.add_argument("--project", help="프로젝트 번호로 필터 (예: P24-031)")
    p_hist.set_defaults(func=cmd_history)

    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())
