# _St 기준 상대경로: AutoDistribute\AutoDistribute.py
"""개인용 _St 배포 ZIP 생성 및 WinSCP SFTP 업로더."""

from __future__ import annotations

import argparse
import hashlib
import html
import json
import os
import re
import queue
import shutil
import subprocess
import sys
import tempfile
import threading
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence

APP_VERSION = "4.0.0"
OUTPUT_NAME = "_St.zip"
ENV_NAME = ".env.conf"
VERSION_FILE_NAME = "_St_ver.html"
DEFAULT_VERSION_URL = "http://aiwk.yjm.kr/down/_St_ver.html"
TARGET_DIR_NAMES = (
    "Analyze8780",
    "Auto8700",
    "Cache8701",
    "IG_StYellow",
    "RTC8790_8791",
    "Shared",
    "WS8771",
)

EXCLUDED_DIR_NAMES = {
    "__pycache__",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    ".tox",
    ".nox",
    "htmlcov",
    "logs",
    "data",
}
EXCLUDED_FILE_SUFFIXES = {".pyc", ".pyo", ".log", ".tmp", ".temp"}
EXCLUDED_FILE_NAMES = {".coverage", "thumbs.db", ".ds_store"}


@dataclass(frozen=True)
class BuildStats:
    st_root: Path
    output: Path
    included_files: int
    excluded_files: int
    excluded_dirs: int
    output_bytes: int
    version: str
    build: int
    sha256: str
    local_version_file: Path
    server_version_file: Path


@dataclass(frozen=True)
class VersionMeta:
    version: str
    build: int
    file: str = OUTPUT_NAME
    sha256: str = ""


@dataclass(frozen=True)
class SftpConfig:
    winscp_exe: Path
    open_command: str
    remote_file: str
    remote_version_file: str
    version_url: str
    log_path: Path


@dataclass(frozen=True)
class UploadStats:
    local_file: Path
    remote_file: str
    winscp_exe: Path
    log_path: Path
    returncode: int


class BuildError(RuntimeError):
    pass


class ConfigError(RuntimeError):
    pass


class UploadError(RuntimeError):
    pass


def tool_dir() -> Path:
    return Path(__file__).resolve().parent


def resolve_st_root(base_dir: Path | None = None) -> Path:
    """AutoDistribute 폴더의 바로 상위 폴더를 _St 루트로 사용한다."""
    base = (base_dir or tool_dir()).resolve()
    root = base.parent
    missing = [name for name in TARGET_DIR_NAMES if not (root / name).is_dir()]
    if missing:
        raise BuildError(
            "지정 배포 폴더가 없습니다: " + ", ".join(missing) + f"\n기준 위치: {root}"
        )
    return root


def default_output_path(base_dir: Path | None = None) -> Path:
    return (base_dir or tool_dir()).resolve() / OUTPUT_NAME


def default_env_path(base_dir: Path | None = None) -> Path:
    return (base_dir or tool_dir()).resolve() / ENV_NAME


def is_read_document(path: Path) -> bool:
    name = path.name.casefold()
    return name.startswith("read_") or name.startswith("ai_read")


def is_excluded_file(path: Path) -> bool:
    name = path.name.casefold()
    if is_read_document(path):
        return True
    if name in EXCLUDED_FILE_NAMES:
        return True
    if path.suffix.casefold() in EXCLUDED_FILE_SUFFIXES:
        return True
    return False


def _iter_target_files(st_root: Path) -> tuple[list[tuple[Path, str]], int, int]:
    included: list[tuple[Path, str]] = []
    excluded_files = 0
    excluded_dirs = 0

    for target_name in TARGET_DIR_NAMES:
        target_root = st_root / target_name
        for root_text, dirs, files in os.walk(target_root, topdown=True, followlinks=False):
            root = Path(root_text)

            kept_dirs: list[str] = []
            for dirname in dirs:
                if dirname.casefold() in EXCLUDED_DIR_NAMES:
                    excluded_dirs += 1
                else:
                    kept_dirs.append(dirname)
            dirs[:] = kept_dirs

            for filename in files:
                path = root / filename
                if is_excluded_file(path):
                    excluded_files += 1
                    continue
                rel = path.relative_to(target_root)
                arcname = (Path("_St") / target_name / rel).as_posix()
                included.append((path, arcname))

    version_path = local_version_path(st_root)
    if version_path.is_file():
        included.append((version_path, f"_St/{VERSION_FILE_NAME}"))

    included.sort(key=lambda item: item[1].casefold())
    return included, excluded_files, excluded_dirs


