"""Проверка и установка обновлений панели с сайта AXON."""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import threading
import zipfile
from pathlib import Path
from typing import Any

import requests

from panel_defaults import PANEL_APP_VERSION, PANEL_UPDATE_MANIFEST_URL
from paths import (
    EXE_NAME,
    get_installed_exe_path,
    get_panel_data_dir,
    get_panel_install_dir,
    get_panel_launch_exe,
    is_frozen_app,
    is_onedir_install,
    is_running_onefile,
)

logger = logging.getLogger(__name__)

UPDATE_STATE_FILE = get_panel_data_dir() / "update_state.json"
DOWNLOAD_TIMEOUT_SEC = 600
MANIFEST_TIMEOUT_SEC = 15
MIN_EXE_BYTES = 5 * 1024 * 1024
MIN_ZIP_BYTES = 10 * 1024 * 1024


def _parse_version(raw: str) -> tuple[int, ...]:
    nums = [int(x) for x in re.findall(r"\d+", str(raw or ""))]
    return tuple(nums) if nums else (0,)


def _version_gt(a: str, b: str) -> bool:
    return _parse_version(a) > _parse_version(b)


def _load_state() -> dict[str, Any]:
    if not UPDATE_STATE_FILE.exists():
        return {}
    try:
        data = json.loads(UPDATE_STATE_FILE.read_text(encoding="utf-8"))
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def _save_state(data: dict[str, Any]) -> None:
    UPDATE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    UPDATE_STATE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def current_version_info() -> dict[str, Any]:
    exe = get_installed_exe_path()
    launch = get_panel_launch_exe()
    return {
        "version": PANEL_APP_VERSION,
        "updatable": exe is not None,
        "exe_path": str(exe) if exe else "",
        "launch_path": str(launch) if launch else "",
        "install_dir": str(get_panel_install_dir()),
        "manifest_url": PANEL_UPDATE_MANIFEST_URL,
        "skipped_version": str(_load_state().get("skipped_version") or ""),
    }


def _parse_json_bytes(raw: bytes) -> Any:
    text = raw.decode("utf-8-sig")
    return json.loads(text)


def _fetch_manifest() -> dict[str, Any]:
    resp = requests.get(PANEL_UPDATE_MANIFEST_URL, timeout=MANIFEST_TIMEOUT_SEC)
    resp.raise_for_status()
    data = _parse_json_bytes(resp.content)
    if not isinstance(data, dict):
        raise RuntimeError("Некорректный manifest")
    version = str(data.get("version") or "").strip()
    if not version:
        raise RuntimeError("В manifest нет version")
    download_url = str(data.get("download_url") or "").strip()
    if not download_url:
        raise RuntimeError("В manifest нет download_url")
    package_type = str(data.get("package_type") or "").strip().lower()
    if not package_type:
        package_type = "zip" if download_url.lower().rstrip("/").endswith(".zip") else "exe"
    return {
        "version": version,
        "download_url": download_url,
        "package_type": package_type,
        "sha256": str(data.get("sha256") or "").strip().lower(),
        "notes": str(data.get("notes") or "").strip(),
        "released_at": str(data.get("released_at") or "").strip(),
    }


def check_for_update() -> dict[str, Any]:
    current = PANEL_APP_VERSION
    state = _load_state()
    try:
        manifest = _fetch_manifest()
    except Exception as e:
        return {
            "current_version": current,
            "latest_version": "",
            "update_available": False,
            "error": str(e),
            "skipped_version": str(state.get("skipped_version") or ""),
            "updatable": is_frozen_app(),
        }

    latest = manifest["version"]
    skipped = str(state.get("skipped_version") or "")
    available = _version_gt(latest, current)
    return {
        "current_version": current,
        "latest_version": latest,
        "update_available": available,
        "download_url": manifest["download_url"],
        "package_type": manifest.get("package_type") or "exe",
        "notes": manifest.get("notes") or "",
        "released_at": manifest.get("released_at") or "",
        "skipped_version": skipped,
        "skipped_this_version": bool(available and skipped == latest),
        "updatable": is_frozen_app(),
    }


def skip_update_version(version: str) -> dict[str, Any]:
    ver = str(version or "").strip()
    state = _load_state()
    state["skipped_version"] = ver
    _save_state(state)
    return {"skipped_version": ver}


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


