"""Windows force-upgrade: download workstatus-desktop NSIS .exe, hand off to windows_exe_upgrade.ps1."""

from __future__ import annotations

import glob
import os
import shutil
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Optional

import requests

from ws_package.log.writer import LogWriter
from ws_package.utils.error_codes import ErrorCode, ExitCode

logger = LogWriter.file(__name__)

TMP_INSTALLER_GLOB = os.path.join(os.environ.get("TEMP", "/tmp"), "ws_force_upgrade_*.exe")
TMP_INSTALLER_PART_GLOB = os.path.join(os.environ.get("TEMP", "/tmp"), "ws_force_upgrade_*.part")
# Stable location outside the install dir so PowerShell can run after the agent exits.
PERSISTENT_UPGRADE_SCRIPT_DIR = os.path.join(
    os.environ.get("TEMP", os.path.expanduser("~")),
    "ws_force_upgrade",
)
UPGRADE_SCRIPT_NAME = "windows_exe_upgrade.ps1"
PE_MAGIC = b"MZ"
NSIS_MARKERS = (b"Nullsoft", b"NullsoftInst")
# Server/CDN may use Workstatus.exe as the filename; validate by NSIS content, not URL name.
NSIS_SCAN_CHUNK = 4 * 1024 * 1024
NSIS_SCAN_MAX_BYTES = 32 * 1024 * 1024
DOWNLOAD_CONNECT_TIMEOUT = 30
DOWNLOAD_READ_TIMEOUT = 120
DOWNLOAD_PROGRESS_LOG_BYTES = 20 * 1024 * 1024
DOWNLOAD_WALL_CLOCK_SECONDS = 20 * 60


def _installer_part_path(exe_path: str) -> str:
    return f"{exe_path}.part"


def _resolve_curl_exe() -> Optional[str]:
    for candidate in (
        shutil.which("curl"),
        os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "curl.exe"),
    ):
        if candidate and os.path.isfile(candidate):
            return candidate
    return None


def _file_contains_nsis_marker(path: str) -> bool:
    """Search the installer for NSIS stub markers (filename may be Workstatus.exe from API)."""
    try:
        file_size = os.path.getsize(path)
        scan_limit = min(file_size, NSIS_SCAN_MAX_BYTES)
        with open(path, "rb") as f:
            offset = 0
            while offset < scan_limit:
                chunk = f.read(min(NSIS_SCAN_CHUNK, scan_limit - offset))
                if not chunk:
                    break
                if any(marker in chunk for marker in NSIS_MARKERS):
                    return True
                offset += len(chunk)
    except OSError as exc:
        logger.error("[win_upgrade] cannot scan %s for NSIS: %s", path, exc)
    return False


def resolve_windows_force_upgrade_url(
    api_link: Optional[str],
    override_link: Optional[str],
) -> Optional[str]:
    """Pick download URL: config override wins, else API link. Warn on Python-agent CDN paths."""
    override = (override_link or "").strip()
    api = (api_link or "").strip()
    chosen = override or api or None
    if not chosen:
        return None
    lower = chosen.lower()
    if "/windows/py/" in lower:
        logger.warning(
            "[win_upgrade] URL is under /windows/py/ (standalone agent). "
            "Force upgrade needs the visible desktop NSIS installer (e.g. .../windows/visible/<ver>/...)."
        )
    if override and api and override != api:
        logger.info("[win_upgrade] using windows_exe_download_url_override (API link ignored): %s", override)
        logger.info("[win_upgrade] API download_link: %s", api)
    elif not override and api:
        logger.info("[win_upgrade] using API download_link: %s", api)
    return chosen


def windows_force_upgrade_fallback_urls(primary_url: str, app_version: Optional[str]) -> list[str]:
    """Alternate CDN paths when primary URL fails NSIS validation (electron-builder artifact names)."""
    fallbacks: list[str] = []
    version = (app_version or "").strip()
    if version and "/visible/" in primary_url.lower():
        base = primary_url.rsplit("/", 1)[0]
        for name in (
            f"Workstatus-{version}-x64.exe",
            f"Workstatus-Desktop-{version}-x64.exe",
            "Workstatus-Desktop.exe",
            "Workstatus-Desktop-Setup.exe",
        ):
            candidate = f"{base}/{name}"
            if candidate != primary_url:
                fallbacks.append(candidate)
    return fallbacks