def validate_zip(zip_path: Path) -> tuple[int, list[str]]:
    violations: list[str] = []
    file_count = 0
    allowed_second = {name.casefold() for name in TARGET_DIR_NAMES}

    with zipfile.ZipFile(zip_path, "r") as zf:
        bad_member = zf.testzip()
        if bad_member:
            violations.append(f"ZIP CRC 오류: {bad_member}")

        names = zf.namelist()
        if not names:
            violations.append("ZIP이 비어 있습니다.")

        for arcname in names:
            normalized = arcname.replace("\\", "/")
            if normalized.endswith("/"):
                continue
            parts = [part for part in normalized.split("/") if part]
            file_count += 1

            if not parts or parts[0].casefold() != "_st":
                violations.append(f"base_root 위반: {arcname}")
                continue
            if len(parts) == 2 and parts[1].casefold() == VERSION_FILE_NAME.casefold():
                continue
            if len(parts) < 3:
                violations.append(f"base_root 위반: {arcname}")
                continue
            if parts[1].casefold() not in allowed_second:
                violations.append(f"지정 외 폴더 포함: {arcname}")
                continue

            lower_parts = [part.casefold() for part in parts]
            if any(part in EXCLUDED_DIR_NAMES for part in lower_parts[2:-1]):
                violations.append(f"제외 폴더 포함: {arcname}")

            basename = parts[-1].casefold()
            suffix = Path(parts[-1]).suffix.casefold()
            if basename.startswith("read_") or basename.startswith("ai_read"):
                violations.append(f"read 문서 포함: {arcname}")
            if basename in EXCLUDED_FILE_NAMES or suffix in EXCLUDED_FILE_SUFFIXES:
                violations.append(f"제외 파일 포함: {arcname}")

    return file_count, violations


def build_st_zip(
    st_root: Path | None = None,
    output: Path | None = None,
    *,
    base_dir: Path | None = None,
    progress: Callable[[str], None] | None = None,
) -> BuildStats:
    base = (base_dir or tool_dir()).resolve()
    root = (st_root or resolve_st_root(base)).resolve()

    missing = [name for name in TARGET_DIR_NAMES if not (root / name).is_dir()]
    if missing:
        raise BuildError("지정 배포 폴더가 없습니다: " + ", ".join(missing))

    output_path = (output or default_output_path(base)).resolve(strict=False)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    temp_output = output_path.with_name(output_path.name + ".tmp")

    version_url = DEFAULT_VERSION_URL
    try:
        conf_values = parse_env_conf(default_env_path(base))
        version_url = conf_values.get("VERSION_URL", DEFAULT_VERSION_URL).strip() or DEFAULT_VERSION_URL
    except Exception:
        pass
    meta = choose_next_build(root, version_url=version_url, progress=progress)
    local_ver_path = write_local_version(root, meta)

    if progress:
        progress(f"_St 기준: {root}")
        progress("대상: " + ", ".join(TARGET_DIR_NAMES))
        progress(f"버전: {meta.version} / build={meta.build}")
        progress(f"출력: {output_path}")

    included, excluded_files, excluded_dirs = _iter_target_files(root)
    if not included:
        raise BuildError("포함할 파일이 없습니다.")

    if temp_output.exists():
        temp_output.unlink()

    try:
        with zipfile.ZipFile(
            temp_output,
            "w",
            compression=zipfile.ZIP_DEFLATED,
            compresslevel=9,
            allowZip64=True,
        ) as zf:
            total = len(included)
            for index, (path, arcname) in enumerate(included, 1):
                zf.write(path, arcname)
                if progress and (index == 1 or index == total or index % 100 == 0):
                    progress(f"압축: {index}/{total}")

        _, violations = validate_zip(temp_output)
        if violations:
            raise BuildError("ZIP 검증 실패:\n" + "\n".join(violations[:30]))

        os.replace(temp_output, output_path)
    except Exception:
        try:
            if temp_output.exists():
                temp_output.unlink()
        except OSError:
            pass
        raise

    zip_hash = sha256_file(output_path)
    server_meta = VersionMeta(meta.version, meta.build, meta.file, zip_hash)
    server_ver_path = write_server_version(base, server_meta)
    stats = BuildStats(
        st_root=root,
        output=output_path,
        included_files=len(included),
        excluded_files=excluded_files,
        excluded_dirs=excluded_dirs,
        output_bytes=output_path.stat().st_size,
        version=meta.version,
        build=meta.build,
        sha256=zip_hash,
        local_version_file=local_ver_path,
        server_version_file=server_ver_path,
    )
    if progress:
        progress(
            f"완료: 포함 {stats.included_files} / 제외파일 {stats.excluded_files} / "
            f"제외폴더 {stats.excluded_dirs}"
        )
    return stats