def _verify_download(path: Path, *, min_bytes: int, expected_sha256: str = "") -> int:
    size = path.stat().st_size
    if size < min_bytes:
        raise RuntimeError("Скачанный файл слишком маленький")
    if expected_sha256:
        actual = _sha256_file(path)
        if actual != expected_sha256.lower():
            raise RuntimeError("Контрольная сумма файла не совпадает")
    return size


def _download_file(download_url: str, dest: Path, *, min_bytes: int, expected_sha256: str = "") -> int:
    dest.parent.mkdir(parents=True, exist_ok=True)
    part = dest.with_suffix(dest.suffix + ".part")
    if part.exists():
        part.unlink()
    with requests.get(download_url, stream=True, timeout=DOWNLOAD_TIMEOUT_SEC) as resp:
        resp.raise_for_status()
        total = 0
        with part.open("wb") as fh:
            for chunk in resp.iter_content(chunk_size=1024 * 256):
                if not chunk:
                    continue
                fh.write(chunk)
                total += len(chunk)
    if total < min_bytes:
        part.unlink(missing_ok=True)
        raise RuntimeError("Скачанный файл слишком маленький")
    if expected_sha256:
        actual = _sha256_file(part)
        if actual != expected_sha256.lower():
            part.unlink(missing_ok=True)
            raise RuntimeError("Контрольная сумма файла не совпадает")
    if dest.exists():
        dest.unlink()
    part.replace(dest)
    return total


def _find_extracted_app_root(extract_dir: Path) -> Path:
    direct = extract_dir / EXE_NAME
    if direct.exists():
        return extract_dir
    for child in extract_dir.iterdir():
        if child.is_dir() and (child / EXE_NAME).exists():
            return child
    raise RuntimeError(f"В архиве нет {EXE_NAME}")


def _extract_zip(zip_path: Path, extract_dir: Path) -> Path:
    if extract_dir.exists():
        shutil.rmtree(extract_dir, ignore_errors=True)
    extract_dir.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(zip_path, "r") as archive:
        archive.extractall(extract_dir)
    return _find_extracted_app_root(extract_dir)


def _resolve_install_icon(install_dir: Path) -> Path:
    for rel in (
        Path("_internal") / "assets" / "axon.ico",
        Path("assets") / "axon.ico",
    ):
        candidate = install_dir / rel
        if candidate.exists():
            return candidate.resolve()
    return (install_dir / EXE_NAME).resolve()


def _desktop_dir() -> Path:
    return Path(os.environ.get("USERPROFILE", str(Path.home()))) / "Desktop"


def _schedule_apply_update(*, launch_exe: Path, staging_dir: Path | None = None) -> None:
    data_dir = get_panel_data_dir()
    install_dir = get_panel_install_dir()
    bat_path = data_dir / "_apply_panel_update.bat"
    log_path = data_dir / "update_last.log"
    launch_exe = launch_exe.resolve()
    install_dir = install_dir.resolve()
    desktop = _desktop_dir()
    shortcut = desktop / "AXON Dump Radar.lnk"
    desktop_exe = desktop / EXE_NAME
    icon_ico = _resolve_install_icon(install_dir if staging_dir is not None else launch_exe.parent)

    lines = [
        "@echo off",
        "setlocal EnableExtensions",
        f'set "LOG={log_path}"',
        f'echo [%date% %time%] Update start>>"%LOG%"',
        "ping 127.0.0.1 -n 3 >nul",
        "set /a TRIES=0",
        ":wait_proc",
        "set /a TRIES+=1",
        'tasklist /FI "IMAGENAME eq AXON Dump Radar.exe" 2>nul | find /I "AXON Dump Radar.exe" >nul',
        "if not errorlevel 1 (",
        "  if %TRIES% GEQ 45 (",
        '    echo [%date% %time%] Force kill>>"%LOG%"',
        '    taskkill /F /IM "AXON Dump Radar.exe" >nul 2>&1',
        "    ping 127.0.0.1 -n 3 >nul",
        "    goto after_wait",
        "  )",
        "  ping 127.0.0.1 -n 2 >nul",
        "  goto wait_proc",
        ")",
        ":after_wait",
        f'echo [%date% %time%] Process exited>>"%LOG%"',
    ]

    if staging_dir is not None:
        staging_dir = staging_dir.resolve()
        lines.extend(
            [
                f'if not exist "{install_dir}" mkdir "{install_dir}"',
                (
                    f'robocopy "{staging_dir}" "{install_dir}" /E /R:5 /W:2 '
                    f"/NFL /NDL /NJH /NJS /NC /NS /NP"
                ),
                "set RC=%ERRORLEVEL%",
                f'echo [%date% %time%] robocopy exit %RC%>>"%LOG%"',
                "if %RC% GEQ 8 (",
                f'  echo Robocopy failed>>"%LOG%"',
                '  msg * "AXON Dump Radar: не удалось установить обновление. См. update_last.log в папке AXON-Dump-Radar (AppData). Скачайте zip с axon-trade.com/downloads"',
                "  exit /b 1",
                ")",
            ]
        )
        launch_exe = install_dir / EXE_NAME

    lines.extend(
        [
            f'if exist "{desktop_exe}" del /F /Q "{desktop_exe}"',
            f'if exist "{desktop_exe}.bak" del /F /Q "{desktop_exe}.bak"',
        ]
    )

    lines.extend(
        [
            f'if not exist "{launch_exe}" (',
            f'  echo Launch exe missing: {launch_exe}>>"%LOG%"',
            '  msg * "AXON Dump Radar: после обновления не найден exe. Скачайте установку с axon-trade.com/downloads"',
            "  exit /b 1",
            ")",
            f'echo [%date% %time%] Starting {launch_exe}>>"%LOG%"',
            f'start "" /D "{launch_exe.parent}" "{launch_exe}"',
            (
                "powershell -NoProfile -Command "
                f"\"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{shortcut}'); "
                f"$s.TargetPath='{launch_exe}'; "
                f"$s.WorkingDirectory='{launch_exe.parent}'; "
                f"$s.IconLocation='{icon_ico}'; "
                f"$s.Save()\""
            ),
            f'del /F /Q "{bat_path}"',
            "endlocal",
        ]
    )

    bat_path.write_text("\r\n".join(lines), encoding="utf-8")
    # Без CREATE_NO_WINDOW — скрытый cmd чаще режет Defender при обновлении.
    subprocess.Popen(["cmd", "/c", str(bat_path)], close_fds=True)