def is_valid_nsis_installer(path: str, source_url: str = "") -> bool:
    """True if file is an NSIS installer (content check; server filename may be Workstatus.exe)."""
    if source_url:
        logger.info("[win_upgrade] validating installer from url=%s path=%s", source_url, path)
    try:
        with open(path, "rb") as f:
            header = f.read(2)
        if header != PE_MAGIC:
            logger.error(
                "[win_upgrade] invalid PE header %r in %s",
                header,
                path,
            )
            return False
    except OSError as exc:
        logger.error("[win_upgrade] cannot read %s: %s", path, exc)
        return False

    if not _file_contains_nsis_marker(path):
        size = os.path.getsize(path)
        logger.error(
            "[win_upgrade] %s (%s bytes) is not an NSIS installer (no Nullsoft signature). "
            "The API download_link must return the desktop NSIS setup, not the standalone "
            "Python/PyInstaller agent (same ~200MB size but different format).",
            path,
            size,
        )
        return False

    logger.info("[win_upgrade] validated NSIS installer: %s", path)
    return True


def _upgrade_log_path() -> Path:
    return Path(os.path.expanduser("~")) / ".workstatus" / "logs" / "windows_force_upgrade.log"


def resolve_windows_upgrade_script() -> Path:
    """Locate windows_exe_upgrade.ps1 next to frozen binary or under workstatus/."""
    candidates: list[Path] = []
    if getattr(sys, "frozen", False):
        meipass = getattr(sys, "_MEIPASS", None)
        if meipass:
            candidates.append(Path(meipass) / UPGRADE_SCRIPT_NAME)
        candidates.append(Path(sys.executable).resolve().parent / UPGRADE_SCRIPT_NAME)
    here = Path(__file__).resolve()
    candidates.append(here.parents[2] / UPGRADE_SCRIPT_NAME)
    candidates.append(here.parents[3] / "workstatus" / UPGRADE_SCRIPT_NAME)

    for path in candidates:
        if path.is_file():
            return path
    raise FileNotFoundError(
        f"{UPGRADE_SCRIPT_NAME} not found; checked: {[str(p) for p in candidates]}"
    )


def materialize_windows_upgrade_script() -> Path:
    """
    Return a path to windows_exe_upgrade.ps1 that still exists after the agent exits.

    The script lives inside the install directory (_internal/) in onedir builds.
    We copy it to a stable TEMP location so PowerShell always runs the same known
    path regardless of where the agent was installed.
    """
    source = resolve_windows_upgrade_script()
    dest_dir = Path(PERSISTENT_UPGRADE_SCRIPT_DIR)
    dest_dir.mkdir(parents=True, exist_ok=True)
    dest = dest_dir / UPGRADE_SCRIPT_NAME

    meipass = getattr(sys, "_MEIPASS", None)
    in_meipass = bool(
        meipass and str(source.resolve()).lower().startswith(str(Path(meipass).resolve()).lower())
    )
    if getattr(sys, "frozen", False) and in_meipass:
        shutil.copy2(source, dest)
        logger.info(
            "[win_upgrade] materialized upgrade script to persistent location: %s",
            dest,
        )
        return dest

    if not dest.is_file() or source.resolve() != dest.resolve():
        try:
            if not dest.is_file() or os.path.getmtime(source) > os.path.getmtime(dest):
                shutil.copy2(source, dest)
                logger.info("[win_upgrade] copied upgrade script to: %s", dest)
                return dest
        except OSError as exc:
            logger.warning("[win_upgrade] could not copy script to %s: %s", dest, exc)

    return source


