"""Updates: check, stage, apply on restart.

Deliberately NOT a silent background installer. Files are downloaded and
unpacked while the app runs, but nothing is replaced until the next start,
before a single module is imported. That gives three properties worth more
than instant updates:

  * nothing is swapped underneath a running recording or render;
  * Windows can't fail on locked files, because nothing is in use yet;
  * a bad update is one rollback folder away from being undone.

Your data is never touched: config.json, data/, plugins/, .venv and
node_modules are preserved across an update by name.

The update source is a URL, or a path to a manifest on this machine or a
network share — pointing it at a folder is how you test the whole path
without publishing anything, and how a small team can update from a shared
drive. Relative `url` values in the manifest resolve against the manifest's
own location, so a release folder can be moved without editing it.

A release is described by a manifest (config "update_url"):

    {"version": "0.2.0",
     "url": "https://.../Clipshelf-0.2.0.zip",
     "sha256": "…",
     "notes": "what changed",
     "min_python": "3.11"}
"""
import hashlib
import json
import os
import platform
import re
import shutil
import sys
import threading
import time
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request

from clipshelf import net, __version__, config
from clipshelf.config import DATA, ROOT

router = APIRouter(tags=["update"])

# TODO(packaging): this applies updates by copying loose files over the
# install, which is right for the zip distribution. When the single-file /
# installer build lands (embedded Python + Inno Setup — see the plan's 8.2),
# a release is an installer, not a tree: `apply_pending` should then hand the
# downloaded installer to the OS and exit, rather than swapping files. Keep
# the manifest shape the same either way; add "installer": true to switch.
#
# A fresh install self-updates out of the box. Still overridable in
# settings, and a local folder is a valid source too (scripts/release.sh) —
# that is how the whole path gets tested without publishing anything.
DEFAULT_UPDATE_URL = "https://clipshelf.gg/releases/latest.json"

UPDATES = DATA / "updates"
PENDING = UPDATES / "pending.json"
CHECK_EVERY_S = 24 * 3600

# never overwritten by an update — this is the user's install, not ours
# Never overwritten by an update. `python` is the interpreter an installed
# copy runs on and `installed` is what tells it where its library lives —
# a release archive contains neither, but naming them is cheaper than
# relying on that staying true.
PRESERVE = {"config.json", "data", "plugins", ".venv", "node_modules",
            "app.log", ".running", "dist", ".git", "python", "installed",
            "deps-stale", "first_run.log"}

state = {"checking": False, "downloading": False, "progress": 0.0,
         "latest": None, "error": "", "last_check": 0}


def as_url(src: str) -> str:
    """A source the URL opener understands. Anything that is not http(s) is
    treated as a path on this machine (or a share) and turned into file://,
    so "~/product/dist/releases/latest.json" just works."""
    src = str(src or "").strip()
    if not src or "://" in src:
        return src
    return Path(src).expanduser().resolve().as_uri()


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


def is_newer(candidate: str, current: str = __version__) -> bool:
    return _ver_tuple(candidate) > _ver_tuple(current)


def _explain(e: Exception) -> str:
    """Turn a network failure into something the reader can act on.

    "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate
    has expired" reads as "your server's certificate expired", and the one
    thing it never means is that. It is the machine's own root store that
    went stale, and no amount of looking at the update settings will show
    that — so say it here, where the error is displayed."""
    msg = f"{type(e).__name__}: {e}"
    if "CERTIFICATE_VERIFY_FAILED" in msg:
        return ("secure connection refused — this machine's root certificates "
                "look out of date. Windows Update refreshes them; until then "
                "downloads from clipshelf.gg will fail too.")
    if isinstance(e, (TimeoutError,)) or "timed out" in msg:
        return "the update server did not answer in time — try again shortly"
    return msg[:200]


