# -*- coding: utf-8 -*-
"""변경 목록 → 일일 브리핑 HTML.

Stage 2: 파일 수준 변경(추가/수정/삭제)만 담는다.
Stage 3에서 "확인 필요 M건 + 근거" 섹션이 추가된다 (build_brief의 구조는 그걸 수용하게 잡아둠).
메일 클라이언트 호환을 위해 인라인 스타일의 단순 테이블만 쓴다.
"""

from __future__ import annotations

import html
import re
from datetime import datetime
from pathlib import PurePosixPath

from watcher.state import KIND_LABEL as _KIND_LABEL
from watcher.state import Change

_KIND_COLOR = {"added": "#1a7f37", "modified": "#9a6700", "removed": "#cf222e"}

_STYLE_TABLE = "border-collapse:collapse;width:100%;font-size:14px"
_STYLE_TH = ("text-align:left;padding:6px 10px;border-bottom:2px solid #d0d7de;"
             "background:#f6f8fa;color:#57606a")
_STYLE_TD = "padding:6px 10px;border-bottom:1px solid #d0d7de"


def _esc(s: str) -> str:
    return html.escape(s, quote=True)


def _change_row(c: Change, ack_base: str | None, ack_tokens: dict | None,
                info: dict | None = None) -> str:
    p = PurePosixPath(c.path)
    old = (c.old_sha256 or "—")[:12]
    new = (c.new_sha256 or "—")[:12]
    color = _KIND_COLOR[c.kind]
    token = (ack_tokens or {}).get(c.id)
    if ack_base and c.id and token:
        ack = (f'<a href="{_esc(ack_base)}/ack?id={c.id}&amp;t={_esc(token)}" '
               f'style="color:#0969da">확인했음 기록</a>')
    else:
        ack = "—"
    # 실무 언어는 파일명이 아니라 도면번호·Rev — 표제란 값이 있으면 앞세운다
    # (설계 10년차 검토 반영, 2026-08-28)
    if info and info.get("dwg"):
        rev = f' Rev {_esc(info["rev"])}' if info.get("rev") else ""
        file_cell = (f'<strong>도면 {_esc(info["dwg"])}</strong>{rev}'
                     f'<br><span style="color:#8c959f;font-size:12px">'
                     f'{_esc(p.name)}</span>')
    else:
        file_cell = _esc(p.name)
    return (
        f'<tr>'
        f'<td style="{_STYLE_TD}"><strong style="color:{color}">{_KIND_LABEL[c.kind]}</strong></td>'
        f'<td style="{_STYLE_TD}">{file_cell}</td>'
        f'<td style="{_STYLE_TD}">{_esc(str(p.parent))}</td>'
        f'<td style="{_STYLE_TD}"><code>{old} &rarr; {new}</code></td>'
        f'<td style="{_STYLE_TD}">{ack}</td>'
        f'</tr>'
    )


def _issues_section(issues) -> str:
    from watcher.consistency import CODE_LABEL
    blocks = []
    for iss in issues:
        evidence = "".join(
            f'<li style="color:#57606a">{_esc(ev)}</li>' for ev in iss.evidence)
        blocks.append(f"""
  <div style="border:1px solid #cf222e;border-left:6px solid #cf222e;border-radius:6px;
              padding:10px 14px;margin:0 0 10px;background:#fff5f5">
    <strong style="color:#cf222e">{_esc(CODE_LABEL[iss.code])}</strong>
    &nbsp;<code>{_esc(iss.path)}</code>
    <div style="margin:4px 0 2px">{_esc(iss.message)}</div>
    <ul style="margin:4px 0 0;padding-left:20px;font-size:13px">{evidence}</ul>
  </div>""")
    return "\n".join(blocks)


def _fmt_row_values(s: str | None) -> str:
    """diff가 str(dict)로 남긴 행 값을 'TAG=.., SPEC=..' 꼴로 정리."""
    import ast
    try:
        d = ast.literal_eval(s or "")
        return ", ".join(f"{k}={v}" for k, v in d.items() if str(v).strip())
    except (ValueError, SyntaxError):
        return s or ""


def _delta_badge(old: str | None, new: str | None) -> str:
    """수량처럼 숫자인 값의 증감 배지 (+2 초록 / -1 빨강) — Odoo BOM 비교 규칙."""
    try:
        d = float(new) - float(old)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        return ""
    if d == 0:
        return ""
    color = "#1a7f37" if d > 0 else "#cf222e"
    sign = "+" if d > 0 else ""
    num = f"{d:g}"
    return (f' <span style="color:{color};font-weight:700">'
            f"({sign}{num})</span>")


