"""
업데이터 GUI

updater.exe용 최소 GUI 모듈입니다.
CustomTkinter 기반으로 다운로드 진행률과 상태를 표시합니다.

이 파일은 별도의 exe로 패킹되어 메인 프로그램과 함께 배포됩니다.

작성자: KH.Choi
최종 수정: 2026-02-18
"""

import json
import logging
import os
import shutil
import subprocess
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse

import customtkinter as ctk
import requests

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# CustomTkinter 설정
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")


# ============================================================================
# 데이터 클래스
# ============================================================================

@dataclass
class UpdateConfig:
    """
    업데이트 구성 정보
    
    updater.exe가 읽는 config.json의 구조입니다.
    """
    download_url: str
    target_path: str
    version: str
    restart_exe: str = "voc_noti.exe"


# ============================================================================
# 예외 클래스
# ============================================================================

class UpdaterError(Exception):
    """업데이터 기본 예외"""
    pass


class DownloadError(UpdaterError):
    """다운로드 실패"""
    pass


class ExtractError(UpdaterError):
    """압축 해제 실패"""
    pass


class ConfigError(UpdaterError):
    """설정 파일 오류"""
    pass


# ============================================================================
# UpdaterGUI 클래스
# ============================================================================

class UpdaterGUI(ctk.CTk):
    """
    업데이터 GUI 창
    
    CustomTkinter 기반 최소 GUI로 다음 기능을 제공합니다:
    - 다운로드 진행률 표시 (Progress Bar)
    - 상태 메시지 표시
    - 성공/실패 결과 표시
    
    실행 흐름:
    1. config.json 로드
    2. zip 다운로드
    3. 압축 해제 및 파일 교체
    4. 메인 프로그램 재실행
    """
    
    CONFIG_FILENAME = "voc_updater_config.json"
    EXTRACT_DIRNAME = "voc_update_extract"
    
    def __init__(self):
        """UpdaterGUI 초기화"""
        super().__init__()
        
        self.title("VOC 모니터링 업데이터")
        self.geometry("450x280")
        self.resizable(False, False)
        
        # 창을 화면 중앙에 배치
        self._center_window()
        
        # 변수
        self.config: Optional[UpdateConfig] = None
        self.download_path: Optional[Path] = None
        
        # UI 구성
        self._setup_ui()
        
        # 업데이트 시작
        self.after(100, self._start_update)
    
    def _center_window(self):
        """창을 화면 중앙에 배치"""
        self.update_idletasks()
        width = 450
        height = 280
        x = (self.winfo_screenwidth() // 2) - (width // 2)
        y = (self.winfo_screenheight() // 2) - (height // 2)
        self.geometry(f'{width}x{height}+{x}+{y}')
    
    def _setup_ui(self):
        """UI 구성"""
        # 메인 프레임
        self.main_frame = ctk.CTkFrame(self, fg_color="transparent")
        self.main_frame.pack(fill="both", expand=True, padx=20, pady=20)
        
        # 아이콘/로고 라벨
        self.icon_label = ctk.CTkLabel(
            self.main_frame,
            text="🔄",
            font=ctk.CTkFont(size=48)
        )
        self.icon_label.pack(pady=(0, 10))
        
        # 상태 라벨
        self.status_label = ctk.CTkLabel(
            self.main_frame,
            text="업데이트 준비 중...",
            font=ctk.CTkFont(size=14, weight="bold")
        )
        self.status_label.pack(pady=(0, 10))
        
        # 진행률 바
        self.progress_bar = ctk.CTkProgressBar(
            self.main_frame,
            width=400,
            height=20
        )
        self.progress_bar.set(0)
        self.progress_bar.pack(pady=(0, 10))
        
        # 세부 정보 라벨
        self.detail_label = ctk.CTkLabel(
            self.main_frame,
            text="",
            font=ctk.CTkFont(size=11),
            text_color="gray"
        )
        self.detail_label.pack(pady=(0, 10))
        
        # 버튼 프레임 (초기에는 숨김)
        self.button_frame = ctk.CTkFrame(self.main_frame, fg_color="transparent")
        
        self.close_button = ctk.CTkButton(
            self.button_frame,
            text="닫기",
            width=100,
            command=self._on_close
        )
        self.close_button.pack(side="left", padx=5)
        
        self.retry_button = ctk.CTkButton(
            self.button_frame,
            text="재시도",
            width=100,
            command=self._start_update
        )
        self.retry_button.pack(side="left", padx=5)
    
    def _update_status(self, status: str, detail: str = ""):
        """상태 업데이트"""
        self.status_label.configure(text=status)
        self.detail_label.configure(text=detail)
        self.update_idletasks()
    
    def _update_progress(self, value: float, detail: str = ""):
        """진행률 업데이트"""
        self.progress_bar.set(value)
        if detail:
            self.detail_label.configure(text=detail)
        self.update_idletasks()
    
    def _show_buttons(self, show_retry: bool = False):
        """버튼 표시"""
        self.retry_button.pack_forget()
        if show_retry:
            self.retry_button.pack(side="left", padx=5)
        self.button_frame.pack(pady=10)
    
    def _load_config(self) -> UpdateConfig:
        """
        config.json 로드
        
        Returns:
            UpdateConfig: 업데이트 구성 정보
        
        Raises:
            ConfigError: 설정 파일을 찾을 수 없거나 파싱 실패
        """
        config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
        
        if not config_path.exists():
            raise ConfigError(f"설정 파일을 찾을 수 없습니다: {config_path}")
        
        try:
            with open(config_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
            
            config = UpdateConfig(
                download_url=data.get('download_url', ''),
                target_path=data.get('target_path', ''),
                version=data.get('version', ''),
                restart_exe=data.get('restart_exe', 'voc_noti.exe')
            )

            if not config.download_url:
                raise ConfigError("download_url이 비어 있습니다.")
            if not config.target_path:
                raise ConfigError("target_path가 비어 있습니다.")
            if not config.version:
                raise ConfigError("version이 비어 있습니다.")
            
            logger.info(f"설정 로드 완료: 버전 {config.version}")
            return config
            
        except json.JSONDecodeError as e:
            raise ConfigError(f"설정 파일 파싱 실패: {e}")
        except KeyError as e:
            raise ConfigError(f"필수 설정 누락: {e}")
    
    def _download_zip(self) -> Path:
        """
        zip 파일 다운로드
        
        Returns:
            Path: 다운로드된 zip 파일 경로
        
        Raises:
            DownloadError: 다운로드 실패
        """
        if not self.config:
            raise DownloadError("설정이 로드되지 않았습니다.")
        
        url = self.config.download_url
        self._update_status("다운로드 중...", "연결 중...")
        
        try:
            # 파일명 추출
            parsed = urlparse(url)
            filename = os.path.basename(parsed.path)
            if not filename.endswith('.zip'):
                filename = f"voc_noti_{self.config.version}.zip"
            
            self.download_path = Path(tempfile.gettempdir()) / filename
            
            # 다운로드 (스트리밍)
            response = requests.get(url, stream=True, timeout=30)
            response.raise_for_status()
            
            total_size = int(response.headers.get('content-length', 0))
            downloaded = 0
            chunk_size = 8192
            
            with open(self.download_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=chunk_size):
                    if chunk:
                        f.write(chunk)
                        downloaded += len(chunk)
                        
                        if total_size > 0:
                            progress = downloaded / total_size
                            self._update_progress(
                                progress * 0.7,  # 전체의 70%
                                f"{downloaded / 1024 / 1024:.1f} MB / {total_size / 1024 / 1024:.1f} MB"
                            )
            
            logger.info(f"다운로드 완료: {self.download_path}")
            return self.download_path
            
        except requests.exceptions.RequestException as e:
            raise DownloadError(f"다운로드 실패: {e}")
    
    def _extract_and_replace(self, zip_path: Path) -> tuple[bool, str]:
        """
        압축 해제 및 파일 교체
        
        Args:
            zip_path: zip 파일 경로
        
        Returns:
            tuple[bool, str]: (성공 여부, 메시지)
        """
        if not self.config:
            return False, "설정이 로드되지 않았습니다."
        
        target_path = Path(self.config.target_path)
        backup_path: Optional[Path] = None
        extract_dir = Path(tempfile.gettempdir()) / self.EXTRACT_DIRNAME
        
        self._update_status("설치 중...", "파일 교체 중...")
        self._update_progress(0.75)
        
        try:
            # 백업 폴더 생성
            backup_path = target_path.parent / f"{target_path.name}_backup_{self.config.version}"
            if backup_path.exists():
                shutil.rmtree(backup_path)
            
            # 기존 파일 백업
            if target_path.exists():
                shutil.copytree(target_path, backup_path)
                logger.info(f"백업 생성: {backup_path}")

            # 압축 해제 준비
            if extract_dir.exists():
                shutil.rmtree(extract_dir)
            extract_dir.mkdir(parents=True, exist_ok=True)

            # 압축 해제
            self._update_progress(0.8, "압축 해제 중...")
            with zipfile.ZipFile(zip_path, 'r') as zip_ref:
                zip_ref.extractall(extract_dir)

            # 압축 해제 결과 루트 결정
            extracted_items = [p for p in extract_dir.iterdir()]
            source_root = extract_dir
            if len(extracted_items) == 1 and extracted_items[0].is_dir():
                source_root = extracted_items[0]

            # 대상 경로 생성
            target_path.mkdir(parents=True, exist_ok=True)

            # 파일 교체
            self._update_progress(0.9, "파일 교체 중...")
            for item in source_root.iterdir():
                dst = target_path / item.name
                if item.is_dir():
                    shutil.copytree(item, dst, dirs_exist_ok=True)
                else:
                    shutil.copy2(item, dst)
            
            self._update_progress(0.95, "완료 중...")
            logger.info(f"압축 해제 완료: {target_path}")
            
            # 임시 파일 정리
            if zip_path.exists():
                zip_path.unlink()
            if extract_dir.exists():
                shutil.rmtree(extract_dir)
            
            return True, "설치 완료"
            
        except zipfile.BadZipFile as e:
            return False, f"손상된 zip 파일: {e}"
        except PermissionError as e:
            return False, f"파일 권한 오류: {e}"
        except Exception as e:
            # 롤백
            if backup_path and backup_path.exists():
                logger.info("롤백 중...")
                if target_path.exists():
                    shutil.rmtree(target_path)
                shutil.move(str(backup_path), str(target_path))
            if extract_dir.exists():
                shutil.rmtree(extract_dir, ignore_errors=True)
            return False, f"설치 실패: {e}"
    
    def _restart_main_app(self) -> bool:
        """
        메인 앱 재실행
        
        Returns:
            bool: 실행 성공 여부
        """
        if not self.config:
            return False
        
        target_path = Path(self.config.target_path)
        exe_path = target_path / self.config.restart_exe
        
        if not exe_path.exists():
            logger.warning(f"실행 파일을 찾을 수 없습니다: {exe_path}")
            return False
        
        try:
            creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
            subprocess.Popen(
                [str(exe_path)],
                cwd=str(target_path),
                creationflags=creation_flags
            )
            logger.info(f"메인 앱 실행: {exe_path}")
            return True
        except Exception as e:
            logger.error(f"메인 앱 실행 실패: {e}")
            return False
    
    def _start_update(self):
        """업데이트 시작"""
        try:
            # 설정 로드
            self.config = self._load_config()
            self._update_status(
                f"버전 {self.config.version}으로 업데이트",
                "준비 중..."
            )
            
            # 다운로드
            zip_path = self._download_zip()
            
            # 설치
            success, message = self._extract_and_replace(zip_path)
            
            if success:
                self._update_progress(1.0)
                self._update_status("업데이트 완료! ✓", message)
                
                # 메인 앱 재실행
                self._restart_main_app()
                
                # 3초 후 종료
                self.after(3000, self._on_close)
            else:
                self._update_status("업데이트 실패 ✗", message)
                self._show_buttons(show_retry=True)
                
        except ConfigError as e:
            self._update_status("설정 오류 ✗", str(e))
            self._show_buttons(show_retry=False)
        except DownloadError as e:
            self._update_status("다운로드 실패 ✗", str(e))
            self._show_buttons(show_retry=True)
        except Exception as e:
            logger.exception("알 수 없는 오류")
            self._update_status("알 수 없는 오류 ✗", str(e))
            self._show_buttons(show_retry=True)
    
    def _on_close(self):
        """창 닫기"""
        # 임시 파일 정리
        if self.download_path and self.download_path.exists():
            try:
                self.download_path.unlink()
            except (OSError, PermissionError):
                pass
        
        # config.json 정리
        config_path = Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
        if config_path.exists():
            try:
                config_path.unlink()
            except (OSError, PermissionError):
                pass
        
        self.destroy()


# ============================================================================
# 메인 실행
# ============================================================================

def main():
    """updater.exe 진입점"""
    app = UpdaterGUI()
    app.mainloop()


if __name__ == "__main__":
    main()