class update_win_build:
    """Download workstatus-desktop installer and spawn windows_exe_upgrade.ps1."""

    def __init__(self, version: str) -> None:
        self.updated_version = version
        self.installer_path: Optional[str] = None
        self._upgrade_id = uuid.uuid4().hex[:12]
        logger.info(
            "[win_upgrade] init target_version=%s upgrade_id=%s",
            version,
            self._upgrade_id,
        )

    def _cleanup_old_temp_installers(self, keep_path: Optional[str] = None) -> None:
        keep_resolved = None
        keep_part_resolved = None
        if keep_path:
            try:
                keep_resolved = os.path.realpath(keep_path)
                keep_part_resolved = os.path.realpath(_installer_part_path(keep_path))
            except OSError:
                keep_resolved = keep_path
                keep_part_resolved = _installer_part_path(keep_path)
        for pattern in (TMP_INSTALLER_GLOB, TMP_INSTALLER_PART_GLOB):
            for path in glob.glob(pattern):
                if keep_resolved or keep_part_resolved:
                    try:
                        resolved = os.path.realpath(path)
                        if resolved in {keep_resolved, keep_part_resolved}:
                            continue
                    except OSError:
                        pass
                try:
                    os.remove(path)
                    logger.info("[win_upgrade] removed previous temp installer: %s", path)
                except OSError as exc:
                    logger.warning("[win_upgrade] could not remove %s: %s", path, exc)

    def _remove_download_artifacts(self, exe_path: str) -> None:
        for path in (exe_path, _installer_part_path(exe_path)):
            try:
                if os.path.isfile(path):
                    os.remove(path)
            except OSError:
                pass

    def _finalize_download(
        self,
        part_path: str,
        exe_path: str,
        log_file: Path,
        download_url: str,
        expected_size: int,
    ) -> bool:
        try:
            actual_size = os.path.getsize(part_path)
        except OSError as exc:
            self._append_upgrade_log(
                log_file,
                f"download_failed reason=missing_part path={part_path} error={exc}",
            )
            return False

        if expected_size > 0 and actual_size != expected_size:
            self._append_upgrade_log(
                log_file,
                f"download_size_mismatch actual={actual_size} expected={expected_size} url={download_url}",
            )
            return False

        if not is_valid_nsis_installer(part_path, source_url=download_url or ""):
            self._append_upgrade_log(
                log_file,
                f"download_validation_failed url={download_url} bytes={actual_size}",
            )
            return False

        try:
            if os.path.isfile(exe_path):
                os.remove(exe_path)
            os.replace(part_path, exe_path)
        except OSError as exc:
            logger.error("[win_upgrade] could not finalize installer %s: %s", exe_path, exc)
            self._append_upgrade_log(
                log_file,
                f"download_failed reason=rename_error path={exe_path} error={exc}",
            )
            return False

        self._append_upgrade_log(
            log_file,
            f"download_ok bytes={actual_size} url={download_url} handing_off script={UPGRADE_SCRIPT_NAME}",
        )
        logger.info("[win_upgrade] download ok; handing off to %s", UPGRADE_SCRIPT_NAME)
        return True

    def _download_with_curl(
        self,
        download_url: str,
        part_path: str,
        log_file: Path,
        expected_size: int,
    ) -> bool:
        curl_exe = _resolve_curl_exe()
        if not curl_exe:
            logger.info("[win_upgrade] curl.exe not found; using requests fallback")
            return False

        self._append_upgrade_log(
            log_file,
            f"download_curl_start url={download_url} path={part_path} expected={expected_size}",
        )
        cmd = [
            curl_exe,
            "-fLsS",
            "--connect-timeout",
            str(DOWNLOAD_CONNECT_TIMEOUT),
            "--max-time",
            str(DOWNLOAD_WALL_CLOCK_SECONDS),
            "--retry",
            "2",
            "--retry-delay",
            "3",
            "-o",
            part_path,
            download_url,
        ]
        creationflags = (
            getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
            | getattr(subprocess, "CREATE_NO_WINDOW", 0)
        )
        try:
            proc = subprocess.Popen(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                stdin=subprocess.DEVNULL,
                creationflags=creationflags,
            )
        except OSError as exc:
            logger.warning("[win_upgrade] curl spawn failed: %s", exc)
            self._append_upgrade_log(
                log_file,
                f"download_curl_spawn_failed error={type(exc).__name__}: {exc}",
            )
            return False

        last_progress_logged = 0
        deadline = time.monotonic() + DOWNLOAD_WALL_CLOCK_SECONDS + 60
        while proc.poll() is None:
            if time.monotonic() >= deadline:
                proc.kill()
                self._append_upgrade_log(
                    log_file,
                    f"download_curl_timeout url={download_url} path={part_path}",
                )
                return False
            try:
                if os.path.isfile(part_path):
                    written = os.path.getsize(part_path)
                    if written - last_progress_logged >= DOWNLOAD_PROGRESS_LOG_BYTES:
                        self._append_upgrade_log(
                            log_file,
                            f"download_progress bytes={written} expected={expected_size} url={download_url}",
                        )
                        last_progress_logged = written
            except OSError:
                pass
            time.sleep(2)

        if proc.returncode != 0:
            partial = 0
            try:
                if os.path.isfile(part_path):
                    partial = os.path.getsize(part_path)
            except OSError:
                pass
            logger.error(
                "[win_upgrade] curl download failed rc=%s partial_bytes=%s",
                proc.returncode,
                partial,
            )
            self._append_upgrade_log(
                log_file,
                f"download_curl_failed rc={proc.returncode} partial_bytes={partial} "
                f"url={download_url}",
            )
            return False

        logger.info("[win_upgrade] curl download finished path=%s", part_path)
        return True

    def _download_with_requests(
        self,
        download_url: str,
        part_path: str,
        log_file: Path,
        expected_size: int,
        attempt: int,
        max_retries: int,
    ) -> bool:
        self._append_upgrade_log(
            log_file,
            f"download_requests_start url={download_url} path={part_path} "
            f"attempt={attempt + 1}/{max_retries} expected={expected_size}",
        )
        response = requests.get(
            download_url,
            stream=True,
            timeout=(DOWNLOAD_CONNECT_TIMEOUT, DOWNLOAD_READ_TIMEOUT),
        )
        if response.status_code != 200:
            logger.error("[win_upgrade] HTTP %s for %s", response.status_code, download_url)
            self._append_upgrade_log(
                log_file,
                f"download_http_error status={response.status_code} url={download_url} "
                f"attempt={attempt + 1}/{max_retries}",
            )
            return False

        self._append_upgrade_log(
            log_file,
            f"download_connected status=200 url={download_url} expected={expected_size}",
        )

        written = 0
        last_progress_logged = 0
        with open(part_path, "wb") as out:
            for chunk in response.iter_content(chunk_size=65536):
                if chunk:
                    out.write(chunk)
                    written += len(chunk)
                    if written - last_progress_logged >= DOWNLOAD_PROGRESS_LOG_BYTES:
                        self._append_upgrade_log(
                            log_file,
                            f"download_progress bytes={written} expected={expected_size} url={download_url}",
                        )
                        last_progress_logged = written

        logger.info(
            "[win_upgrade] wrote bytes=%s (declared content-length=%s)",
            written,
            expected_size,
        )
        return True

    def _ensure_upgrade_log_dir(self, log_file: Path) -> None:
        try:
            log_file.parent.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            logger.warning("[win_upgrade] could not create log dir %s: %s", log_file.parent, exc)

    def _append_upgrade_log(self, log_file: Path, message: str) -> None:
        from datetime import datetime, timezone

        ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
        line = f"[python] {ts} {message}\n"
        try:
            with open(log_file, "a", encoding="utf-8") as f:
                f.write(line)
        except OSError as exc:
            logger.warning("[win_upgrade] could not append upgrade log: %s", exc)

    def _log_python_handoff(self, log_file: Path, script_path: Path, installer_path: str) -> None:
        from datetime import datetime, timezone

        ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
        line = (
            f"[python] {ts} upgrade_id={self._upgrade_id} "
            f"target_version={self.updated_version!r} installer={installer_path} "
            f"script={script_path}\n"
        )
        try:
            with open(log_file, "a", encoding="utf-8") as f:
                f.write(line)
        except OSError as exc:
            logger.warning("[win_upgrade] could not append handoff log: %s", exc)

    def _spawn_upgrade_script(
        self,
        log_file: Path,
        script_path: Path,
        *,
        dry_run: bool = False,
    ) -> Optional[int]:
        """
        Start windows_exe_upgrade.ps1 in a process that survives parent exit.

        Must run BEFORE Electron handoff: stopStealth(true) kills this Python process.
        """
        installer_path = self.installer_path
        if not installer_path:
            return None

        ps_args = [
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            str(script_path),
            "-InstallerPath",
            installer_path,
            "-UpgradeId",
            self._upgrade_id,
            "-TargetVersion",
            str(self.updated_version or ""),
        ]
        if dry_run:
            ps_args.append("-DryRun")

        # Prefer Start-Process so the upgrade survives sys.exit() in the parent agent.
        start_cmd = (
            f"$p = Start-Process -FilePath 'powershell.exe' "
            f"-ArgumentList @({','.join(repr(a) for a in ps_args)}) "
            f"-WindowStyle Hidden -PassThru; Write-Output $p.Id"
        )

        creationflags = (
            getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
            | getattr(subprocess, "CREATE_NO_WINDOW", 0)
            | getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0)
        )

        try:
            proc = subprocess.Popen(
                [
                    "powershell.exe",
                    "-NoProfile",
                    "-ExecutionPolicy",
                    "Bypass",
                    "-Command",
                    start_cmd,
                ],
                env=os.environ.copy(),
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                stdin=subprocess.DEVNULL,
                creationflags=creationflags,
                close_fds=True,
            )
            stdout, stderr = proc.communicate(timeout=30)
            child_pid = None
            if stdout:
                text = stdout.decode("utf-8", errors="replace").strip()
                for line in reversed(text.splitlines()):
                    line = line.strip()
                    if line.isdigit():
                        child_pid = int(line)
                        break
            if proc.returncode != 0:
                err = stderr.decode("utf-8", errors="replace").strip() if stderr else ""
                logger.error(
                    "[win_upgrade] Start-Process launcher failed rc=%s stderr=%s",
                    proc.returncode,
                    err,
                )
                return None
            if child_pid:
                self._append_upgrade_log(
                    log_file,
                    f"spawned powershell pid={child_pid} upgrade_id={self._upgrade_id}",
                )
                logger.info("[win_upgrade] detached upgrade process pid=%s", child_pid)
                return child_pid
            logger.error("[win_upgrade] Start-Process did not return child PID: %r", stdout)
            return None
        except Exception as exc:
            logger.error("[win_upgrade] failed to spawn upgrade script: %s", exc, exc_info=True)
            return None

    def _notify_electron_handoff(self) -> None:
        """Ask Electron to quit UI only; upgrade script stops/kills processes."""
        try:
            from ws_package.utils.electron_comm import get_electron_comm

            ec = get_electron_comm()
            sent = ec.send_to_electron(
                "force_upgrade_handoff",
                {
                    "upgrade_id": self._upgrade_id,
                    "target_version": self.updated_version,
                },
            )
            if sent:
                logger.info("[win_upgrade] sent force_upgrade_handoff to Electron (quit UI)")
            else:
                logger.warning(
                    "[win_upgrade] Electron not connected; upgrade script will stop processes"
                )
        except Exception as exc:
            logger.warning("[win_upgrade] could not notify Electron for handoff: %s", exc)

    async def swap_build(self) -> None:
        if not self.installer_path or not os.path.isfile(self.installer_path):
            logger.error("[win_upgrade] swap_build: missing installer at %s", self.installer_path)
            return

        log_file = _upgrade_log_path()
        self._ensure_upgrade_log_dir(log_file)

        try:
            script_path = materialize_windows_upgrade_script()
        except FileNotFoundError as exc:
            logger.error("[win_upgrade] %s", exc)
            return

        self._log_python_handoff(log_file, script_path, self.installer_path)

        child_pid = self._spawn_upgrade_script(log_file, script_path)
        if not child_pid:
            logger.error("[win_upgrade] aborting: upgrade script did not start")
            self._append_upgrade_log(
                log_file,
                f"spawn_failed upgrade_id={self._upgrade_id}",
            )
            return

        time.sleep(1.0)

        self._notify_electron_handoff()

        try:
            from ws_package.utils.electron_comm import get_electron_comm

            get_electron_comm().send_to_electron(
                "version_check_result",
                {
                    "force_upgrade": True,
                    "updated": True,
                    "handoff": True,
                    "source": "force_upgrade_update_app",
                    "upgrade_id": self._upgrade_id,
                    "upgrade_log": str(log_file),
                    "upgrade_script_pid": child_pid,
                },
            )
        except Exception as exc:
            logger.warning("[win_upgrade] could not notify Electron before handoff: %s", exc)

        if os.environ.get("WORKSTATUS_FORCE_UPGRADE_NO_EXIT", "").strip().lower() in (
            "1",
            "true",
            "yes",
        ):
            logger.warning(
                "[win_upgrade] WORKSTATUS_FORCE_UPGRADE_NO_EXIT set — not calling sys.exit (dev only)"
            )
            return

        logger.critical("%s", ErrorCode.UPGRADE_WINDOWS)
        sys.exit(ExitCode.OK)

    async def download_file(self, url, max_retries=3, fallback_urls: Optional[list] = None):
        urls_to_try: list[str] = []
        for candidate in [url] + list(fallback_urls or []):
            c = (candidate or "").strip()
            if c and c not in urls_to_try:
                urls_to_try.append(c)

        if not urls_to_try:
            logger.error("[win_upgrade] no download URL configured")
            return False

        installer_name = f"ws_force_upgrade_{self._upgrade_id}.exe"
        temp_dir = os.environ.get("TEMP") or os.path.expanduser("~")
        new_build = os.path.join(temp_dir, installer_name)
        self.installer_path = new_build
        self._cleanup_old_temp_installers(keep_path=new_build)

        log_file = _upgrade_log_path()
        self._ensure_upgrade_log_dir(log_file)

        logger.info("[win_upgrade] download candidates=%s path=%s", urls_to_try, new_build)
        logger.info("[win_upgrade] detailed log: %s", log_file)
        logger.info(
            "[win_upgrade] expect NSIS desktop installer (.exe; server filename may be Workstatus.exe)"
        )

        for url_index, download_url in enumerate(urls_to_try):
            self._append_upgrade_log(
                log_file,
                f"download_start upgrade_id={self._upgrade_id} url={download_url} path={new_build}",
            )
            logger.info(
                "[win_upgrade] download url=%s (%s/%s) path=%s",
                download_url,
                url_index + 1,
                len(urls_to_try),
                new_build,
            )

            part_path = _installer_part_path(new_build)
            expected_size = 0
            try:
                head = requests.head(
                    download_url,
                    timeout=(DOWNLOAD_CONNECT_TIMEOUT, DOWNLOAD_READ_TIMEOUT),
                    allow_redirects=True,
                )
                content_length = head.headers.get("content-length")
                if content_length is not None:
                    expected_size = int(content_length)
            except Exception as exc:
                logger.warning("[win_upgrade] HEAD failed for %s: %s", download_url, exc)

            logger.info(
                "[win_upgrade] url=%s expect_bytes=%s part_path=%s",
                download_url,
                expected_size,
                part_path,
            )

            for attempt in range(max_retries):
                self._remove_download_artifacts(new_build)
                try:
                    ok = False
                    if attempt == 0:
                        ok = self._download_with_curl(
                            download_url,
                            part_path,
                            log_file,
                            expected_size,
                        )
                    if not ok:
                        ok = self._download_with_requests(
                            download_url,
                            part_path,
                            log_file,
                            expected_size,
                            attempt,
                            max_retries,
                        )

                    if not ok:
                        if attempt < max_retries - 1:
                            continue
                        break

                    if self._finalize_download(
                        part_path,
                        new_build,
                        log_file,
                        download_url,
                        expected_size,
                    ):
                        await self.swap_build()
                        return True

                    if attempt < max_retries - 1:
                        continue
                    break

                except Exception as e:
                    partial = 0
                    try:
                        if os.path.isfile(part_path):
                            partial = os.path.getsize(part_path)
                        elif os.path.isfile(new_build):
                            partial = os.path.getsize(new_build)
                    except OSError:
                        pass
                    logger.error(
                        "[win_upgrade] download failed url=%s attempt %s: %s",
                        download_url,
                        attempt + 1,
                        e,
                        exc_info=True,
                    )
                    self._append_upgrade_log(
                        log_file,
                        f"download_error url={download_url} attempt={attempt + 1}/{max_retries} "
                        f"partial_bytes={partial} error={type(e).__name__}: {e}",
                    )
                    if attempt < max_retries - 1:
                        self._remove_download_artifacts(new_build)
                        continue
                    self._append_upgrade_log(
                        log_file,
                        f"download_failed reason=exception url={download_url} error={type(e).__name__}: {e}",
                    )
                    self._remove_download_artifacts(new_build)
                    break

            if url_index + 1 < len(urls_to_try):
                logger.info("[win_upgrade] trying next fallback URL")
                self._append_upgrade_log(
                    log_file,
                    f"download_trying_fallback next_url={urls_to_try[url_index + 1]}",
                )

        self._append_upgrade_log(
            log_file,
            f"download_failed reason=all_urls_exhausted upgrade_id={self._upgrade_id}",
        )
        return False