def _row_key_cell(i) -> str:
    """행 식별 칸: 키 + 품명·규격 (어떤 부품인지 열어보지 않아도 알게 — 2026-08-25 요청)."""
    label = f'<br><span style="color:#57606a;font-size:12px">{_esc(i.label)}</span>' \
        if getattr(i, "label", None) else ""
    return f'<code>{_esc(i.key)}</code>{label}'


def _row_diff_table(items, extra_cell=None) -> str:
    """BOM 등 표 변경을 색상 표로: 추가=파랑, 삭제=빨강 취소선, 변경=앰버+델타.
    extra_cell: 항목→HTML 콜백 — 웹 상세가 [오탐 삭제] 버튼 칸을 붙일 때 사용
    (브리핑 메일에는 없음)."""
    trs = []
    def _x(i):
        return f'<td style="{_STYLE_TD}">{extra_cell(i)}</td>' if extra_cell else ""
    for i in items:
        if i.kind == "ROW_ADDED":
            trs.append(f'<tr style="background:#dbeafe;color:#1f2328">'
                       f'<td style="{_STYLE_TD};color:#1d4ed8;font-weight:700">행 추가</td>'
                       f'<td style="{_STYLE_TD}">{_row_key_cell(i)}</td>'
                       f'<td style="{_STYLE_TD}">{_esc(_fmt_row_values(i.new))}</td>'
                       f'<td style="{_STYLE_TD};color:#8c959f">{_esc(i.where)}</td>{_x(i)}</tr>')
        elif i.kind == "ROW_REMOVED":
            trs.append(f'<tr style="background:#fee2e2;color:#1f2328">'
                       f'<td style="{_STYLE_TD};color:#cf222e;font-weight:700">행 삭제</td>'
                       f'<td style="{_STYLE_TD}">{_row_key_cell(i)}</td>'
                       f'<td style="{_STYLE_TD};text-decoration:line-through;color:#57606a">'
                       f'{_esc(_fmt_row_values(i.old))}</td>'
                       f'<td style="{_STYLE_TD};color:#8c959f">{_esc(i.where)}</td>{_x(i)}</tr>')
        elif i.kind == "ROW_CHANGED":
            key, _, col = i.key.rpartition(".")
            label = (f'<br><span style="color:#57606a;font-size:12px">'
                     f'{_esc(i.label)}</span>') if getattr(i, "label", None) else ""
            trs.append(f'<tr style="background:#fef3c7;color:#1f2328">'
                       f'<td style="{_STYLE_TD};color:#9a6700;font-weight:700">값 변경</td>'
                       f'<td style="{_STYLE_TD}"><code>{_esc(key or i.key)}</code>{label}</td>'
                       f'<td style="{_STYLE_TD}">{_esc(col)}: {_esc(i.old or "—")} '
                       f'&rarr; <strong>{_esc(i.new or "—")}</strong>'
                       f'{_delta_badge(i.old, i.new)}</td>'
                       f'<td style="{_STYLE_TD};color:#8c959f">{_esc(i.where)}</td>{_x(i)}</tr>')
    if not trs:
        return ""
    return (f'<table style="{_STYLE_TABLE};margin:6px 0 8px">'
            f'<tr><th style="{_STYLE_TH}">구분</th><th style="{_STYLE_TH}">행</th>'
            f'<th style="{_STYLE_TH}">내용</th><th style="{_STYLE_TH}">위치</th>'
            + (f'<th style="{_STYLE_TH}"></th>' if extra_cell else '') + '</tr>'
            + "".join(trs) + "</table>")


# 수량 성격의 열 이름 — BOM 발주 영향 합계 계산용
_QTY_COL = re.compile(r"(수량|QTY|Q'TY|Q-TY|EA)\s*$", re.IGNORECASE)
_QTY_IN_REPR = re.compile(r"'(?:수량|QTY|Q'TY|EA)'\s*:\s*'?(-?\d+(?:\.\d+)?)",
                          re.IGNORECASE)


