#!/usr/bin/env python3
"""설정 항목 전수 조사 — `.env` 에 없이 코드 기본값으로 도는 것을 가른다.

사용자 지시(2026-08-05, 08-19 재확인): "내가 일부러 꺼둔 것이 있을 수
있으니 제대로 파악해." 그래서 이 도구는 **켜거나 끄지 않는다.** 지금
무엇이 어떤 값으로 도는지 표로 보이는 것까지만 한다.

읽는 곳 셋:
  · `app/core/config.py` 의 Settings 클래스 — 항목 이름·형·코드 기본값.
    (실행 중인 설정 객체가 아니라 **소스**를 읽는다. 실행 객체는 이미
    .env 가 덮어쓴 뒤라 "코드 기본값이 무엇인가"를 되찾을 수 없다.)
  · `backend/.env` — 실제로 적혀 있는 줄.
  · 저장소 전체 — 그 항목을 코드가 실제로 읽는 자리가 있는가
    (`settings.<이름>` / `getattr(settings, "<이름>"`).

산출: artifact/<날짜>_env_flag_audit/index.html (첫 줄 charset 선언).

사용:
  .venv/bin/python tools/env_flag_audit.py
  .venv/bin/python tools/env_flag_audit.py --out /경로/디렉토리
"""
from __future__ import annotations

import argparse
import ast
import html as H
import re

from pathlib import Path
from typing import Any, Dict, List, Optional

ROOT = Path(__file__).resolve().parent.parent          # backend/
REPO = ROOT.parent
CONFIG = ROOT / "app" / "core" / "config.py"
ENV = ROOT / ".env"


def parse_settings() -> List[Dict[str, Any]]:
    """Settings 클래스의 항목을 소스에서 읽는다 — 이름·형·기본값·주석."""
    src = CONFIG.read_text(encoding="utf-8")
    lines = src.splitlines()
    tree = ast.parse(src)
    cls = next(
        (n for n in tree.body
         if isinstance(n, ast.ClassDef) and n.name == "Settings"), None)
    if cls is None:
        raise SystemExit("config.py 에서 Settings 클래스를 못 찾았다")

    out: List[Dict[str, Any]] = []
    for node in cls.body:
        if not isinstance(node, ast.AnnAssign) or not isinstance(
                node.target, ast.Name):
            continue
        name = node.target.id
        if name.startswith("_") or name == "model_config":
            continue
        try:
            type_txt = ast.unparse(node.annotation)
        except Exception:            # noqa: BLE001 — 표시용
            type_txt = "?"
        default: Any = None
        default_txt = "(없음)"
        if node.value is not None:
            try:
                default_txt = ast.unparse(node.value)
            except Exception:        # noqa: BLE001
                default_txt = "?"
            try:
                default = ast.literal_eval(node.value)
            except Exception:        # noqa: BLE001 — Field(...) 등
                default = None
        # 바로 위에 붙은 주석 묶음을 설명으로 쓴다.
        note: List[str] = []
        i = node.lineno - 2
        while i >= 0 and lines[i].strip().startswith("#"):
            note.append(lines[i].strip().lstrip("# ").rstrip())
            i -= 1
        out.append({
            "name": name,
            "type": type_txt,
            "default_txt": default_txt,
            "default": default,
            "is_bool": type_txt.strip() == "bool",
            "note": " ".join(reversed(note)),
            "lineno": node.lineno,
        })
    return out


def parse_env() -> Dict[str, str]:
    if not ENV.is_file():
        return {}
    got: Dict[str, str] = {}
    for line in ENV.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, val = line.split("=", 1)
        got[key.strip().upper()] = val.strip()
    return got