def _meta_values(text: str) -> dict[str, str]:
    pattern = re.compile(r'<meta\s+name=["\'](?P<name>st-[^"\']+)["\']\s+content=["\'](?P<value>.*?)["\']\s*/?>', re.I)
    return {m.group("name").casefold(): html.unescape(m.group("value").strip()) for m in pattern.finditer(text or "")}


def parse_version_html(text: str) -> VersionMeta:
    values = _meta_values(text)
    try:
        build = int(values.get("st-build", "0"))
    except ValueError as exc:
        raise BuildError("_St_ver.html st-build가 정수가 아닙니다.") from exc
    return VersionMeta(
        version=values.get("st-version", "0") or "0",
        build=max(0, build),
        file=values.get("st-file", OUTPUT_NAME) or OUTPUT_NAME,
        sha256=values.get("st-sha256", "").strip().casefold(),
    )


def render_version_html(meta: VersionMeta, *, file_comment: str = "_St/_St_ver.html") -> str:
    esc = html.escape
    return "\n".join([
        f"<!-- FILE: {esc(file_comment)} | ROLE: _St 설치/배포 build 버전 -->",
        "<!doctype html>",
        '<html lang="ko"><head><meta charset="utf-8">',
        f'<meta name="st-version" content="{esc(meta.version)}">',
        f'<meta name="st-build" content="{int(meta.build)}">',
        f'<meta name="st-file" content="{esc(meta.file)}">',
        f'<meta name="st-sha256" content="{esc(meta.sha256)}">',
        '<title>_St version</title></head>',
        f'<body>version={esc(meta.version)} build={int(meta.build)}</body></html>',
        "",
    ])


def local_version_path(st_root: Path) -> Path:
    return st_root / VERSION_FILE_NAME


def server_version_path(base_dir: Path) -> Path:
    return base_dir / VERSION_FILE_NAME


def read_local_version(st_root: Path) -> VersionMeta:
    path = local_version_path(st_root)
    if not path.is_file():
        return VersionMeta("0", 0)
    return parse_version_html(path.read_text(encoding="utf-8-sig"))


def manifest_version(st_root: Path) -> str:
    path = st_root / "IG_StYellow" / "manifest.json"
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        value = str(data.get("version_name") or data.get("version") or "0").strip()
        return value or "0"
    except Exception as exc:
        raise BuildError(f"manifest 버전 읽기 실패: {path} / {exc}") from exc


def version_tail_number(version: str) -> int:
    parts = re.findall(r"\d+", version or "")
    return int(parts[-1]) if parts else 0


def fetch_remote_version(url: str, timeout: float = 3.0) -> VersionMeta:
    request = urllib.request.Request(url, headers={"Cache-Control": "no-cache", "User-Agent": "AutoDistribute/4"})
    with urllib.request.urlopen(request, timeout=max(0.5, float(timeout))) as response:
        return parse_version_html(response.read().decode("utf-8-sig", errors="strict"))


def choose_next_build(st_root: Path, *, version_url: str = DEFAULT_VERSION_URL, remote_getter: Callable[[str, float], VersionMeta] | None = fetch_remote_version, progress: Callable[[str], None] | None = None) -> VersionMeta:
    version = manifest_version(st_root)
    local = read_local_version(st_root)
    candidates = [version_tail_number(version), local.build + 1]
    if remote_getter is not None and version_url:
        try:
            remote = remote_getter(version_url, 3.0)
            candidates.append(remote.build + 1)
            if progress:
                progress(f"서버 build 확인: {remote.build}")
        except Exception as exc:
            if progress:
                progress(f"서버 build 확인 실패(로컬 기준 계속): {exc}")
    build = max(candidates)
    return VersionMeta(version=version, build=build, file=OUTPUT_NAME, sha256="")


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest().casefold()