def _schedule_exe_replace(new_exe: Path, current_exe: Path) -> None:
    data_dir = get_panel_data_dir()
    bat_path = data_dir / "_apply_panel_update.bat"
    log_path = data_dir / "update_last.log"
    backup = current_exe.with_suffix(".exe.bak")
    new_exe = new_exe.resolve()
    current_exe = current_exe.resolve()

    bat = "\r\n".join(
        [
            "@echo off",
            "setlocal EnableExtensions",
            f'set "LOG={log_path}"',
            f'echo [%date% %time%] Exe replace start>>"%LOG%"',
            "set /a TRIES=0",
            ":wait_proc",
            "set /a TRIES+=1",
            'tasklist /FI "IMAGENAME eq AXON Dump Radar.exe" 2>nul | find /I "AXON Dump Radar.exe" >nul',
            "if not errorlevel 1 (",
            "  if %TRIES% GEQ 45 (",
            '    echo [%date% %time%] Force kill>>"%LOG%"',
            '    taskkill /F /IM "AXON Dump Radar.exe" >nul 2>&1',
            "    ping 127.0.0.1 -n 3 >nul",
            "    goto after_wait",
            "  )",
            "  ping 127.0.0.1 -n 2 >nul",
            "  goto wait_proc",
            ")",
            ":after_wait",
            f'if exist "{backup}" del /F /Q "{backup}"',
            f'if exist "{current_exe}" move /Y "{current_exe}" "{backup}"',
            f'copy /Y "{new_exe}" "{current_exe}"',
            f'if errorlevel 1 (',
            f'  echo Copy failed>>"%LOG%"',
            f'  if exist "{backup}" move /Y "{backup}" "{current_exe}"',
            '  msg * "AXON Dump Radar: не удалось заменить exe. См. update_last.log"',
            f'  exit /b 1',
            f')',
            f'del /F /Q "{new_exe}"',
            f'echo [%date% %time%] Starting {current_exe}>>"%LOG%"',
            f'start "" /D "{current_exe.parent}" "{current_exe}"',
            f'del /F /Q "{bat_path}"',
            "endlocal",
        ]
    )
    bat_path.write_text(bat, encoding="utf-8")
    subprocess.Popen(["cmd", "/c", str(bat_path)], close_fds=True)