def read_sites(names: List[str],
               decl_lines: Dict[str, int]) -> Dict[str, int]:
    """각 항목의 이름이 코드에 나타나는 자리 수 — 죽은 항목을 가른다.

    ★이름 자체를 센다. 읽는 모양이 하나가 아니기 때문이다 —
    `settings.x` · `_settings.x` · `getattr(settings, "x")` ·
    `getattr(settings, 'x')` · 설정 API 가 이름 문자열로 다루는 자리.
    한 모양만 세면 살아 있는 항목을 죽은 것으로 읽는다(실제로 세어 확인했다).

    config.py 도 센다. **선언 줄만** 빼고 센다 — 그 파일 안에서 검증기가
    읽는 항목이 있어서(예: 배포 환경에서 기본 열쇠를 막는 검사) 파일째
    빼면 살아 있는 항목이 죽은 것으로 나온다(실제로 세어 확인했다).
    """
    counts = {n: 0 for n in names}
    if not names:
        return counts
    decl_line = {n: ln for n, ln in decl_lines.items()}
    pattern = re.compile(
        r"\b(" + "|".join(re.escape(n) for n in sorted(
            names, key=len, reverse=True)) + r")\b")
    for path in (ROOT / "app").rglob("*.py"):
        try:
            text = path.read_text(encoding="utf-8")
        except Exception:            # noqa: BLE001
            continue
        is_config = path.resolve() == CONFIG.resolve()
        for lineno, line in enumerate(text.splitlines(), 1):
            for m in pattern.finditer(line):
                name = m.group(1)
                if is_config and decl_line.get(name) == lineno:
                    continue
                counts[name] += 1
    return counts


def truthy(text: str) -> Optional[bool]:
    low = text.strip().strip('"').strip("'").lower()
    if low in {"1", "true", "yes", "on"}:
        return True
    if low in {"0", "false", "no", "off"}:
        return False
    return None


def esc(x: Any) -> str:
    return H.escape("" if x is None else str(x))