def write_local_version(st_root: Path, meta: VersionMeta) -> Path:
    path = local_version_path(st_root)
    path.write_text(render_version_html(VersionMeta(meta.version, meta.build, meta.file, "")), encoding="utf-8")
    return path


def write_server_version(base_dir: Path, meta: VersionMeta) -> Path:
    path = server_version_path(base_dir)
    path.write_text(render_version_html(meta), encoding="utf-8")
    return path

def parse_env_conf(path: Path) -> dict[str, str]:
    """KEY=VALUE 형식의 개인용 conf를 읽는다. # 주석과 빈 줄은 무시한다."""
    if not path.is_file():
        raise ConfigError(f"설정 파일이 없습니다: {path}")

    values: dict[str, str] = {}
    for line_no, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        if "=" not in line:
            raise ConfigError(f".env.conf 형식 오류 {line_no}행: KEY=VALUE 필요")
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip()
        if not key:
            raise ConfigError(f".env.conf 형식 오류 {line_no}행: KEY 없음")
        values[key] = value
    return values


def _expand_open_command(template: str, values: Mapping[str, str]) -> str:
    user_id = values.get("SFTP_ID", "")
    password = values.get("SFTP_PW", "")
    replacements = {
        "{SFTP_ID}": user_id,
        "{SFTP_PW}": password,
        "{SFTP_ID_URL}": urllib.parse.quote(user_id, safe=""),
        "{SFTP_PW_URL}": urllib.parse.quote(password, safe=""),
    }
    command = template
    for key, value in replacements.items():
        command = command.replace(key, value)
    return command.strip()


def _resolve_winscp_exe(raw: str, base_dir: Path) -> Path:
    candidates: list[Path] = []
    if raw:
        candidate = Path(os.path.expandvars(raw)).expanduser()
        if not candidate.is_absolute():
            candidate = base_dir / candidate
        candidates.append(candidate)

    if sys.platform.startswith("win"):
        for env_name in ("ProgramFiles(x86)", "ProgramFiles"):
            root = os.environ.get(env_name)
            if root:
                candidates.append(Path(root) / "WinSCP" / "WinSCP.com")
                candidates.append(Path(root) / "WinSCP" / "WinSCP.exe")
        found = shutil.which("WinSCP.com") or shutil.which("WinSCP.exe")
        if found:
            candidates.append(Path(found))

    for candidate in candidates:
        if candidate.is_file():
            return candidate.resolve()

    shown = raw or "C:\\Program Files (x86)\\WinSCP\\WinSCP.com"
    raise ConfigError(f"WinSCP 실행파일을 찾을 수 없습니다: {shown}")


def load_sftp_config(
    conf_path: Path | None = None,
    *,
    base_dir: Path | None = None,
    require_exe: bool = True,
) -> SftpConfig:
    base = (base_dir or tool_dir()).resolve()
    path = (conf_path or default_env_path(base)).resolve(strict=False)
    values = parse_env_conf(path)

    open_template = values.get("WINSCP_OPEN", "").strip()
    if not open_template:
        raise ConfigError(".env.conf의 WINSCP_OPEN이 비어 있습니다.")
    open_command = _expand_open_command(open_template, values)
    if not open_command.casefold().startswith("open sftp://"):
        raise ConfigError("WINSCP_OPEN은 'open sftp://'로 시작해야 합니다.")
    if "{SFTP_" in open_command:
        raise ConfigError("WINSCP_OPEN에 치환되지 않은 SFTP 변수가 있습니다.")

    remote_file = values.get("REMOTE_FILE", OUTPUT_NAME).strip() or OUTPUT_NAME
    remote_default = (remote_file.rsplit("/", 1)[0] + "/" + VERSION_FILE_NAME) if "/" in remote_file else VERSION_FILE_NAME
    remote_version_file = values.get("REMOTE_VERSION_FILE", remote_default).strip() or VERSION_FILE_NAME
    version_url = values.get("VERSION_URL", DEFAULT_VERSION_URL).strip() or DEFAULT_VERSION_URL
    for key, value in (("REMOTE_FILE", remote_file), ("REMOTE_VERSION_FILE", remote_version_file), ("VERSION_URL", version_url)):
        if "\n" in value or "\r" in value:
            raise ConfigError(f"{key}에 줄바꿈을 사용할 수 없습니다.")

    winscp_raw = values.get("WINSCP_EXE", "").strip()
    if require_exe:
        winscp_exe = _resolve_winscp_exe(winscp_raw, base)
    else:
        candidate = Path(os.path.expandvars(winscp_raw or "WinSCP.com")).expanduser()
        if not candidate.is_absolute():
            candidate = base / candidate
        winscp_exe = candidate

    log_raw = values.get("WINSCP_LOG", "WinSCP.log").strip() or "WinSCP.log"
    log_path = Path(os.path.expandvars(log_raw)).expanduser()
    if not log_path.is_absolute():
        log_path = base / log_path

    return SftpConfig(
        winscp_exe=winscp_exe,
        open_command=open_command,
        remote_file=remote_file,
        remote_version_file=remote_version_file,
        version_url=version_url,
        log_path=log_path.resolve(strict=False),
    )


