"""wivexx_update.py — in-app auto-update (like a game launcher / desktop app).

Wivexx checks a small version manifest on wivexx.com; if a newer build exists it
offers a one-click update: download the release zip, extract it, and swap the new
code over the install folder, then relaunch. The release zip is produced by
make_release.py and contains ONLY code/assets — never user data — so updating
never touches the user's .env, memory, goals, apps, projects, etc.

Manifest (hosted at MANIFEST_URL) is JSON:
    { "version": "1.22",
      "url":     "https://wivexx.com/downloads/Wivexx-latest.zip",
      "notes":   "What's new in this version…",
      "min":     "1.0" }          # optional: oldest version allowed to in-place update

Windows can't overwrite files that are in use, so apply_update() stages the new
files to a temp folder and launches a tiny detached .bat that waits for Wivexx to
exit, copies the new files in (preserving user data), and relaunches it.
"""
from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.request
import zipfile
from typing import Optional

MANIFEST_URL = "https://wivexx.com/api/version"
HERE = os.path.dirname(os.path.abspath(__file__))

# v1.36: wivexx.com sits behind Vercel bot mitigation that intermittently 403s
# Python's `urllib` request signature (measured ~1-in-3 under a burst) while
# letting `requests` and browsers through 100%. So the updater now goes through
# `requests` with a real browser User-Agent + a few retries; `urllib` stays as a
# fallback only if `requests` can't be imported. This is why the in-app update
# button showed "HTTP Error 403: Forbidden".
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
       "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
_HEADERS = {"User-Agent": _UA, "Accept": "application/json, */*",
            "Accept-Language": "en-US,en;q=0.9"}


def _get_json(url: str, timeout: float, retries: int = 3) -> dict:
    """GET + parse JSON, preferring requests (bot-filter-friendly) with retries."""
    last = None
    for attempt in range(retries):
        try:
            try:
                import requests
                r = requests.get(url, headers=_HEADERS, timeout=timeout)
                if r.status_code == 200:
                    return r.json()
                last = "HTTP %s" % r.status_code
            except ImportError:
                req = urllib.request.Request(url, headers=_HEADERS)
                with urllib.request.urlopen(req, timeout=timeout) as resp:
                    return json.loads(resp.read().decode("utf-8"))
        except Exception as e:                       # noqa: BLE001
            last = str(e)
        time.sleep(0.8 * (attempt + 1))              # brief backoff before retry
    raise RuntimeError(last or "request failed")

# data the updater must NEVER overwrite, even if a stray copy is in the zip
_PROTECT = {
    ".env", "credentials.json", "wivexx_memory.db", "usage.json",
    "wivexx_calibration.json", "wivexx_voiceprint.json", "wivexx_voiceprint.npy",
    "wivexx_calendar_token.json", "wivexx_drive_token.json", "wivexx_drive_state.json",
    "wivexx_apps.json", "wivexx_goals.json", "wivexx_macros.json", "wivexx_socials.json",
    "wivexx_wake_words.json", "wivexx_hotkeys.json", "wivexx_media_devices.json",
    "wivexx_video_projects.json", "wivexx_notify_prefs.json", "wivexx_hud.json",
    "wivexx_autoclicker.json", "wivexx_config.py",
    # settings / caches / state written by the running app (also DENY'd from the zip,
    # so normally never in the package — listed here as a second line of defence)
    "wivexx_settings.json", "wivexx_i18n_cache.json", "wivexx_gaming_mode.json",
    "wivexx_workmode.json", ".wivexx_preflight.json", "wallpaper_state.js",
    ".wivexx_restore.json",   # v1.55: mode snapshot for a seamless auto-update restart
}


