# _St 기준 상대경로: ..\AutoDistribute\AutoDistribute.py
"""개인용 _St 배포 ZIP 생성기. 서버 업로드 기능은 후속 단계에서 추가한다."""

from __future__ import annotations

import argparse
import os
import queue
import shutil
import sys
import threading
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable

APP_VERSION = "1.0.0"
OUTPUT_NAME = "_St.zip"

# 배포 제외 폴더: 이름 기준, 대소문자 무시
EXCLUDED_DIR_NAMES = {
    "__pycache__",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    ".tox",
    ".nox",
    "htmlcov",
    "logs",
    "data",
}

# 배포 제외 파일 확장자/파일명
EXCLUDED_FILE_SUFFIXES = {".pyc", ".pyo", ".log"}
EXCLUDED_FILE_NAMES = {".coverage", "thumbs.db", ".ds_store"}
SELF_TOOL_FILE_NAMES = {"autodistribute.py", "run_autodistribute.bat", "test_autodistribute.py"}


@dataclass(frozen=True)
class BuildStats:
    source: Path
    output: Path
    included_files: int
    excluded_files: int
    excluded_dirs: int
    output_bytes: int


class BuildError(RuntimeError):
    pass


def _norm_name(name: str) -> str:
    return name.casefold()


def is_read_document(path: Path) -> bool:
    """배포에서 제외할 read 계열 문서인지 확인한다."""
    name = path.name.casefold()
    # 사용자의 read_* 제외 기준 + 현재 프로젝트의 ai_read* 문서까지 함께 제외한다.
    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 resolve_st_source(explicit: str | os.PathLike[str] | None = None) -> Path:
    """명시 경로, 현재 폴더, 스크립트 위치를 기준으로 _St를 자동 탐지한다."""
    candidates: list[Path] = []

    def add_candidate(raw: Path) -> None:
        raw = raw.expanduser()
        if raw.name.casefold() == "_st":
            candidates.append(raw)
        candidates.append(raw / "_St")

    if explicit:
        add_candidate(Path(explicit))

    cwd = Path.cwd()
    add_candidate(cwd)

    script_dir = Path(__file__).resolve().parent
    add_candidate(script_dir)
    add_candidate(script_dir.parent)

    seen: set[str] = set()
    for candidate in candidates:
        try:
            resolved = candidate.resolve()
        except OSError:
            continue
        key = os.path.normcase(str(resolved))
        if key in seen:
            continue
        seen.add(key)
        if resolved.is_dir() and resolved.name.casefold() == "_st":
            return resolved

    raise BuildError(
        "_St 폴더를 찾지 못했습니다. _St 폴더 안/상위에서 실행하거나 --source로 지정하세요."
    )


def default_output_path(source: Path) -> Path:
    return source.parent / OUTPUT_NAME


def _same_or_inside(path: Path, parent: Path) -> bool:
    try:
        path.resolve().relative_to(parent.resolve())
        return True
    except (ValueError, OSError):
        return False


def _iter_files(
    source: Path,
    *,
    output: Path,
    self_dir: Path,
) -> tuple[list[Path], int, int]:
    included: list[Path] = []
    excluded_files = 0
    excluded_dirs = 0
    output_resolved = output.resolve(strict=False)
    self_dir_resolved = self_dir.resolve(strict=False)
    source_resolved = source.resolve(strict=False)

    for root_text, dirs, files in os.walk(source, topdown=True, followlinks=False):
        root = Path(root_text)

        kept_dirs: list[str] = []
        for dirname in dirs:
            dpath = root / dirname
            dname = _norm_name(dirname)
            exclude = dname in EXCLUDED_DIR_NAMES

            # 생성기 폴더가 실수로 _St 안에 있어도 배포 ZIP에는 포함하지 않는다.
            try:
                if dpath.resolve(strict=False) == self_dir_resolved:
                    exclude = True
            except OSError:
                pass

            if exclude:
                excluded_dirs += 1
            else:
                kept_dirs.append(dirname)
        dirs[:] = kept_dirs

        for filename in files:
            path = root / filename

            # 생성기 파일을 _St 루트에 직접 둔 경우에도 배포 ZIP에서는 제외한다.
            if self_dir_resolved == source_resolved and filename.casefold() in SELF_TOOL_FILE_NAMES:
                excluded_files += 1
                continue
            try:
                if path.resolve(strict=False) == output_resolved:
                    excluded_files += 1
                    continue
            except OSError:
                pass

            if filename.casefold() == f"{OUTPUT_NAME}.tmp".casefold():
                excluded_files += 1
                continue

            if is_excluded_file(path):
                excluded_files += 1
                continue

            included.append(path)

    included.sort(key=lambda p: p.relative_to(source).as_posix().casefold())
    return included, excluded_files, excluded_dirs