def winscp_quote(value: str) -> str:
    return '"' + value.replace('"', '""') + '"'


def build_winscp_script(config: SftpConfig, local_file: Path, version_file: Path | None = None) -> str:
    local = local_file.resolve(strict=False)
    version = (version_file or (local.parent / VERSION_FILE_NAME)).resolve(strict=False)
    lines = [
        "option batch abort",
        "option confirm off",
        config.open_command,
        # ZIP을 먼저 올리고 버전 메타를 마지막에 올려 사용자가 미완료 ZIP을 보지 않게 한다.
        f"put {winscp_quote(str(local))} {winscp_quote(config.remote_file)}",
    ]
    if version.is_file():
        lines.append(f"put {winscp_quote(str(version))} {winscp_quote(config.remote_version_file)}")
    lines.extend(["exit", ""])
    return "\n".join(lines)


def upload_zip(
    local_file: Path,
    *,
    conf_path: Path | None = None,
    base_dir: Path | None = None,
    progress: Callable[[str], None] | None = None,
    runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> UploadStats:
    local = local_file.resolve()
    if not local.is_file():
        raise UploadError(f"업로드 파일이 없습니다: {local}")

    _, violations = validate_zip(local)
    if violations:
        raise UploadError("업로드 전 ZIP 검증 실패:\n" + "\n".join(violations[:30]))

    base = (base_dir or tool_dir()).resolve()
    config = load_sftp_config(conf_path, base_dir=base, require_exe=runner is None)
    config.log_path.parent.mkdir(parents=True, exist_ok=True)

    version_file = local.parent / VERSION_FILE_NAME
    if not version_file.is_file():
        raise UploadError(f"서버 버전 파일이 없습니다: {version_file}")
    script_text = build_winscp_script(config, local, version_file)
    temp_path: Path | None = None
    run = runner or subprocess.run

    if progress:
        progress(f"SFTP 업로드 시작: {local.name} → {config.remote_file}")
        progress(f"버전 메타: {VERSION_FILE_NAME} → {config.remote_version_file}")
        progress(f"WinSCP: {config.winscp_exe}")

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8-sig",
            newline="\n",
            prefix="autodistribute_winscp_",
            suffix=".txt",
            dir=base,
            delete=False,
        ) as handle:
            handle.write(script_text)
            temp_path = Path(handle.name)

        cmd = [
            str(config.winscp_exe),
            "/ini=nul",
            f"/log={config.log_path}",
            f"/script={temp_path}",
        ]
        completed = run(
            cmd,
            cwd=str(base),
            capture_output=True,
            text=True,
            errors="replace",
            timeout=1800,
            check=False,
        )
        if completed.returncode != 0:
            tail = (completed.stderr or completed.stdout or "").strip()
            if len(tail) > 1200:
                tail = tail[-1200:]
            message = f"WinSCP 업로드 실패 (exit={completed.returncode})"
            if tail:
                message += "\n" + tail
            message += f"\n로그: {config.log_path}"
            raise UploadError(message)
    finally:
        if temp_path is not None:
            try:
                temp_path.unlink(missing_ok=True)
            except OSError:
                pass

    if progress:
        progress(f"SFTP 업로드 완료: {config.remote_file}")
        progress(f"버전 메타 업로드 완료: {config.remote_version_file}")
        progress(f"WinSCP 로그: {config.log_path}")

    return UploadStats(
        local_file=local,
        remote_file=config.remote_file,
        winscp_exe=config.winscp_exe,
        log_path=config.log_path,
        returncode=0,
    )