def current_version() -> str:
    # use the already-loaded module if present — never trigger a fresh app boot
    _wa = sys.modules.get("wivexx_api")
    if _wa is not None:
        v = getattr(_wa, "WIVEXX_VERSION", None)
        if v:
            return str(v)
    try:
        txt = open(os.path.join(HERE, "wivexx_api.py"), encoding="utf-8").read()
        m = re.search(r'WIVEXX_VERSION\s*=\s*"([^"]+)"', txt)
        if m:
            return m.group(1)
    except Exception:
        pass
    return "0.0"


def _ver_tuple(v: str) -> tuple:
    return tuple(int(p) for p in re.findall(r"\d+", v or "0"))


def is_newer(remote: str, local: str) -> bool:
    """v1.0: the SERVER is the single source of truth — any difference from the
    local build counts as an update. (The old `remote > local` tuple compare
    could never roll out the official-launch renumbering 1.58 → 1.0, and would
    strand clients again on any future reset.)"""
    remote_t = _ver_tuple(remote)
    if not remote_t or remote_t == (0, 0):
        return False                     # bad/missing manifest → never "update"
    return remote_t != _ver_tuple(local)


def check(timeout: float = 8.0) -> dict:
    """Return {ok, update, current, latest, url, notes, error}."""
    cur = current_version()
    try:
        man = _get_json(MANIFEST_URL, timeout)
    except Exception as e:
        return {"ok": False, "update": False, "current": cur, "error": str(e)}
    latest = str(man.get("version") or "0.0")
    return {
        "ok": True,
        "update": is_newer(latest, cur),
        "current": cur,
        "latest": latest,
        "url": man.get("url") or "",
        "notes": man.get("notes") or "",
    }


def download(url: str, progress=None, retries: int = 3) -> str:
    """Download the release zip to a temp file; return its path. Uses requests
    (browser UA — the bot filter 403s urllib) with retries; falls back to urllib."""
    if not url:
        raise ValueError("no download url")
    fd, dest = tempfile.mkstemp(prefix="wivexx_update_", suffix=".zip")
    os.close(fd)
    last = None
    for attempt in range(retries):
        try:
            try:
                import requests
                with requests.get(url, headers=_HEADERS, timeout=120, stream=True) as r:
                    if r.status_code != 200:
                        last = "HTTP %s" % r.status_code
                        raise RuntimeError(last)
                    total = int(r.headers.get("Content-Length", 0))
                    got = 0
                    with open(dest, "wb") as f:
                        for chunk in r.iter_content(65536):
                            if not chunk:
                                continue
                            f.write(chunk)
                            got += len(chunk)
                            if progress and total:
                                try:
                                    progress(got / total)
                                except Exception:
                                    pass
            except ImportError:
                req = urllib.request.Request(url, headers=_HEADERS)
                with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f:
                    total = int(r.headers.get("Content-Length", 0))
                    got = 0
                    while True:
                        chunk = r.read(65536)
                        if not chunk:
                            break
                        f.write(chunk)
                        got += len(chunk)
                        if progress and total:
                            try:
                                progress(got / total)
                            except Exception:
                                pass
            # sanity: a real release zip is many MB — a tiny file is a blocked/error body
            if os.path.getsize(dest) > 100000:
                return dest
            last = "downloaded file too small (%d bytes)" % os.path.getsize(dest)
        except Exception as e:                       # noqa: BLE001
            last = str(e)
        time.sleep(1.0 * (attempt + 1))
    raise RuntimeError(last or "download failed")


def _extract(zip_path: str) -> tuple:
    """Extract the zip to a temp folder; return (extract_root, app_dir) — app_dir is
    the folder that holds the app files (the zip from make_release.py wraps everything
    in a top-level Wivexx-<ver>/ dir), extract_root is what to delete afterwards."""
    out = tempfile.mkdtemp(prefix="wivexx_staged_")
    with zipfile.ZipFile(zip_path) as z:
        z.extractall(out)
    entries = [os.path.join(out, e) for e in os.listdir(out)]
    dirs = [e for e in entries if os.path.isdir(e)]
    if len(dirs) == 1 and not [e for e in entries if os.path.isfile(e)]:
        return out, dirs[0]   # unwrap the single Wivexx-<ver>/ folder
    return out, out