# ---------------------------------------------------------------- check
def check(force: bool = False) -> dict:
    url = str(config.CFG.get("update_url") or DEFAULT_UPDATE_URL).strip()
    if not url:
        return {"enabled": False, "reason": "no update source configured"}
    if not force and time.time() - state["last_check"] < 600 and state["latest"]:
        return {"enabled": True, **_summary()}
    state.update(checking=True, error="")
    try:
        url = as_url(url)
        req = urllib.request.Request(url, headers={"User-Agent": f"Clipshelf/{__version__}"})
        with urllib.request.urlopen(req, timeout=30, context=net.context()) as r:
            manifest = json.loads(r.read())
        if not manifest.get("version") or not manifest.get("url"):
            raise ValueError("manifest is missing version/url")
        # a relative url means "next to the manifest", which is what makes a
        # release folder movable
        raw = str(manifest["url"])
        manifest["url"] = raw if "://" in raw else urllib.parse.urljoin(url, raw)
        state["latest"] = manifest
        state["last_check"] = time.time()
    except Exception as e:
        state["error"] = _explain(e)
        # Only complain when someone asked. A background check that cannot
        # reach the internet is a network condition, not a fault in this
        # app, and logging it daily would fill the problems panel with
        # something the user can neither fix nor act on.
        if force:
            from clipshelf import problems
            problems.record("updates", e, "could not read the update manifest")
    finally:
        state["checking"] = False
    return {"enabled": True, **_summary()}


def can_write_install() -> bool:
    """Can we actually replace our own files?

    The installer offers a per-machine option, which puts the app in
    Program Files. An update there fails on permissions halfway through and
    rolls back — correct, but a user watching it deserves to be told before
    downloading 4 MB, not after."""
    try:
        probe = Path(ROOT) / ".write-probe"
        probe.write_text("", encoding="utf-8")
        probe.unlink()
        return True
    except Exception:
        return False


def _summary() -> dict:
    m = state["latest"] or {}
    pend = pending()
    return {
        "current": __version__,
        "latest": m.get("version"),
        "notes": m.get("notes", ""),
        "update_available": bool(m.get("version") and is_newer(m["version"])),
        "downloading": state["downloading"],
        "progress": round(state["progress"], 3),
        "pending": pend,
        "error": state["error"],
        "checked": int(state["last_check"]),
        "writable": can_write_install(),
    }


# ---------------------------------------------------------------- download
def _download(manifest: dict):
    state.update(downloading=True, progress=0.0, error="")
    ver = str(manifest["version"])
    UPDATES.mkdir(parents=True, exist_ok=True)
    zpath = UPDATES / f"{ver}.zip"
    try:
        req = urllib.request.Request(manifest["url"],
                                     headers={"User-Agent": f"Clipshelf/{__version__}"})
        with urllib.request.urlopen(req, timeout=120, context=net.context()) as r:
            total = int(r.headers.get("Content-Length") or 0)
            got = 0
            h = hashlib.sha256()
            with open(zpath, "wb") as fh:
                while True:
                    chunk = r.read(256 * 1024)
                    if not chunk:
                        break
                    fh.write(chunk)
                    h.update(chunk)
                    got += len(chunk)
                    if total:
                        state["progress"] = got / total
        want = str(manifest.get("sha256") or "").lower()
        if want and h.hexdigest() != want:
            zpath.unlink(missing_ok=True)
            raise ValueError("downloaded file does not match the published checksum")

        staged = UPDATES / f"staged-{ver}"
        shutil.rmtree(staged, ignore_errors=True)
        with zipfile.ZipFile(zpath) as z:
            names = z.namelist()
            tops = {n.split("/", 1)[0] for n in names if "/" in n}
            prefix = (tops.pop() + "/") if len(tops) == 1 and "run.py" not in names else ""
            for n in names:
                if n.endswith("/") or not n.startswith(prefix):
                    continue
                rel = n[len(prefix):]
                out = (staged / rel).resolve()
                if not str(out).startswith(str(staged.resolve())):
                    raise ValueError(f"update archive escapes its folder: {rel}")
                out.parent.mkdir(parents=True, exist_ok=True)
                out.write_bytes(z.read(n))
        zpath.unlink(missing_ok=True)
        if not (staged / "src" / "clipshelf" / "__init__.py").exists():
            shutil.rmtree(staged, ignore_errors=True)
            raise ValueError("that archive does not look like a Clipshelf release")
        PENDING.write_text(json.dumps({"version": ver, "dir": str(staged),
                                       "staged_at": int(time.time())}), encoding="utf-8")
        state["progress"] = 1.0
    except Exception as e:
        state["error"] = _explain(e)
        from clipshelf import problems
        problems.record("updates", e, "downloading the update failed")
    finally:
        state["downloading"] = False