def build_and_upload(
    *,
    base_dir: Path | None = None,
    output: Path | None = None,
    conf_path: Path | None = None,
    progress: Callable[[str], None] | None = None,
) -> tuple[BuildStats, UploadStats]:
    build_stats = build_st_zip(base_dir=base_dir, output=output, progress=progress)
    upload_stats = upload_zip(
        build_stats.output,
        base_dir=base_dir,
        conf_path=conf_path,
        progress=progress,
    )
    return build_stats, upload_stats


def _open_folder(path: Path) -> None:
    if sys.platform.startswith("win"):
        os.startfile(str(path))  # type: ignore[attr-defined]
    elif sys.platform == "darwin":
        subprocess.Popen(["open", str(path)])
    else:
        subprocess.Popen(["xdg-open", str(path)])


def run_gui() -> None:
    import tkinter as tk
    from tkinter import messagebox, ttk

    root = tk.Tk()
    root.title(f"AutoDistribute v{APP_VERSION}")
    root.geometry("800x500")
    root.minsize(720, 420)

    events: queue.Queue[tuple[str, object]] = queue.Queue()
    status_var = tk.StringVar(value="대기")
    output_var = tk.StringVar(value=str(default_output_path()))
    conf_var = tk.StringVar(value=str(default_env_path()))

    main = ttk.Frame(root, padding=12)
    main.pack(fill="both", expand=True)
    main.columnconfigure(1, weight=1)
    main.rowconfigure(6, weight=1)

    try:
        st_root = resolve_st_root()
        root_text = str(st_root)
    except BuildError as exc:
        root_text = str(tool_dir().parent)
        status_var.set("FAIL")
        initial_error = str(exc)
    else:
        initial_error = ""

    ttk.Label(main, text="_St 기준").grid(row=0, column=0, sticky="nw", padx=(0, 8), pady=5)
    ttk.Label(main, text=root_text).grid(row=0, column=1, sticky="w", pady=5)

    ttk.Label(main, text="배포 폴더").grid(row=1, column=0, sticky="nw", padx=(0, 8), pady=5)
    ttk.Label(main, text="\n".join(f"../{name}" for name in TARGET_DIR_NAMES)).grid(
        row=1, column=1, sticky="w", pady=5
    )

    ttk.Label(main, text="생성 ZIP").grid(row=2, column=0, sticky="w", padx=(0, 8), pady=5)
    ttk.Entry(main, textvariable=output_var, state="readonly").grid(
        row=2, column=1, sticky="ew", pady=5
    )

    ttk.Label(main, text="SFTP 설정").grid(row=3, column=0, sticky="w", padx=(0, 8), pady=5)
    ttk.Entry(main, textvariable=conf_var, state="readonly").grid(
        row=3, column=1, sticky="ew", pady=5
    )

    buttons = ttk.Frame(main)
    buttons.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(8, 8))
    build_button = ttk.Button(buttons, text="_St.zip 생성")
    build_button.pack(side="left")
    deploy_button = ttk.Button(buttons, text="_St.zip 생성 + 서버 업로드")
    deploy_button.pack(side="left", padx=(8, 0))
    upload_button = ttk.Button(buttons, text="현재 ZIP 서버 업로드")
    upload_button.pack(side="left", padx=(8, 0))
    open_button = ttk.Button(buttons, text="폴더 열기", state="disabled")
    open_button.pack(side="left", padx=(8, 0))
    ttk.Label(buttons, textvariable=status_var).pack(side="right")

    log_text = tk.Text(main, height=14, wrap="word", state="disabled")
    log_text.grid(row=6, column=0, columnspan=2, sticky="nsew")

    def append_log(message: str) -> None:
        log_text.configure(state="normal")
        log_text.insert("end", message + "\n")
        log_text.see("end")
        log_text.configure(state="disabled")

    if initial_error:
        append_log("FAIL: " + initial_error)

    def set_busy(busy: bool) -> None:
        state = "disabled" if busy else "normal"
        build_button.configure(state=state)
        deploy_button.configure(state=state)
        upload_button.configure(state=state)
        if busy:
            open_button.configure(state="disabled")
        elif default_output_path().is_file():
            open_button.configure(state="normal")

    def worker(mode: str) -> None:
        try:
            if mode == "build":
                stats = build_st_zip(progress=lambda msg: events.put(("log", msg)))
                events.put(("build_done", stats))
            elif mode == "upload":
                stats = upload_zip(
                    default_output_path(),
                    progress=lambda msg: events.put(("log", msg)),
                )
                events.put(("upload_done", stats))
            else:
                build_stats, upload_stats = build_and_upload(
                    progress=lambda msg: events.put(("log", msg))
                )
                events.put(("deploy_done", (build_stats, upload_stats)))
        except Exception as exc:
            events.put(("error", exc))

    def start(mode: str) -> None:
        set_busy(True)
        status_var.set("업로드중" if mode == "upload" else "생성중")
        append_log("---")
        threading.Thread(target=worker, args=(mode,), daemon=True).start()

    build_button.configure(command=lambda: start("build"))
    deploy_button.configure(command=lambda: start("deploy"))
    upload_button.configure(command=lambda: start("upload"))
    open_button.configure(command=lambda: _open_folder(default_output_path().parent))

    if default_output_path().is_file():
        open_button.configure(state="normal")

    def poll_events() -> None:
        try:
            while True:
                kind, payload = events.get_nowait()
                if kind == "log":
                    append_log(str(payload))
                elif kind == "build_done":
                    stats = payload
                    assert isinstance(stats, BuildStats)
                    output_var.set(str(stats.output))
                    status_var.set("생성 완료")
                    set_busy(False)
                    append_log(f"ZIP 검증 OK: {stats.output.name} ({stats.output_bytes / 1048576:.2f} MB) / {stats.version} build={stats.build}")
                elif kind == "upload_done":
                    stats = payload
                    assert isinstance(stats, UploadStats)
                    status_var.set("업로드 완료")
                    set_busy(False)
                    append_log(f"업로드 OK: {stats.remote_file}")
                    messagebox.showinfo("AutoDistribute", "서버 업로드가 완료되었습니다.")
                elif kind == "deploy_done":
                    build_stats, upload_stats = payload  # type: ignore[misc]
                    output_var.set(str(build_stats.output))
                    status_var.set("배포 완료")
                    set_busy(False)
                    append_log(
                        f"배포 OK: {build_stats.output.name} → {upload_stats.remote_file}"
                    )
                    messagebox.showinfo("AutoDistribute", "_St.zip 생성 및 서버 업로드가 완료되었습니다.")
                elif kind == "error":
                    status_var.set("FAIL")
                    set_busy(False)
                    append_log(f"FAIL: {payload}")
                    messagebox.showerror("AutoDistribute", str(payload))
        except queue.Empty:
            pass
        root.after(100, poll_events)

    root.after(100, poll_events)
    root.mainloop()