def apply_update(zip_path: str, relaunch: str = "Wivexx.vbs") -> dict:
    """Stage the new files and launch a detached updater that swaps them in after
    Wivexx exits, then relaunches. Returns {ok, error}; on success the caller should
    promptly quit the app so the updater can copy over the (now-unlocked) files."""
    if os.name != "nt":
        return {"ok": False, "error": "auto-update is Windows-only"}
    try:
        staged_root, staged = _extract(zip_path)
    except Exception as e:
        return {"ok": False, "error": "bad update package: %s" % e}

    # robocopy copies staged → install: /E (all subdirs), /XF-excludes every protected
    # user file, and NEVER purges (so user data + extra files survive). We deliberately
    # do NOT use /XO — a changed file whose new copy happens to carry an OLDER timestamp
    # (unchanged-but-restamped, a revert/hotfix, clock skew) must still be applied.
    # /R:3 /W:2 bounds retries (robocopy's default is 1M retries × 30s — a single locked
    # file would hang the update forever). Then relaunch + clean up.
    #
    # The bat itself is PURE ASCII: cmd.exe parses batch files in the OEM codepage, not
    # UTF-8, so literal paths with accents/non-Latin characters (José, Müller, 中文…)
    # would be mangled. Every path travels via environment variables instead — cmd
    # expands those as Unicode internally, so any install/temp/user folder name works.
    # It also waits for THIS process id to really exit (max ~60s) before copying,
    # instead of a blind 2-second sleep.
    xf = " ".join('"%s"' % f for f in sorted(_PROTECT))
    log = os.path.join(tempfile.gettempdir(), "wivexx_update.log")
    bat = os.path.join(tempfile.gettempdir(), "wivexx_apply_update.bat")
    relaunch_path = os.path.join(HERE, relaunch)
    script = """@echo off
echo Updating Wivexx... > "%WVX_LOG%"
set N=0
:waitloop
tasklist /FI "PID eq %WVX_PID%" 2>nul | find "%WVX_PID%" >nul || goto copy
set /a N+=1
if %N% GEQ 60 goto copy
ping 127.0.0.1 -n 2 >nul
goto waitloop
:copy
robocopy "%WVX_SRC%" "%WVX_DST%" /E /R:3 /W:2 /XF %WVX_XF% >> "%WVX_LOG%" 2>&1
if %ERRORLEVEL% GEQ 8 echo ROBOCOPY REPORTED ERRORS: exit code %ERRORLEVEL% >> "%WVX_LOG%"
start "" "%WVX_RELAUNCH%"
rmdir /s /q "%WVX_CLEAN%" >nul 2>&1
del "%WVX_ZIP%" >nul 2>&1
del "%~f0" >nul 2>&1
"""
    env = dict(os.environ)
    env.update({
        "WVX_SRC": staged, "WVX_DST": HERE, "WVX_LOG": log, "WVX_ZIP": zip_path,
        "WVX_CLEAN": staged_root, "WVX_RELAUNCH": relaunch_path,
        "WVX_PID": str(os.getpid()), "WVX_XF": xf,
    })
    try:
        with open(bat, "w", encoding="ascii") as f:
            f.write(script)
        # CREATE_NO_WINDOW (hidden console) + new process group: children survive the
        # parent exiting on Windows, and unlike DETACHED_PROCESS the bat gets a real
        # (invisible) console — a console-less cmd HANGS on pipelines like tasklist|find.
        subprocess.Popen(["cmd", "/c", bat], creationflags=0x08000000 | 0x00000200,
                         close_fds=True, env=env)
    except Exception as e:
        return {"ok": False, "error": "couldn't launch updater: %s" % e}
    return {"ok": True}


if __name__ == "__main__":
    print(json.dumps(check(), indent=2))
