"""
Авто-обновление TelePush.
Обновляет _internal/ (код, шаблоны, статику) — exe не трогаем.
"""
import logging
import os
import sys
import shutil
import zipfile
import requests
from pathlib import Path
from config import TELEPUSH_VERSION, TELEPUSH_SERVER_URL, DATA_DIR

logger = logging.getLogger("telepush.updater")

UPDATE_DIR = DATA_DIR / "update_tmp"

# Папки которые НЕ обновляем (данные юзера)
SKIP_DIRS = {"data", "logs", "db", "sessions", "update_tmp"}
SKIP_FILES = {".flask_secret", "telepush.lock", "telepush.sqlite3", "config.json", "license.json"}


def _parse_version(v):
    try:
        return tuple(int(x) for x in str(v).split("."))
    except Exception:
        return (0, 0, 0)


def check_update() -> dict:
    try:
        try:
            from utils.hwid import generate_hwid
            _hwid = generate_hwid()
        except Exception:
            _hwid = ""
        resp = requests.get(
            f"{TELEPUSH_SERVER_URL}/api/version",
            params={"hwid": _hwid} if _hwid else None,
            timeout=10,
        )
        data = resp.json()
        server_version = data.get("version", "")
        # Force restart disabled — client decides when to update
        if server_version and _parse_version(server_version) > _parse_version(TELEPUSH_VERSION):
            return {
                "available": True,
                "version": server_version,
                "changelog": data.get("changelog", ""),
            }
        return {"available": False}
    except Exception as e:
        logger.debug("Update check failed: %s", e)
        return {"available": False}


def _backup_user_settings():
    """Гарантирует, что перед обновой user_settings.json лежит в стабильном
    месте (~/.telepush/), которое живёт вне DATA_DIR и переживёт любой update.
    Копируем туда из самого свежего доступного источника."""
    try:
        stable = Path.home() / ".telepush" / "user_settings.json"
        stable.parent.mkdir(parents=True, exist_ok=True)

        candidates = [
            stable,
            DATA_DIR / "user_settings.json",
            DATA_DIR / "user_settings.backup.json",
        ]
        # Берём самый свежий валидный файл
        best = None
        best_mtime = -1.0
        import json as _j
        for c in candidates:
            try:
                if c.exists() and c.stat().st_size > 0:
                    _j.loads(c.read_text(encoding="utf-8"))  # проверка что JSON валиден
                    m = c.stat().st_mtime
                    if m > best_mtime:
                        best_mtime = m
                        best = c
            except Exception:
                continue

        if not best:
            logger.info("[backup] настроек нет — пропускаю")
            return

        raw = best.read_bytes()
        if best != stable:
            stable.write_bytes(raw)
            logger.info("[backup] настройки скопированы в %s из %s (%d байт)", stable, best, len(raw))
        else:
            logger.info("[backup] настройки уже в стабильном месте: %s (%d байт)", stable, len(raw))
    except Exception as e:
        logger.warning("[backup] user_settings backup failed: %s", e)


MANUAL_DOWNLOAD_URL = f"{TELEPUSH_SERVER_URL}/api/download?v=latest"