def main(argv: Iterable[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="개인용 _St build 생성·ZIP·WinSCP SFTP 업로더")
    parser.add_argument("--build", action="store_true", help="GUI 없이 ZIP 생성")
    parser.add_argument("--upload", action="store_true", help="GUI 없이 현재 ZIP 업로드")
    parser.add_argument("--deploy", action="store_true", help="GUI 없이 ZIP 생성 후 업로드")
    parser.add_argument("--output", help="출력 ZIP 경로")
    parser.add_argument("--conf", help=".env.conf 경로")
    args = parser.parse_args(list(argv) if argv is not None else None)

    selected = sum(bool(flag) for flag in (args.build, args.upload, args.deploy))
    if selected > 1:
        parser.error("--build, --upload, --deploy 중 하나만 사용하세요.")
    if selected == 0:
        run_gui()
        return 0

    output = Path(args.output).expanduser() if args.output else default_output_path()
    conf = Path(args.conf).expanduser() if args.conf else None

    try:
        if args.build:
            stats = build_st_zip(output=output, progress=print)
            print(f"OK: {stats.output}")
        elif args.upload:
            stats = upload_zip(output, conf_path=conf, progress=print)
            print(f"OK: {stats.remote_file}")
        else:
            build_stats, upload_stats = build_and_upload(
                output=output,
                conf_path=conf,
                progress=print,
            )
            print(f"OK: {build_stats.output} -> {upload_stats.remote_file}")
        return 0
    except Exception as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