def download_and_apply_update(window=None) -> dict[str, Any]:
    if not is_frozen_app():
        raise RuntimeError("Обновление доступно только в собранном .exe")

    current_exe = get_installed_exe_path()
    if not current_exe or not current_exe.exists():
        raise RuntimeError("Не найден путь к текущему .exe")

    info = check_for_update()
    if not info.get("update_available"):
        raise RuntimeError("Обновление не требуется")
    if info.get("error"):
        raise RuntimeError(str(info["error"]))

    manifest = _fetch_manifest()
    download_url = str(manifest.get("download_url") or "")
    package_type = str(manifest.get("package_type") or "exe").lower()
    expected_sha256 = str(manifest.get("sha256") or "")

    updates_dir = get_panel_data_dir() / "updates"
    updates_dir.mkdir(parents=True, exist_ok=True)

    if package_type == "zip":
        zip_path = updates_dir / f"panel-{manifest['version']}.zip"
        bytes_written = _download_file(
            download_url,
            zip_path,
            min_bytes=MIN_ZIP_BYTES,
            expected_sha256=expected_sha256,
        )
        staging = updates_dir / f"staging-{manifest['version']}"
        app_root = _extract_zip(zip_path, staging)
        launch_exe = app_root / EXE_NAME
        _schedule_apply_update(launch_exe=launch_exe, staging_dir=app_root)
    else:
        new_exe = updates_dir / EXE_NAME
        bytes_written = _download_file(
            download_url,
            new_exe,
            min_bytes=MIN_EXE_BYTES,
            expected_sha256=expected_sha256,
        )
        _verify_download(new_exe, min_bytes=MIN_EXE_BYTES, expected_sha256=expected_sha256)
        _schedule_exe_replace(new_exe, current_exe)

    def _exit_app() -> None:
        try:
            if window is not None:
                from tray_service import quit_app

                quit_app(window)
        except Exception:
            pass
        # Гарантированно завершаем процесс — иначе bat не может перезаписать файлы.
        threading.Timer(2.0, lambda: os._exit(0)).start()

    threading.Timer(1.0, _exit_app).start()
    return {
        "restarting": True,
        "bytes": bytes_written,
        "latest_version": info.get("latest_version"),
        "package_type": package_type,
    }


def _is_running_onedir() -> bool:
    exe = get_installed_exe_path()
    if not exe:
        return False
    return (exe.parent / "_internal").is_dir()


def maybe_auto_migrate_to_appdir() -> bool:
    """После перехода с onefile — тихо ставим onedir в AppData (без повторного клика)."""
    if not is_frozen_app():
        return False

    state = _load_state()
    install_dir = get_panel_install_dir()

    if is_onedir_install(install_dir):
        state["appdir_migrated"] = True
        _save_state(state)
        return False

    if state.get("appdir_migrated") and not is_running_onefile():
        return False

    zip_url = "https://axon-trade.com/downloads/axon-dump-radar.zip"
    updates_dir = get_panel_data_dir() / "updates"
    updates_dir.mkdir(parents=True, exist_ok=True)
    zip_path = updates_dir / "panel-appdir-bootstrap.zip"
    staging = updates_dir / "staging-appdir-bootstrap"

    try:
        _download_file(zip_url, zip_path, min_bytes=MIN_ZIP_BYTES)
        app_root = _extract_zip(zip_path, staging)
        launch_exe = app_root / EXE_NAME
        _schedule_apply_update(launch_exe=launch_exe, staging_dir=app_root)
        state["appdir_migrated"] = True
        _save_state(state)
        return True
    except Exception as e:
        logger.warning("auto migrate: %s", e)
        return False


def cli_check_update() -> int:
    """Фоновая проверка без UI панели (MessageBox на Windows)."""
    if not is_frozen_app():
        return 0
    try:
        info = check_for_update()
    except Exception:
        return 0
    if not info.get("update_available") or info.get("skipped_this_version"):
        return 0
    if sys.platform != "win32":
        return 0
    try:
        import ctypes

        latest = info.get("latest_version") or "?"
        current = info.get("current_version") or "?"
        text = (
            f"Доступна новая версия AXON Dump Radar.\n\n"
            f"Сейчас: {current}\nНовая: {latest}\n\n"
            f"Рекомендуем обновить. Панель перезапустится."
        )
        choice = ctypes.windll.user32.MessageBoxW(
            0,
            text,
            "AXON Dump Radar — обновление",
            0x21,  # MB_OKCANCEL | MB_ICONQUESTION
        )
        if choice != 1:  # IDCANCEL
            skip_update_version(latest)
            return 0
        download_and_apply_update()
        return 0
    except Exception as e:
        logger.warning("cli_check_update: %s", e)
        return 1
