"""
독립 자가 업데이터.

Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약을
헬퍼 프로세스로 우회. 메인 앱이 종료된 직후 파일 교체 + 재실행.

사용법 (메인 앱이 호출):
    updater.exe --pid <메인_PID> --new <new_exe_path> --target <target_exe_path>

흐름:
1. 메인 앱 종료 대기 (PID 폴링, 최대 30초)
2. target_exe를 .bak으로 백업
3. new_exe → target_exe 이동
4. target_exe 재실행 + 업데이터 자가 종료
실패 시 .bak 복원

빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path


# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'


def _log(msg: str) -> None:
    """진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
    line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
    # stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
    try:
        print(line, end='', file=sys.stderr)
    except Exception:
        pass
    try:
        _LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
        with open(_LOG_PATH, 'a', encoding='utf-8') as f:
            f.write(line)
    except OSError:
        pass


def is_pid_running(pid: int) -> bool:
    """Windows에서 PID 실행 중인지 확인."""
    if sys.platform != 'win32':
        try:
            os.kill(pid, 0)
            return True
        except OSError:
            return False
    try:
        import ctypes
        from ctypes import wintypes
        PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
        STILL_ACTIVE = 259
        kernel32 = ctypes.windll.kernel32
        h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
        if not h:
            return False
        try:
            exit_code = wintypes.DWORD()
            if kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)):
                return exit_code.value == STILL_ACTIVE
            return False
        finally:
            kernel32.CloseHandle(h)
    except Exception:
        return False


def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
    """PID가 종료될 때까지 폴링. timeout 시 False."""
    deadline = time.time() + timeout_sec
    while time.time() < deadline:
        if not is_pid_running(pid):
            return True
        time.sleep(0.5)
    return False


def replace_file(new_path: Path, target_path: Path,
                  max_retries: int = 5) -> Path | None:
    """target을 .bak으로 백업하고 new를 target 위치로 이동.

    Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
    (특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
    있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).

    Returns:
        백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
    """
    backup = target_path.with_suffix(target_path.suffix + '.bak')
    last_err: Exception | None = None

    # 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
    if backup.exists():
        try:
            backup.unlink()
        except OSError as e:
            _log(f"[updater] old backup unlink failed (continuing): {e}")

    # 2단계: target → backup 이동 (락 해제 대기 재시도)
    for attempt in range(max_retries):
        if not target_path.exists():
            break  # 첫 설치 등 — target 없으면 그냥 다음 단계로
        try:
            shutil.move(str(target_path), str(backup))
            break
        except OSError as e:
            last_err = e
            wait = 0.3 * (2 ** attempt)
            _log(f"[updater] target move attempt {attempt+1}/{max_retries} "
                  f"failed ({e}); waiting {wait:.1f}s")
            time.sleep(wait)
    else:
        # 모든 재시도 실패
        _log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
        return None

    # 3단계: new → target 이동
    for attempt in range(max_retries):
        try:
            shutil.move(str(new_path), str(target_path))
            return backup
        except OSError as e:
            last_err = e
            wait = 0.3 * (2 ** attempt)
            _log(f"[updater] new move attempt {attempt+1}/{max_retries} "
                  f"failed ({e}); waiting {wait:.1f}s")
            time.sleep(wait)

    # new 이동 실패 → backup으로 롤백 시도
    _log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
    if backup.exists() and not target_path.exists():
        try:
            shutil.move(str(backup), str(target_path))
            _log("[updater] rolled back from backup")
        except OSError as e:
            _log(f"[updater] rollback also failed: {e}")
    return None


def launch(exe_path: Path) -> bool:
    """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
    try:
        if sys.platform == 'win32':
            # CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
            # — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
            DETACHED_PROCESS = 0x00000008
            CREATE_NO_WINDOW = 0x08000000
            subprocess.Popen(
                [str(exe_path)],
                creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
                close_fds=True,
            )
        else:
            subprocess.Popen([str(exe_path)], close_fds=True)
        return True
    except OSError as e:
        _log(f"[updater] launch failed: {e}")
        return False


def main() -> int:
    parser = argparse.ArgumentParser(description="Clock-out Calculator updater")
    parser.add_argument('--pid', type=int, required=True, help='메인 앱 PID')
    parser.add_argument('--new', required=True, help='새 .exe 경로')
    parser.add_argument('--target', required=True, help='교체 대상 .exe 경로')
    parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
    args = parser.parse_args()

    _log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")

    new_exe = Path(args.new).resolve()
    target_exe = Path(args.target).resolve()

    if not new_exe.exists():
        _log(f"[updater] new exe not found: {new_exe}")
        return 2

    if not wait_for_exit(args.pid, timeout_sec=30):
        _log(f"[updater] timeout waiting for PID {args.pid}")
        return 3

    # Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
    # 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
    time.sleep(0.5)

    backup = replace_file(new_exe, target_exe)
    if backup is None:
        _log("[updater] replace_file failed — aborting")
        return 4

    if args.no_launch:
        _log("[updater] --no-launch set, exiting after replace")
        return 0

    if not launch(target_exe):
        _log("[updater] launch failed — rolling back")
        try:
            target_exe.unlink()
            shutil.move(str(backup), str(target_exe))
            launch(target_exe)
        except OSError:
            pass
        return 5

    _log("[updater] update complete, new app launched")
    return 0


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