def bom_impact(items) -> tuple[int, int, int, float | None]:
    """행 변경의 발주 영향 요약: (신규 행, 삭제 행, 값 변경 건, 수량 증감 합계).
    수량 열을 못 찾으면 합계는 None. 구매 전달용 한 줄 (설계 10년차 검토)."""
    added = sum(1 for i in items if i.kind == "ROW_ADDED")
    removed = sum(1 for i in items if i.kind == "ROW_REMOVED")
    changed = sum(1 for i in items if i.kind == "ROW_CHANGED")
    delta, seen = 0.0, False
    for i in items:
        if i.kind == "ROW_CHANGED":
            if _QTY_COL.search(i.key.rpartition(".")[2]):
                try:
                    delta += float(i.new) - float(i.old)
                    seen = True
                except (TypeError, ValueError):
                    pass
        elif i.kind in ("ROW_ADDED", "ROW_REMOVED"):
            m = _QTY_IN_REPR.search(i.new or i.old or "")
            if m:
                seen = True
                delta += float(m.group(1)) * (1 if i.kind == "ROW_ADDED" else -1)
    return added, removed, changed, (delta if seen else None)


def bom_impact_line(items) -> str:
    """행 변경 표 위에 붙는 발주 영향 한 줄 HTML. 행 변경이 없으면 빈 문자열."""
    a, r, c, d = bom_impact(items)
    if not (a or r or c):
        return ""
    parts = []
    if a:
        parts.append(f'<b style="color:#1d4ed8">신규 {a}행</b>')
    if r:
        parts.append(f'<b style="color:#cf222e">삭제 {r}행</b>')
    if c:
        parts.append(f'<b style="color:#9a6700">값 변경 {c}건</b>')
    dtxt = ""
    if d:
        dv = int(d) if float(d).is_integer() else round(d, 2)
        dtxt = f' · 수량 합계 <b>{"+" if d > 0 else ""}{dv}개</b>'
    return (f'<div style="font-size:13px;margin:2px 0 6px;color:#1f2328">'
            f'발주 영향: {" / ".join(parts)}{dtxt}</div>')


# 개별 변경 항목의 구분 라벨·색 (표 렌더용)
_ITEM_KIND = {
    "NEW_REVISION_OF": ("이전 판", "#57606a", "#f6f8fa"),
    "FIELD_CHANGED": ("값 변경", "#9a6700", "#fef3c7"),
    "FIELD_ADDED": ("값 추가", "#1d4ed8", "#dbeafe"),
    "FIELD_REMOVED": ("값 삭제", "#cf222e", "#fee2e2"),
    "TEXT_CHANGED": ("글자 변경", "#9a6700", "#fef3c7"),
    "TEXT_ADDED": ("글자 추가", "#1d4ed8", "#dbeafe"),
    "TEXT_REMOVED": ("글자 삭제", "#cf222e", "#fee2e2"),
    "PAGE_VISUAL_CHANGED": ("그림 변경", "#7c3aed", "#f3e8ff"),
}


def item_diff_table(items, cap_of=None, extra_cell=None) -> str:
    """개별 변경 항목(글자·값·그림)을 정렬된 표로 — 줄글 목록은 항목이 많으면
    읽기 불편하다는 피드백 반영 (2026-08-28). 전/후 사진은 해당 행 바로 아래
    전체 폭 행으로. extra_cell: 웹 상세의 [오탐 삭제] 버튼 칸 콜백."""
    if not items:
        return ""
    ncols = 5 + (1 if extra_cell else 0)
    trs = []
    for i in items:
        label, color, bg = _ITEM_KIND.get(i.kind, (i.kind, "#57606a", "#f6f8fa"))
        x = f'<td style="{_STYLE_TD}">{extra_cell(i)}</td>' if extra_cell else ""
        trs.append(
            f'<tr style="background:{bg};color:#1f2328">'
            f'<td style="{_STYLE_TD};color:{color};font-weight:700;'
            f'white-space:nowrap">{label}</td>'
            f'<td style="{_STYLE_TD}"><code>{_esc(i.key)}</code></td>'
            f'<td style="{_STYLE_TD};color:#57606a">{_esc(i.old or "—")}</td>'
            f'<td style="{_STYLE_TD}"><strong>{_esc(i.new or "—")}</strong>'
            f'{_delta_badge(i.old, i.new)}</td>'
            f'<td style="{_STYLE_TD};color:#8c959f">{_esc(i.where)}</td>{x}</tr>')
        cap = cap_of(i) if cap_of else None
        if cap:
            block = render_capture(cap)
            if block:
                trs.append(f'<tr><td colspan="{ncols}" style="{_STYLE_TD}">'
                           f'{block}</td></tr>')
    return (f'<table style="{_STYLE_TABLE};margin:6px 0 8px">'
            f'<tr><th style="{_STYLE_TH}">구분</th><th style="{_STYLE_TH}">항목</th>'
            f'<th style="{_STYLE_TH}">이전</th><th style="{_STYLE_TH}">새 값</th>'
            f'<th style="{_STYLE_TH}">위치</th>'
            + (f'<th style="{_STYLE_TH}"></th>' if extra_cell else '')
            + '</tr>' + "".join(trs) + '</table>')


