import os
import zipfile
import pathlib
from typing import List, NamedTuple, Tuple


class PlannedChange(NamedTuple):
    kind: str  # "delete", "override", "add", "exec"
    path: str  # относительный путь или описание скрипта
    exists: bool  # существует ли цель сейчас


class UpdateInstaller:
    """Установщик обновлений Sucrose Launcher (Windows-only)"""

    def __init__(self, downloads_dir: str, game_dir: str, launcher_dir: str,
                 log, osa):
        self.downloads = pathlib.Path(downloads_dir)
        self.game = pathlib.Path(game_dir)
        self.launcher = launcher_dir  # путь к корню лаунчера (туда копируют скрипты)
        self.temp = self.downloads / "temp"
        self.log = log
        self.osa = osa

    # ───── Публичный API ─────

    def install(self, upd_code: str) -> bool:
        """Установить обновление. Возвращает True при успехе."""
        try:
            root = self._unpack(upd_code)
            self._apply(root)
            self.log.i(f"✅ {upd_code} установлено")
            return True
        except Exception as e:
            self.log.e(f"✗ Сбой установки {upd_code}: {e}")
            import traceback
            self.log.d(traceback.format_exc())
            return False

    def dry_run(self, upd_code: str) -> List[PlannedChange]:
        """Вернуть список запланированных изменений без их применения."""
        root = self._unpack(upd_code)
        changes = []
        changes.extend(self._plan_deletions(root))
        changes.extend(self._plan_override(root))
        changes.extend(self._plan_add_if_missing(root))
        changes.extend(self._plan_scripts(root))
        return changes

    def show_notes(self, upd_code: str):
        notes = self.temp / upd_code / "notes.txt"
        if notes.exists():
            print(f"\n=== 📝 {upd_code} ===")
            print(notes.read_text(encoding='utf-8'))
            print("=" * 30)
            input("[Enter]")

    # ───── Распаковка ─────

    def _unpack(self, upd_code: str) -> pathlib.Path:
        """Распаковать архив → /temp/{upd_code}/ (идемпотентно)"""
        root = self.temp / upd_code
        if not root.exists():
            self.temp.mkdir(exist_ok=True)
            archive = self.downloads / f"{upd_code}.zip"

            # Защита от ZipSlip (пути с ../ внутри архива)
            with zipfile.ZipFile(archive, 'r') as zf:
                for member in zf.infolist():
                    target = root / member.filename
                    if not self._is_safe_extract_path(root, target):
                        raise ValueError(f"Опасный путь в архиве: {member.filename}")
                zf.extractall(root)

            self.log.d(f"Распаковано: {root}")
        return root

    @staticmethod
    def _is_safe_extract_path(base: pathlib.Path, target: pathlib.Path) -> bool:
        """Защита от атак ZipSlip при распаковке"""
        return str(target.resolve()).startswith(str(base.resolve()) + os.sep)

    # ───── Применение изменений ─────

    def _apply(self, root: pathlib.Path):
        self._delete_files(root)
        self._copy_override(root)
        self._copy_add_if_missing(root)
        self._run_scripts(root)

    def _delete_files(self, root: pathlib.Path):
        for rel in self._read_delete_list(root):
            if ".." in pathlib.PureWindowsPath(rel).parts:
                self.log.w(f"  ✗ Пропущен опасный путь: {rel}")
                continue
            target = self.game / rel
            if target.exists():
                self.log.d(f"  → удалено: {rel}")
                self.osa.files.remove(str(target))
            else:
                self.log.d(f"  · {rel} (не существует)")

    def _copy_override(self, root: pathlib.Path):
        src = root / "override"
        if src.exists() and any(src.iterdir()):
            self.log.i("Копирование override/")
            self.osa.files.cp(str(src) + "/", str(self.game))

    def _copy_add_if_missing(self, root: pathlib.Path):
        src = root / "add_if_missing"
        if not (src.exists() and any(src.iterdir())):
            return
        self.log.i("add_if_missing/")
        for item in src.rglob("*"):
            if item.is_file():
                dst = self.game / item.relative_to(src)
                if not dst.exists():
                    dst.parent.mkdir(parents=True, exist_ok=True)
                    self.osa.files.cp(str(item), str(dst))
                    self.log.d(f"  + {dst.relative_to(self.game)}")

    def _run_scripts(self, root: pathlib.Path):
        """
        Запуск скриптов из exec/
        Структура: exec/{step_name}/install.bat + сопутствующие файлы
        Запускаем каждый .bat из его родительской папки, передавая launcher_dir как %1
        """
        exec_dir = root / "exec"
        if not exec_dir.exists():
            return

        # Собираем все .bat в подпапках первого уровня внутри exec/
        script_steps: List[Tuple[pathlib.Path, pathlib.Path]] = []  # (папка_шага, путь_к_скрипту)
        for step_dir in sorted(exec_dir.iterdir()):
            if step_dir.is_dir():
                for bat in step_dir.glob("*.bat"):
                    script_steps.append((step_dir, bat))

        if not script_steps:
            return

        self.log.i(f"Запуск скриптов ({len(script_steps)} шагов)")
        for step_dir, bat in script_steps:
            step_name = step_dir.name
            self.log.d(f"  → {step_name}/{bat.name}")
            try:
                # Запускаем из папки шага — чтобы относительные пути в скрипте работали
                self.osa.processes.exec(
                    ['cmd', '/c', bat.name, self.launcher],
                    workdir=str(step_dir)
                )
            except Exception as e:
                self.log.e(f"Ошибка в шаге {step_name}: {e}")
                raise RuntimeError(f"Сбой в скрипте {step_name}/{bat.name}") from e

    # ───── Планирование для dry_run ─────

    def _plan_deletions(self, root: pathlib.Path) -> List[PlannedChange]:
        paths = self._read_delete_list(root)
        return [PlannedChange("delete", p, (self.game / p).exists()) for p in paths]

    def _plan_override(self, root: pathlib.Path) -> List[PlannedChange]:
        src = root / "override"
        if not (src.exists() and any(src.iterdir())):
            return []
        files = [f for f in src.rglob("*") if f.is_file()]
        return [PlannedChange("override", str(f.relative_to(src)), True) for f in files]

    def _plan_add_if_missing(self, root: pathlib.Path) -> List[PlannedChange]:
        src = root / "add_if_missing"
        if not (src.exists() and any(src.iterdir())):
            return []
        files = [f for f in src.rglob("*") if f.is_file()]
        return [
            PlannedChange("add", str(f.relative_to(src)), (self.game / f.relative_to(src)).exists())
            for f in files
        ]

    def _plan_scripts(self, root: pathlib.Path) -> List[PlannedChange]:
        exec_dir = root / "exec"
        if not exec_dir.exists():
            return []

        steps = []
        for step_dir in sorted(exec_dir.iterdir()):
            if step_dir.is_dir():
                bats = list(step_dir.glob("*.bat"))
                if bats:
                    desc = f"{step_dir.name}/ ({len(bats)} скрипт)"
                    steps.append(PlannedChange("exec", desc, True))
        return steps

    # ───── Утилиты ─────

    def _read_delete_list(self, root: pathlib.Path) -> List[str]:
        f = root / "delete.txt"
        if not f.exists():
            return []
        raw = self.osa.files.read_str_list(str(f))
        return [p.strip() for p in raw if p.strip()]