def download_update(progress_cb=None) -> dict:
    """Скачивает и распаковывает обновление. Возвращает dict с результатом.
    {ok: True, source_dir: Path} — успех, готов к install_files()
    {ok: False, error_code: str, message: str} — с конкретной причиной"""

    # 0. Бекап настроек (страховка от потери)
    _backup_user_settings()

    UPDATE_DIR.mkdir(parents=True, exist_ok=True)
    zip_path = UPDATE_DIR / "update.zip"

    # 1. Скачиваем — ловим каждый класс ошибок отдельно
    logger.info("Downloading update from %s", MANUAL_DOWNLOAD_URL)
    try:
        resp = requests.get(
            MANUAL_DOWNLOAD_URL,
            timeout=(15, 600),  # connect=15s, read=600s
            stream=True,
        )
        resp.raise_for_status()
    except requests.exceptions.SSLError as e:
        return {"ok": False, "error_code": "ssl",
                "message": f"Ошибка SSL-сертификата ({e}). Обнови Windows, проверь дату на ПК или отключи антивирус с проверкой HTTPS. Скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    except requests.exceptions.ConnectionError as e:
        return {"ok": False, "error_code": "no_internet",
                "message": f"Нет соединения с сервером обновлений. Проверь интернет/VPN. Скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    except requests.exceptions.Timeout:
        return {"ok": False, "error_code": "timeout",
                "message": f"Сервер не ответил за 15 сек. Попробуй позже или скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    except requests.exceptions.HTTPError:
        sc = getattr(resp, "status_code", 0)
        return {"ok": False, "error_code": f"http_{sc}",
                "message": f"Сервер вернул код {sc}. Попробуй позже или скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    except Exception as e:
        return {"ok": False, "error_code": "download_other",
                "message": f"Ошибка скачивания ({type(e).__name__}: {e}). Скачай вручную: {MANUAL_DOWNLOAD_URL}"}

    total = int(resp.headers.get('Content-Length', 0))
    logger.info("Download started: %d MB", total // 1048576)

    # 2. Пишем на диск — ошибки диска/прав тут
    got = 0
    try:
        with open(str(zip_path), "wb") as f:
            for chunk in resp.iter_content(chunk_size=65536):
                f.write(chunk)
                got += len(chunk)
                if progress_cb and total > 0:
                    try:
                        progress_cb(got, total)
                    except Exception:
                        pass
    except OSError as e:
        return {"ok": False, "error_code": "disk",
                "message": f"Не удалось сохранить обновление на диск (код {e.errno}). Проверь свободное место и права на папку {UPDATE_DIR}"}
    except Exception as e:
        return {"ok": False, "error_code": "write_other",
                "message": f"Ошибка записи файла ({type(e).__name__}: {e})"}

    logger.info("Downloaded %d bytes", got)

    # Проверка что файл нулевой/битый
    if got == 0:
        return {"ok": False, "error_code": "empty",
                "message": f"Сервер отдал пустой файл. Попробуй позже или скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    if total > 0 and got < total * 0.9:
        return {"ok": False, "error_code": "incomplete",
                "message": f"Скачивание оборвалось ({got}/{total} байт). Проверь интернет и попробуй ещё раз."}

    # 3. Распаковываем — BadZipFile = битый zip
    extract_dir = UPDATE_DIR / "extracted"
    if extract_dir.exists():
        shutil.rmtree(str(extract_dir), ignore_errors=True)
    extract_dir.mkdir(parents=True, exist_ok=True)

    try:
        with zipfile.ZipFile(str(zip_path), "r") as zf:
            zf.extractall(str(extract_dir))
    except zipfile.BadZipFile:
        return {"ok": False, "error_code": "bad_zip",
                "message": f"Скачанный файл повреждён (не zip). Антивирус или нестабильный интернет. Попробуй ещё раз или скачай вручную: {MANUAL_DOWNLOAD_URL}"}
    except Exception as e:
        return {"ok": False, "error_code": "extract_other",
                "message": f"Ошибка распаковки ({type(e).__name__}: {e})"}

    contents = list(extract_dir.iterdir())
    source_dir = contents[0] if len(contents) == 1 and contents[0].is_dir() else extract_dir
    return {"ok": True, "source_dir": source_dir, "bytes": got}


def install_update():
    """Скачивает ZIP, обновляет _internal/ (код), перезапускает.
    При ошибке возвращает dict {ok: False, error_code, message}.
    При успехе не возвращает — перезапускает процесс через os._exit."""
    try:
        dl = download_update()
        if not dl.get("ok"):
            logger.error("[update] download failed: %s", dl.get("message"))
            return dl
        source_dir = dl["source_dir"]

        # 3. Определяем пути
        if getattr(sys, 'frozen', False):
            app_dir = Path(sys.executable).parent
        else:
            app_dir = Path(__file__).parent.parent

        # 4. Обновляем _internal/ (весь код) — exe НЕ трогаем
        source_internal = source_dir / "_internal"
        dest_internal = app_dir / "_internal"

        copied = 0
        errors = 0
        skipped_identical = 0  # файлы которые уже совпадают — копировать не надо

        def _files_identical(a, b):
            """Быстрая проверка идентичности: size + mtime. Бинарникам этого
            хватает, потому что shipping pipeline сохраняет mtime."""
            try:
                sa, sb = a.stat(), b.stat()
                if sa.st_size != sb.st_size:
                    return False
                # Допуск 2 сек по mtime — Windows иногда округляет
                if abs(sa.st_mtime - sb.st_mtime) <= 2:
                    return True
                # Если mtime разный, но размер совпал — для бинарников делаем
                # быстрый hash-чек чтобы не копировать впустую.
                if a.suffix.lower() in ('.pyd', '.dll', '.so', '.dylib'):
                    import hashlib
                    h1, h2 = hashlib.md5(), hashlib.md5()
                    h1.update(a.read_bytes())
                    h2.update(b.read_bytes())
                    return h1.digest() == h2.digest()
                return False
            except Exception:
                return False

        if source_internal.exists() and dest_internal.exists():
            # Обновляем _internal/ — тут весь Python код
            for item in source_internal.rglob("*"):
                rel = item.relative_to(source_internal)
                top_part = rel.parts[0].lower() if rel.parts else ""

                # Пропускаем data внутри _internal
                if top_part in SKIP_DIRS:
                    continue
                if rel.name.lower() in SKIP_FILES:
                    continue

                dest = dest_internal / rel
                if item.is_dir():
                    dest.mkdir(parents=True, exist_ok=True)
                else:
                    dest.parent.mkdir(parents=True, exist_ok=True)
                    # Пропускаем если файл уже идентичен — это снимает
                    # ~48 «PermissionError» от заблокированных .pyd/.dll
                    # которые в любом случае не меняются между релизами.
                    if dest.exists() and _files_identical(item, dest):
                        skipped_identical += 1
                        continue
                    try:
                        shutil.copy2(str(item), str(dest))
                        copied += 1
                    except PermissionError:
                        # Файл локирован запущенным процессом, но возможно
                        # содержимое идентично — тогда это не ошибка.
                        if dest.exists() and _files_identical(item, dest):
                            skipped_identical += 1
                        else:
                            errors += 1
                            logger.debug("Locked, skip: %s", rel)
                    except Exception as e:
                        errors += 1
                        logger.debug("Copy error %s: %s", rel, e)
        else:
            # Не frozen или нет _internal — копируем всё кроме exe и data
            for item in source_dir.rglob("*"):
                rel = item.relative_to(source_dir)
                top_part = rel.parts[0].lower() if rel.parts else ""

                if top_part in SKIP_DIRS:
                    continue
                if rel.name.lower() in SKIP_FILES:
                    continue
                # Не трогаем exe
                if rel.name.lower().endswith('.exe'):
                    continue

                dest = app_dir / rel
                if item.is_dir():
                    dest.mkdir(parents=True, exist_ok=True)
                else:
                    dest.parent.mkdir(parents=True, exist_ok=True)
                    try:
                        shutil.copy2(str(item), str(dest))
                        copied += 1
                    except Exception:
                        errors += 1

        # Также обновляем .version во ВСЕХ местах, откуда лаунчер её читает,
        # иначе он каждый следующий запуск будет думать что надо обновляться заново.
        # Лаунчер (client/launcher.py::get_ver) проверяет:
        #   1) BASE / ".tp_ver"         — маркер успешной установки лаунчера
        #   2) BASE / ".version"        — корень установки (где лежит TelePush.exe лаунчера)
        #   3) APP_DIR / ".version"     — папка app/ (где лежит frozen-exe программы)
        # Также он читает APP_DIR / "_internal" / ".version" — туда кладёт ZIP-сборщик.
        src_ver = source_dir / ".version"
        if src_ver.exists():
            try:
                _ver_str = src_ver.read_text(encoding="utf-8").strip() if src_ver.exists() else ""
            except Exception:
                _ver_str = ""
            _targets = [
                app_dir / ".version",                       # frozen exe parent
                app_dir / "_internal" / ".version",         # _internal (dev и launcher read)
                app_dir.parent / ".version",                # корень установки (BASE лаунчера)
                app_dir.parent / ".tp_ver",                 # маркер лаунчера
            ]
            for _t in _targets:
                try:
                    _t.parent.mkdir(parents=True, exist_ok=True)
                    if _ver_str:
                        _t.write_text(_ver_str, encoding="utf-8")
                    else:
                        shutil.copy2(str(src_ver), str(_t))
                except Exception as _ve:
                    logger.debug("[updater] version write %s failed: %s", _t, _ve)
            logger.info("[updater] .version обновлён в %d мест (%s)", len(_targets), _ver_str or "?")

        if skipped_identical:
            logger.info("Update applied: %d files copied, %d skipped (identical), %d errors",
                        copied, skipped_identical, errors)
        else:
            logger.info("Update applied: %d files copied, %d errors", copied, errors)

        # 5. Cleanup
        try:
            shutil.rmtree(str(UPDATE_DIR), ignore_errors=True)
        except Exception:
            pass

        # 6. Перезапуск через лаунчер — делаем в фоне, чтобы endpoint успел
        # отдать клиенту JSON {ok:true} и UI показал "Обновление установлено".
        def _delayed_restart():
            import time as _t
            _t.sleep(2)  # Даём Flask 2 сек отдать ответ клиенту
            try:
                (DATA_DIR / "telepush.lock").unlink(missing_ok=True)
            except Exception:
                pass
            _launcher = DATA_DIR.parent / "TelePush.exe"
            if _launcher.exists():
                try:
                    import subprocess as _sp
                    _sp.Popen([str(_launcher)], cwd=str(_launcher.parent))
                    logger.info("Launcher started, exiting...")
                except Exception as _le:
                    logger.error("Failed to start launcher: %s", _le)
            _t.sleep(0.5)
            try:
                (DATA_DIR / "telepush.lock").unlink(missing_ok=True)
            except Exception:
                pass
            os._exit(0)

        import threading as _thr
        _thr.Thread(target=_delayed_restart, daemon=True).start()
        return {"ok": True, "copied": copied, "errors": errors}

    except Exception as e:
        import traceback
        logger.error("Update failed: %s\n%s", e, traceback.format_exc())
        try:
            shutil.rmtree(str(UPDATE_DIR), ignore_errors=True)
        except Exception:
            pass
        return {"ok": False, "error_code": "install_other",
                "message": f"Ошибка установки ({type(e).__name__}: {str(e)[:150]})"}