def _one_img(cap: dict, label: str, border: str) -> str:
    return (f'<div style="flex:1;min-width:220px">'
            f'<div style="font-size:12px;font-weight:700;color:{border};margin-bottom:3px">'
            f'{label}</div>'
            f'<img src="data:{cap["mime"]};base64,{cap["b64"]}" '
            f'style="max-width:100%;border:1px solid {border};border-radius:6px;display:block">'
            f'<span style="color:#8c959f;font-size:11.5px">{_esc(cap["caption"])}</span></div>')


def render_capture(cap: dict) -> str:
    """전/후 캡처 렌더. 전 사진이 있으면 나란히, 없으면 후만 (덮어쓰기라 구버전 없음)."""
    if not cap:
        return ""
    # 구형(단일) 캡처 호환: after/before 키가 없으면 통째로 after로 취급
    after = cap.get("after") if "after" in cap or "before" in cap else cap
    before = cap.get("before")
    if after is None:
        return ""
    inner = ""
    if before:
        inner += _one_img(before, "변경 전", "#8c959f")
    inner += _one_img(after, "변경 후", "#dc2626")
    note = ("빨간 박스가 변경된 자리 · 좌: 이전 판, 우: 새 판" if before
            else "빨간 박스가 변경된 자리 (이전 판 파일이 없어 새 판만 표시)")
    return (f'<div style="display:flex;gap:12px;flex-wrap:wrap;margin:6px 0 4px">{inner}</div>'
            f'<div style="color:#8c959f;font-size:12px;margin-bottom:8px">{note}</div>')


_DETAIL_CAP = 40  # 항목이 수백 개면(대형 도면 재출력 등) 읽을 수 없다 — 상위만 표시


def _cap_of(captures: dict | None, path: str, item):
    """항목의 캡처. 신형 키(kind|key — 같은 key끼리 덮어쓰기 방지)를 먼저,
    구형 저장분(key만)은 폴백으로 찾는다."""
    if not captures:
        return None
    return captures.get((path, f"{item.kind}|{item.key}")) \
        or captures.get((path, item.key))


def _is_common_item(i) -> bool:
    """전 페이지 반복(표제란·양식) 변경 — 개별 변경과 분리해 접어 보인다."""
    return i.key.startswith("공통위치") or "전 페이지 공통" in i.where


def split_display_items(items, cap_lookup, plain_cap: int):
    """상세 항목의 표시용 분류 — 브리핑과 웹 상세가 공용한다
    (같은 로직을 두 곳에 두었다가 같은 버그를 두 번 고친 자리, 2026-08-26 통합).
    캡처(전/후 사진)가 붙은 항목은 표시 캡에서 자르지 않는다.
    돌려주는 값: (안내문, 개별 변경, 전 페이지 공통, 표 행, 생략 수)"""
    row_items = [i for i in items if i.kind.startswith("ROW_")]
    others, plain_shown, omitted = [], 0, 0
    for i in items:
        if i.kind.startswith("ROW_"):
            continue
        if cap_lookup(i):
            others.append(i)
        elif plain_shown < plain_cap:
            others.append(i)
            plain_shown += 1
        else:
            omitted += 1
    omitted += max(0, len(row_items) - plain_cap)
    notices = [i for i in others if i.kind == "CHECK_SKIPPED"]
    commons = [i for i in others if i.kind != "CHECK_SKIPPED" and _is_common_item(i)]
    regular = [i for i in others
               if i.kind != "CHECK_SKIPPED" and not _is_common_item(i)]
    return notices, regular, commons, row_items[:plain_cap], omitted