def build_html(rows: List[Dict[str, Any]], env: Dict[str, str]) -> str:
    total = len(rows)
    missing = [r for r in rows if not r["in_env"]]
    bools = [r for r in rows if r["is_bool"]]
    bool_missing = [r for r in bools if not r["in_env"]]
    bool_missing_off = [r for r in bool_missing if r["effective"] is False]
    bool_missing_on = [r for r in bool_missing if r["effective"] is True]
    unread = [r for r in rows if r["sites"] == 0]

    def table(items, caption, cols_note=""):
        head = ("<tr><th>설정 이름</th><th>.env</th><th>실제 값</th>"
                "<th>형</th><th>읽는 자리</th><th>설명(코드 주석 첫 줄)</th></tr>")
        body = []
        for r in items:
            state = ("<span class='on'>켜짐</span>" if r["effective"] is True
                     else "<span class='off'>꺼짐</span>"
                     if r["effective"] is False else esc(r["effective_txt"]))
            body.append(
                f"<tr><td><code>{esc(r['env_key'])}</code><br>"
                f"<span class='py'>{esc(r['name'])}"
                f" <a href='#' title='config.py:{r['lineno']}'>"
                f":{r['lineno']}</a></span></td>"
                f"<td>{'있음' if r['in_env'] else '<b>없음</b>'}</td>"
                f"<td>{state}</td><td>{esc(r['type'])}</td>"
                f"<td>{r['sites']}</td>"
                f"<td class='note'>{esc(r['note'][:220])}</td></tr>")
        return (f"<h3>{caption} <span class='num'>{len(items)}</span></h3>"
                f"{cols_note}<table>{head}{''.join(body)}</table>")

    return (
        '<meta charset="utf-8">\n'
        "<title>설정 항목 전수 조사</title>\n<style>\n"
        "body{font-family:'Apple SD Gothic Neo',sans-serif;background:#111;"
        "color:#ddd;padding:22px;max-width:1400px;margin:auto;line-height:1.6}\n"
        "h1{color:#fff;margin-bottom:2px}h2{color:#8cf;margin:26px 0 6px;"
        "border-bottom:1px solid #333;padding-bottom:5px}\n"
        "h3{color:#a0c4ff;margin:18px 0 4px;font-size:15px}\n"
        "table{border-collapse:collapse;width:100%;margin:8px 0}\n"
        "th,td{border:1px solid #333;padding:5px 8px;font-size:12.5px;"
        "vertical-align:top}\n"
        "th{background:#1a1a2e;color:#a0c4ff;text-align:left}\n"
        "code{background:#222;padding:1px 5px;border-radius:3px;color:#fc8}\n"
        ".py{color:#777;font-size:11px}.py a{color:#777;text-decoration:none}\n"
        ".on{color:#6d6}.off{color:#e77}.note{color:#999}\n"
        ".num{color:#fc8;font-weight:600}\n"
        ".lead{color:#999;font-size:13px}\n"
        "</style>\n"
        "<h1>설정 항목 전수 조사</h1>\n"
        "<p class='lead'>source=<code>app/core/config.py</code> Settings 클래스 · "
        "<code>backend/.env</code> · 저장소 전체의 읽는 자리. "
        "<b>이 표는 아무것도 켜거나 끄지 않는다</b> — 지금 무엇이 어떤 값으로 "
        "도는지 보이는 것까지다.</p>\n"
        f"<h2>한눈에</h2><table>"
        f"<tr><td>설정 항목 전체</td><td class='num'>{total}</td></tr>"
        f"<tr><td><code>.env</code> 에 없어 코드 기본값으로 도는 것</td>"
        f"<td class='num'>{len(missing)}</td></tr>"
        f"<tr><td>그중 켜고 끄는 항목</td><td class='num'>{len(bool_missing)}</td></tr>"
        f"<tr><td>그중 기본 꺼짐 / 기본 켜짐</td>"
        f"<td class='num'>{len(bool_missing_off)} / {len(bool_missing_on)}</td></tr>"
        f"<tr><td>코드가 읽는 자리가 하나도 없는 항목</td>"
        f"<td class='num'>{len(unread)}</td></tr>"
        f"<tr><td><code>.env</code> 줄 수</td><td class='num'>{len(env)}</td></tr>"
        "</table>\n"
        "<h2>켜고 끄는 항목 — <code>.env</code> 에 없는 것</h2>"
        "<p class='lead'>일부러 끈 것과 그냥 빠진 것을 사람이 갈라야 하는 목록이다.</p>"
        + table(bool_missing_off, "기본 꺼짐")
        + table(bool_missing_on, "기본 켜짐")
        + "<h2>켜고 끄는 항목 — <code>.env</code> 에 있는 것</h2>"
        + table([r for r in bools if r["in_env"]], "명시된 것")
        + "<h2>코드가 읽는 자리가 없는 항목</h2>"
        "<p class='lead'>설정에는 있는데 아무도 안 읽는다 — 지워도 되는지 "
        "사람이 확인할 목록.</p>"
        + table(unread, "읽는 자리 0")
        + "<h2>켜고 끄는 것이 아닌 항목 전부</h2>"
        + table([r for r in rows if not r["is_bool"]], "값 항목")
    )


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", default="")
    args = ap.parse_args()

    settings_rows = parse_settings()
    env = parse_env()
    sites = read_sites(
        [r["name"] for r in settings_rows],
        {r["name"]: r["lineno"] for r in settings_rows})

    for r in settings_rows:
        key = r["name"].upper()
        r["env_key"] = key
        r["in_env"] = key in env
        r["sites"] = sites.get(r["name"], 0)
        if r["in_env"]:
            r["effective_txt"] = env[key]
            r["effective"] = truthy(env[key]) if r["is_bool"] else env[key]
        else:
            r["effective_txt"] = r["default_txt"]
            r["effective"] = r["default"] if r["is_bool"] else r["default_txt"]

    out_dir = Path(args.out) if args.out else (
        REPO / "artifact" / "20260820_env_flag_audit")
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "index.html").write_text(
        build_html(settings_rows, env), encoding="utf-8")

    missing = [r for r in settings_rows if not r["in_env"]]
    bools = [r for r in settings_rows if r["is_bool"]]
    bool_missing = [r for r in bools if not r["in_env"]]
    print(f"설정 항목 {len(settings_rows)}개 · .env 없음 {len(missing)}개")
    print(f"켜고 끄는 항목 {len(bools)}개 · 그중 .env 없음 {len(bool_missing)}개 "
          f"(기본 꺼짐 "
          f"{len([r for r in bool_missing if r['effective'] is False])})")
    print(f"읽는 자리 0 인 항목 "
          f"{len([r for r in settings_rows if r['sites'] == 0])}개")
    print(f"산출: {out_dir / 'index.html'}")


if __name__ == "__main__":
    main()