def validate_zip(zip_path: Path) -> tuple[int, list[str]]:
    """생성된 ZIP이 _St 루트와 제외 규칙을 지키는지 검사한다."""
    violations: list[str] = []
    file_count = 0

    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("\\", "/")
            parts = [p for p in normalized.split("/") if p]
            if not parts:
                continue
            if parts[0].casefold() != "_st":
                violations.append(f"base_root 위반: {arcname}")
                continue
            if normalized.endswith("/"):
                continue

            file_count += 1
            rel_parts = parts[1:]
            lower_parts = [p.casefold() for p in rel_parts]

            for dname in EXCLUDED_DIR_NAMES:
                if dname in lower_parts[:-1]:
                    violations.append(f"제외 폴더 포함: {arcname}")
                    break

            if rel_parts:
                basename = rel_parts[-1].casefold()
                suffix = Path(rel_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(
    source: Path,
    output: Path | None = None,
    *,
    progress: Callable[[str], None] | None = None,
) -> BuildStats:
    source = source.resolve()
    if source.name.casefold() != "_st" or not source.is_dir():
        raise BuildError(f"올바른 _St 폴더가 아닙니다: {source}")

    output = (output or default_output_path(source)).resolve(strict=False)
    output.parent.mkdir(parents=True, exist_ok=True)
    temp_output = output.with_name(output.name + ".tmp")
    self_dir = Path(__file__).resolve().parent

    if progress:
        progress(f"원본: {source}")
        progress(f"출력: {output}")

    included, excluded_files, excluded_dirs = _iter_files(
        source,
        output=output,
        self_dir=self_dir,
    )
    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 in enumerate(included, 1):
                rel = path.relative_to(source)
                arcname = (Path("_St") / rel).as_posix()
                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:
            preview = "\n".join(violations[:20])
            raise BuildError(f"ZIP 검증 실패:\n{preview}")

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

    stats = BuildStats(
        source=source,
        output=output,
        included_files=len(included),
        excluded_files=excluded_files,
        excluded_dirs=excluded_dirs,
        output_bytes=output.stat().st_size,
    )
    if progress:
        progress(
            f"완료: 파일 {stats.included_files}개 / 제외파일 {stats.excluded_files}개 / "
            f"제외폴더 {stats.excluded_dirs}개"
        )
    return stats


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

        subprocess.Popen(["open", str(path)])
    else:
        import subprocess

        subprocess.Popen(["xdg-open", str(path)])


def run_gui(initial_source: str | None = None) -> None:
    import tkinter as tk
    from tkinter import filedialog, messagebox, ttk

    root = tk.Tk()
    root.title(f"AutoDistribute {_St_label()} v{APP_VERSION}")
    root.geometry("760x430")
    root.minsize(680, 360)

    event_queue: queue.Queue[tuple[str, object]] = queue.Queue()

    try:
        source = resolve_st_source(initial_source)
        source_text = str(source)
    except BuildError:
        source_text = str(Path(initial_source).resolve()) if initial_source else ""

    source_var = tk.StringVar(value=source_text)
    output_var = tk.StringVar(value="")
    status_var = tk.StringVar(value="대기")

    def refresh_output(*_: object) -> None:
        text = source_var.get().strip()
        if not text:
            output_var.set("")
            return
        p = Path(text)
        if p.name.casefold() != "_st" and (p / "_St").is_dir():
            p = p / "_St"
        if p.name.casefold() == "_st":
            output_var.set(str(p.parent / OUTPUT_NAME))

    source_var.trace_add("write", refresh_output)
    refresh_output()

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

    ttk.Label(main, text="_St 폴더").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=5)
    source_entry = ttk.Entry(main, textvariable=source_var)
    source_entry.grid(row=0, column=1, sticky="ew", pady=5)

    def choose_source() -> None:
        initial = source_var.get().strip() or str(Path.cwd())
        selected = filedialog.askdirectory(title="_St 폴더 선택", initialdir=initial)
        if selected:
            p = Path(selected)
            if p.name.casefold() != "_st" and (p / "_St").is_dir():
                p = p / "_St"
            source_var.set(str(p))

    ttk.Button(main, text="찾기", command=choose_source).grid(row=0, column=2, padx=(8, 0), pady=5)

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

    button_bar = ttk.Frame(main)
    button_bar.grid(row=2, column=0, columnspan=3, sticky="ew", pady=(8, 8))

    create_button = ttk.Button(button_bar, text="_St.zip 생성")
    create_button.pack(side="left")

    open_button = ttk.Button(button_bar, text="생성 폴더 열기", state="disabled")
    open_button.pack(side="left", padx=(8, 0))

    ttk.Label(button_bar, textvariable=status_var).pack(side="right")

    log_text = tk.Text(main, height=14, wrap="word", state="disabled")
    log_text.grid(row=4, column=0, columnspan=3, 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")

    def worker() -> None:
        try:
            src = resolve_st_source(source_var.get().strip() or None)
            out = default_output_path(src)
            stats = build_st_zip(
                src,
                out,
                progress=lambda msg: event_queue.put(("log", msg)),
            )
            event_queue.put(("done", stats))
        except Exception as exc:  # GUI에서 오류를 숨기지 않는다.
            event_queue.put(("error", exc))

    def create_zip() -> None:
        create_button.configure(state="disabled")
        open_button.configure(state="disabled")
        status_var.set("생성중")
        append_log("---")
        threading.Thread(target=worker, daemon=True).start()

    create_button.configure(command=create_zip)

    def open_output_folder() -> None:
        text = output_var.get().strip()
        if text:
            _open_folder(Path(text).parent)

    open_button.configure(command=open_output_folder)

    def poll_events() -> None:
        try:
            while True:
                kind, payload = event_queue.get_nowait()
                if kind == "log":
                    append_log(str(payload))
                elif kind == "done":
                    stats = payload
                    assert isinstance(stats, BuildStats)
                    output_var.set(str(stats.output))
                    status_var.set("완료")
                    create_button.configure(state="normal")
                    open_button.configure(state="normal")
                    size_mb = stats.output_bytes / (1024 * 1024)
                    append_log(f"검증 OK: {stats.output.name} ({size_mb:.2f} MB)")
                elif kind == "error":
                    status_var.set("FAIL")
                    create_button.configure(state="normal")
                    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 _St_label() -> str:
    return "_St"


def main(argv: Iterable[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="개인용 _St.zip 생성기")
    parser.add_argument("--source", help="_St 폴더 또는 _St 상위 폴더")
    parser.add_argument("--output", help="출력 ZIP 경로. 기본값은 _St 상위/_St.zip")
    parser.add_argument("--build", action="store_true", help="GUI 없이 즉시 생성")
    args = parser.parse_args(list(argv) if argv is not None else None)

    if not args.build:
        run_gui(args.source)
        return 0

    try:
        source = resolve_st_source(args.source)
        output = Path(args.output).expanduser() if args.output else default_output_path(source)
        stats = build_st_zip(source, output, progress=print)
        print(f"OK: {stats.output}")
        return 0
    except Exception as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        return 1


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