def _details_rows(path: str, items, captures: dict | None = None,
                  info: dict | None = None) -> str:
    notices, regular, commons, row_items, omitted = split_display_items(
        items, lambda i: _cap_of(captures, path, i), _DETAIL_CAP)

    def _cap(i):  # 변경 위치 캡처 — 비교 결과를 도면 위에 '표시'만 한다
        return _cap_of(captures, path, i)

    inner = ""
    # 설계자가 리비전 블록에 남긴 공식 개정 사유 — "왜 바뀌었나"가 한 줄로
    if info and info.get("note"):
        inner += (f'<div style="border:1px solid #d0d7de;border-left:5px solid '
                  f'#1d4ed8;border-radius:6px;padding:8px 12px;margin:6px 0;'
                  f'background:#f6f8fa;font-size:13px;color:#1f2328">'
                  f'개정 사유(리비전 블록): <strong>{_esc(info["note"])}</strong>'
                  f'</div>')
    # 검사 한계 고지는 값 변경(→) 표가 아니라 안내 박스로 (2026-08-25 UX 검토)
    for i in notices:
        inner += (f'<div style="border:1px solid #9a6700;border-left:5px solid #9a6700;'
                  f'border-radius:6px;padding:8px 12px;margin:6px 0;background:#fff8e5;'
                  f'font-size:13px"><strong>{_esc(i.key)}</strong> — {_esc(i.new or "")}</div>')
    if regular:
        inner += item_diff_table(regular, cap_of=_cap)
    if commons:
        inner += (f'<details style="margin:6px 0"><summary style="font-size:13px;'
                  f'color:#57606a;cursor:pointer">전 페이지 공통 변경(표제란·양식류) '
                  f'{len(commons)}건 — 펼쳐 보기</summary>'
                  f'{item_diff_table(commons, cap_of=_cap)}</details>')
    inner += bom_impact_line(row_items) + _row_diff_table(row_items)
    if omitted:
        inner += (f'<div style="color:#8c959f;font-size:12.5px;margin-top:4px">'
                  f"외 {omitted}건 생략 — 변경이 매우 많은 문서라 위 {_DETAIL_CAP}건만 "
                  f"표시했습니다. 전체는 감시 PC 화면의 변경 상세에서 볼 수 있습니다.</div>")
    return (f'<tr><td colspan="5" style="{_STYLE_TD};background:#fafbfc">'
            f"{inner}</td></tr>")


def _scan_errors_section(scan_errors) -> str:
    lis = "".join(f'<li>{_esc(p)} — <span style="color:#57606a">{_esc(err)}</span></li>'
                  for p, err in scan_errors)
    return f"""
  <div style="border:1px solid #9a6700;border-left:6px solid #9a6700;border-radius:6px;
              padding:10px 14px;margin:0 0 12px;background:#fff8e5">
    <strong style="color:#9a6700">읽지 못한 파일 {len(scan_errors)}건</strong>
    <div style="font-size:13px;margin-top:4px">잠김·권한 문제로 이번 실행에서 확인하지
    못했습니다. 다음 성공 시 변경 감지가 재개됩니다.</div>
    <ul style="margin:4px 0 0;padding-left:20px;font-size:13px">{lis}</ul>
  </div>"""


def _summary_toc(changes, details: dict, doc_info: dict | None = None) -> str:
    """상단 요약 목차: 파일별로 무엇이 몇 건인지 한눈에 (2026-08-25 UX 검토 —
    '변경 1건' 헤더 아래 항목 수백 개가 예고 없이 이어지던 문제)."""
    if not changes:
        return ""
    lis = []
    for i, c in enumerate(changes):
        items = details.get(c.path, [])
        vis = sum(1 for it in items if it.kind == "PAGE_VISUAL_CHANGED")
        txt = sum(1 for it in items if it.kind.startswith("TEXT_"))
        row = sum(1 for it in items if it.kind.startswith("ROW_"))
        rev = next((it for it in items
                    if it.kind == "FIELD_CHANGED" and it.key == "REV"), None)
        parts = []
        if vis:
            parts.append(f"그림 변경 위치 {vis}곳")
        if txt:
            parts.append(f"글자 변경 {txt}건")
        if row:
            parts.append(f"표(BOM) 변경 {row}건")
        if rev:
            parts.append(f"Rev {rev.old}→{rev.new}")
        name = PurePosixPath(c.path).name if "/" in c.path else c.path
        info = (doc_info or {}).get(c.path)
        if info and info.get("dwg"):  # 실무 언어: 도면번호가 파일명보다 먼저
            name = f'도면 {info["dwg"]} — {name}'
        lis.append(f'<li style="margin:3px 0"><a href="#chg{i}" '
                   f'style="color:#1d4ed8">{_esc(name)}</a>'
                   f'<span style="color:#57606a"> — {_esc(", ".join(parts) or KIND_LABEL_TEXT(c))}'
                   f"</span></li>")
    return ('<div style="border:1px solid #d0d7de;border-radius:6px;'
            'padding:10px 14px;margin:0 0 14px;background:#f6f8fa">'
            '<strong>오늘 확인할 것</strong>'
            f'<ul style="margin:6px 0 0;padding-left:20px">{"".join(lis)}</ul></div>')