def pending() -> dict | None:
    try:
        p = json.loads(PENDING.read_text(encoding="utf-8"))
    except Exception:
        return None
    if not Path(p.get("dir", "")).is_dir():
        return None
    return {"version": p["version"], "staged_at": p.get("staged_at", 0)}


def cancel_pending():
    try:
        p = json.loads(PENDING.read_text(encoding="utf-8"))
        shutil.rmtree(p.get("dir", ""), ignore_errors=True)
    except Exception:
        pass
    PENDING.unlink(missing_ok=True)


# ---------------------------------------------------------------- apply
def apply_pending(root: Path = None, log=print) -> dict:
    """Swap a staged update into the install. Called by run.py at startup,
    BEFORE the app is imported, so nothing is loaded or locked."""
    root = Path(root or ROOT)
    try:
        p = json.loads(PENDING.read_text(encoding="utf-8"))
    except Exception:
        return {"applied": False}
    staged = Path(p.get("dir", ""))
    ver = p.get("version", "?")
    if not (staged / "src" / "clipshelf" / "__init__.py").exists():
        PENDING.unlink(missing_ok=True)
        return {"applied": False, "error": "staged update is incomplete"}

    # "the update ate my library" is the one failure that ends trust
    # permanently, and the copy costs a second
    try:
        sys.path.insert(0, str(root / "src"))
        from clipshelf.backup import snapshot
        log(f"[update] library snapshot: {snapshot('preupdate').name}")
    except Exception as e:
        log(f"[update] could not snapshot the library ({e}) — continuing")

    rollback = UPDATES / f"rollback-{__version__}"
    shutil.rmtree(rollback, ignore_errors=True)
    rollback.mkdir(parents=True, exist_ok=True)
    replaced, attempted = [], []
    try:
        for item in staged.iterdir():
            if item.name in PRESERVE:
                continue
            attempted.append(item.name)
            dest = root / item.name
            if dest.exists():                       # keep the old copy first
                target = rollback / item.name
                if dest.is_dir():
                    shutil.copytree(dest, target, dirs_exist_ok=True)
                    shutil.rmtree(dest)
                else:
                    shutil.copy2(dest, target)
                    dest.unlink()
                # recorded BEFORE the new copy: from here until that copy
                # finishes there is nothing valid at `dest`, and a crash in
                # between is exactly what `replaced` exists to undo
                replaced.append(item.name)
            if item.is_dir():
                shutil.copytree(item, dest)
            else:
                shutil.copy2(item, dest)
        # a changed dependency list means the venv needs another pass
        def _reqs(path: Path):
            """Compare CONTENT, not bytes. A release archive carries LF and
            a checkout on Windows may have CRLF, so a byte comparison calls
            every update a dependency change — and then reinstalls the whole
            tree on every single update, forever."""
            try:
                return [ln.strip() for ln in
                        path.read_text(encoding="utf-8", errors="replace").splitlines()
                        if ln.strip() and not ln.strip().startswith("#")]
            except Exception:
                return None

        old_req = (rollback / "requirements.txt")
        new_req = (root / "requirements.txt")
        if old_req.exists() and new_req.exists() and \
                _reqs(old_req) != _reqs(new_req):
            # Two layouts to satisfy. The zip runs from a .venv that run.bat
            # refreshes when this flag is gone. An INSTALLED copy has no
            # .venv at all — its packages live beside the bundled
            # interpreter — so it needs a marker run.py can act on, or a
            # release that adds a dependency would leave it importing a
            # module that was never installed.
            (root / ".venv" / "deps-ok").unlink(missing_ok=True)
            (root / "deps-stale").write_text(
                "requirements.txt changed in an update; run.py installs them "
                "on the next start and removes this file.\n", encoding="utf-8")
            log("[update] dependencies changed — they will be installed on this start")
        shutil.rmtree(staged, ignore_errors=True)
        PENDING.unlink(missing_ok=True)
        log(f"[update] updated to {ver} (previous version kept in {rollback})")
        return {"applied": True, "version": ver, "replaced": attempted,
                "rollback": str(rollback)}
    except Exception as e:
        log(f"[update] FAILED ({e}) — rolling back")
        for name in attempted:                       # put the old files back
            src = rollback / name
            dest = root / name
            if name not in replaced:
                # new in this release: the install never had it, so undoing
                # means taking it away again
                if dest.is_dir():
                    shutil.rmtree(dest, ignore_errors=True)
                elif dest.exists():
                    dest.unlink()
                continue
            if dest.is_dir():
                shutil.rmtree(dest, ignore_errors=True)
            elif dest.exists():
                dest.unlink()
            if src.is_dir():
                shutil.copytree(src, dest)
            else:
                shutil.copy2(src, dest)
        PENDING.unlink(missing_ok=True)
        return {"applied": False, "error": str(e)}