def KIND_LABEL_TEXT(c) -> str:
    return _KIND_LABEL.get(c.kind, c.kind)


def build_brief(changes: list[Change], run_at: datetime, root: str,
                details: dict | None = None, issues: list | None = None,
                project: str | None = None, ack_base: str | None = None,
                ack_tokens: dict | None = None,
                scan_errors: list | None = None,
                captures: dict | None = None,
                scanned_count: int | None = None,
                doc_info: dict | None = None) -> str:
    """브리핑 HTML 문서 전체를 돌려준다. 변경 0건이어도 문서는 만든다(기록용).

    details: {path: [ChangeItem]} 파일별 내용 변경 상세 (Stage 3)
    issues:  [Issue] 일관성 검사 결과 (Stage 3)
    project: 프로젝트별 브리핑이면 정규 프로젝트 번호 (Stage 4)
    scanned_count: 이번 검사에서 확인한 파일 수 — 0건 날의 '살아있음' 신호
    """
    details = details or {}
    issues = issues or []
    date_str = run_at.strftime("%Y-%m-%d %H:%M")
    n = len(changes)
    proj_label = f"[{project}] " if project else ""
    scanned_note = (f" · 파일 {scanned_count}개 검사함"
                    if scanned_count is not None else "")

    if changes:
        rows = "\n".join(
            f'<tbody id="chg{i}">'
            + _change_row(c, ack_base, ack_tokens, (doc_info or {}).get(c.path))
            + (_details_rows(c.path, details[c.path], captures,
                             (doc_info or {}).get(c.path))
               if c.path in details else "")
            + "</tbody>"
            for i, c in enumerate(changes))
        body = f"""
  {_summary_toc(changes, details, doc_info)}
  <table style="{_STYLE_TABLE}">
    <tr>
      <th style="{_STYLE_TH}">구분</th>
      <th style="{_STYLE_TH}">파일</th>
      <th style="{_STYLE_TH}">폴더</th>
      <th style="{_STYLE_TH}">변경 증거 (파일 지문)</th>
      <th style="{_STYLE_TH}">확인</th>
    </tr>
    {rows}
  </table>"""
    else:
        body = (f'<p style="color:#1a7f37">변경된 산출물이 없습니다 — 감시는 정상'
                f' 동작 중입니다{_esc(scanned_note)}.</p>')

    return f"""<!DOCTYPE html>
<html lang="ko">
<head><meta charset="utf-8"><title>산출물 변경 브리핑 {_esc(date_str)}</title></head>
<body style="font-family:'Malgun Gothic',Segoe UI,sans-serif;color:#1f2328;
             max-width:860px;margin:0 auto;padding:24px">
  <h2 style="margin:0 0 4px">{_esc(proj_label)}산출물 변경 브리핑 — 변경 {n}건
    {f'/ <span style="color:#cf222e">검토 필요 {len(issues)}건</span>' if issues else ''}</h2>
  <p style="color:#57606a;margin:0 0 16px">
    {_esc(date_str)} 기준 · 감시 루트: {_esc(root)}{_esc(scanned_note)}</p>
  {_scan_errors_section(scan_errors) if scan_errors else ''}
  {f'<h3 style="margin:16px 0 8px">검토 필요 {len(issues)}건</h3>{_issues_section(issues)}'
   if issues else ''}
  <h3 style="margin:16px 0 8px">변경 목록</h3>
  {body}
  <p style="color:#8c959f;font-size:12px;margin-top:24px">
    Drev — 읽기 전용 감시. 이 메일은 변경 '사실'의 기록이며,
    내용 판단은 담당자가 합니다. 각 변경의 [확인했음 기록] 링크는 감시 PC에서
    누르면 확인 이력으로 저장됩니다.</p>
</body>
</html>
"""


def brief_filename(run_at: datetime, project: str | None = None) -> str:
    # 표제란에서 온 프로젝트 문자열이 그대로 들어온다 — 경로 구분자·Windows 금지
    # 문자를 소독하지 않으면 파일 생성이 죽거나 brief_dir 밖에 쓰게 된다
    if project:
        safe = re.sub(r'[\\/:*?"<>|\s.]+', "_", project).strip("_") or "무명"
        suffix = f"_{safe}"
    else:
        suffix = ""
    return f"brief_{run_at.strftime('%Y%m%d')}{suffix}.html"