def _auto_loop():
    time.sleep(300)
    while True:
        try:
            if config.CFG.get("update_url") and config.CFG.get("update_auto", True):
                res = check(force=True)
                m = state["latest"] or {}
                if res.get("update_available") and not pending() \
                        and config.CFG.get("update_auto_download", False):
                    _download(m)
        except Exception:
            pass
        time.sleep(CHECK_EVERY_S)


def start():
    threading.Thread(target=_auto_loop, daemon=True).start()


# ---------------------------------------------------------------- routes
@router.get("/api/update")
def update_state():
    url = str(config.CFG.get("update_url") or DEFAULT_UPDATE_URL).strip()
    return {"enabled": bool(url), "url": url,
            "auto_check": bool(config.CFG.get("update_auto", True)),
            "auto_download": bool(config.CFG.get("update_auto_download", False)),
            **_summary()}


@router.post("/api/update/check")
async def update_check():
    import asyncio
    return await asyncio.to_thread(check, True)


@router.post("/api/update/download")
async def update_download():
    m = state["latest"]
    if not m:
        check(force=True)
        m = state["latest"]
    if not m:
        raise HTTPException(400, state["error"] or "no update information")
    if not is_newer(m["version"]):
        raise HTTPException(400, f"{__version__} is already the latest version")
    if state["downloading"]:
        return {"status": "already downloading"}
    threading.Thread(target=_download, args=(m,), daemon=True).start()
    return {"status": "started", "version": m["version"]}


@router.delete("/api/update/pending")
def update_cancel():
    cancel_pending()
    return {"status": "success"}


@router.post("/api/update/settings")
async def update_settings(request: Request):
    body = await request.json()
    changes = {}
    if "url" in body:
        changes["update_url"] = str(body["url"]).strip()[:300]
    if "auto_check" in body:
        changes["update_auto"] = bool(body["auto_check"])
    if "auto_download" in body:
        changes["update_auto_download"] = bool(body["auto_download"])
    config.apply(changes)
    return {"status": "success", **update_state()}


@router.post("/api/update/restart")
def update_restart():
    """Restart the app so a staged update is applied. Refused while work is
    in flight — an update is never worth eating a render."""
    from clipshelf.routers import editor_render
    if editor_render.job_q.qsize() or editor_render._running.get("jid"):
        raise HTTPException(409, "a render is in progress — try again when it finishes")
    from clipshelf import desktop
    threading.Thread(target=desktop.relaunch, daemon=True).start()
    return {"status": "restarting"}
