#!/usr/bin/env python3
"""EliteCabal launcher — update and start the game.

There is deliberately no configuration in this launcher. It ships inside the
game folder, so it already knows where the game is: its own directory. The
patch server is baked in at build time. A player should be able to double-click
it and press Play, and never be asked a question they have no way to answer.

It keeps itself up to date as well, so a change to the launcher does not mean
telling everyone to download a new one by hand.

Runs on Linux (launching the client through Wine) and on Windows (launching it
directly) from the same source.

    python3 launcher/cabal_launcher.py
"""
from __future__ import annotations

import json
import os
import pathlib
import platform
import shutil
import socket
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request

from PySide6.QtCore import Qt, QThread, QTimer, Signal, QSize
from PySide6.QtCore import QUrl
from PySide6.QtGui import (QColor, QCursor, QDesktopServices, QLinearGradient,
                           QPainter, QPixmap)
from PySide6.QtWidgets import (
    QApplication, QFrame, QHBoxLayout, QLabel, QMainWindow, QProgressBar,
    QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from manifest import diff, sha256  # noqa: E402

APP_NAME = "EliteCabal"
TAGLINE = "EPISODE 8  ·  THE REVOLUTION OF ACTION"

# Baked in at build time. Everything the launcher needs to know about the
# outside world is these three lines, and none of them are a player's problem.
PATCH_BASE = os.environ.get("CABAL_PATCH_BASE", "https://patch.elitecabal.xyz/patch")
MANIFEST_URL = f"{PATCH_BASE}/manifest.json"
SERVER_HOST, SERVER_PORT = os.environ.get("CABAL_SERVER", "play.elitecabal.xyz:38101").split(":")

CLIENT_EXE = "cabalmain.exe"
# Records what was last verified, so a normal launch does not re-hash the whole
# install. See quick_check().
STATE_NAME = ".launcher-state.json"
LAUNCH_ARG = "husky"

# Version of THIS launcher. The manifest advertises the current one; if they
# differ the launcher replaces itself before touching the game.
LAUNCHER_VERSION = 4

BG        = "#0d0f12"
BG_PANEL  = "#14171c"
BG_INPUT  = "#0a0c0f"
BORDER    = "#242a33"
TEXT      = "#c9d1d9"
TEXT_DIM  = "#6b7480"
ACCENT    = "#4fc3f7"
ACCENT_HI = "#81d4fa"
OK        = "#66bb6a"
DANGER    = "#ef5350"

STYLE = f"""
QWidget {{ background: {BG}; color: {TEXT};
           font-family: "Segoe UI","Noto Sans",sans-serif; font-size: 13px; }}
QLabel {{ background: transparent; }}
#Panel {{ background: {BG_PANEL}; border: 1px solid {BORDER}; border-radius: 8px; }}
#Title {{ font-size: 30px; font-weight: 600; letter-spacing: 7px; color: #eaf6ff; }}
#Subtitle {{ color: #7fb8d4; letter-spacing: 3px; font-size: 10px; }}
#Status {{ color: {TEXT_DIM}; font-size: 12px; }}
#Detail {{ color: #4a525c; font-size: 11px;
           font-family: "JetBrains Mono","DejaVu Sans Mono",monospace; }}
#SectionLabel {{ color: {TEXT_DIM}; font-size: 10px; letter-spacing: 2px; }}
#NewsTitle {{ color: {ACCENT_HI}; font-size: 13px; font-weight: 600; }}
#NewsDate {{ color: #4a525c; font-size: 10px; }}
#NewsBody {{ color: {TEXT_DIM}; font-size: 12px; }}
#Dot {{ font-size: 15px; }}
#Link {{ color: {TEXT_DIM}; font-size: 12px; }}
#Link:hover {{ color: {ACCENT_HI}; }}
#NewsTitleLink {{ color: {ACCENT_HI}; font-size: 13px; font-weight: 600; }}
#NewsTitleLink:hover {{ color: #b3e5fc; }}
#Sep {{ background: {BORDER}; max-height: 1px; min-height: 1px; }}
QScrollArea, QScrollArea > QWidget > QWidget {{ background: transparent; border: none; }}
QScrollBar:vertical {{ background: transparent; width: 6px; margin: 0; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QPushButton {{ background: {BG_PANEL}; border: 1px solid {BORDER}; border-radius: 5px;
               padding: 8px 16px; color: {TEXT_DIM}; }}
QPushButton:hover {{ border: 1px solid {ACCENT}; color: {ACCENT_HI}; }}
QPushButton:disabled {{ color: #3a4049; border-color: #1b1f26; }}
#Play {{ background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #1a6f9e, stop:1 #12506f);
         border: 1px solid {ACCENT}; border-radius: 6px; color: #eaf6ff;
         font-size: 18px; font-weight: 600; letter-spacing: 4px; padding: 15px 0; }}
#Play:hover {{ background: qlineargradient(x1:0,y1:0,x2:0,y2:1, stop:0 #2288bd, stop:1 #17658a); }}
#Play:disabled {{ background: #171b21; border-color: {BORDER}; color: #454c56; }}
QProgressBar {{ background: {BG_INPUT}; border: 1px solid {BORDER}; border-radius: 4px;
                max-height: 6px; text-align: center; color: transparent; }}
QProgressBar::chunk {{ background: qlineargradient(x1:0,y1:0,x2:1,y2:0,
                       stop:0 {ACCENT}, stop:1 {ACCENT_HI}); border-radius: 4px; }}
"""


def game_dir() -> pathlib.Path:
    """The folder this launcher lives in, which is the game folder.

    Shipping inside the install is what removes the need for any path setting.
    When frozen by PyInstaller the script path points into a temporary
    extraction directory, so the executable's own location is the only honest
    answer.
    """
    if getattr(sys, "frozen", False):
        return pathlib.Path(sys.executable).resolve().parent
    return pathlib.Path(__file__).resolve().parent


def human(n: float) -> str:
    for unit in ("B", "KiB", "MiB", "GiB"):
        if n < 1024 or unit == "GiB":
            return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
        n /= 1024
    return f"{n:.1f} GiB"


def fetch_json(url: str, timeout: int = 20) -> dict:
    with urllib.request.urlopen(url, timeout=timeout) as r:
        return json.loads(r.read().decode())


def manifest_id(manifest: dict) -> str:
    """A stable fingerprint of the file list, so a changed manifest always
    forces a real verify."""
    import hashlib
    blob = json.dumps(manifest.get("files", []), sort_keys=True).encode()
    return hashlib.sha256(blob).hexdigest()


def quick_check(manifest: dict, root: pathlib.Path) -> bool:
    """True when the install provably matches what we last verified.

    Hashing the whole client takes about fifteen seconds on a fast NVMe and far
    longer on a normal disk, and doing it on every launch to almost always
    conclude "nothing changed" is a poor trade.

    Timestamps are untrustworthy for the ORIGINAL install - the client archives
    carry 2007-2012 mtimes and several extraction paths rewrite them - which is
    why the manifest hashes. But once the launcher has verified an install by
    hash and recorded what it saw, a later size and mtime match is strong
    evidence nothing moved. Anything unrecognised falls through to the full
    hash, and "Verify files" always does the real thing.
    """
    state_path = root / STATE_NAME
    try:
        state = json.loads(state_path.read_text())
    except Exception:
        return False
    if state.get("manifest_id") != manifest_id(manifest):
        return False
    seen = state.get("files") or {}
    for entry in manifest.get("files", []):
        rec = seen.get(entry["path"])
        if not rec:
            return False
        target = root / entry["path"]
        try:
            st = target.stat()
        except OSError:
            return False
        if st.st_size != entry["size"] or st.st_size != rec[0] or st.st_mtime_ns != rec[1]:
            return False
    return True


def write_state(manifest: dict, root: pathlib.Path) -> None:
    files = {}
    for entry in manifest.get("files", []):
        try:
            st = (root / entry["path"]).stat()
        except OSError:
            continue
        files[entry["path"]] = [st.st_size, st.st_mtime_ns]
    try:
        (root / STATE_NAME).write_text(
            json.dumps({"manifest_id": manifest_id(manifest), "files": files}))
    except OSError:
        pass


# --------------------------------------------------------------------- workers

class ServerPing(QThread):
    """Is the login server accepting connections? A launcher that says 'online'
    without checking is worse than one that says nothing."""
    result = Signal(bool, int)      # reachable, milliseconds

    def run(self):
        import time
        start = time.monotonic()
        try:
            with socket.create_connection((SERVER_HOST, int(SERVER_PORT)), timeout=5):
                pass
            self.result.emit(True, int((time.monotonic() - start) * 1000))
        except OSError:
            self.result.emit(False, 0)


class ManifestFetcher(QThread):
    """Fetch manifest.json off the GUI thread.

    It is several megabytes for a full client, and doing this inline froze the
    window before it had painted once - the launcher looked hung on every
    start. Nothing that touches the network belongs on the UI thread.
    """
    done = Signal(object, str)      # manifest or None, error

    def run(self):
        try:
            self.done.emit(fetch_json(MANIFEST_URL, timeout=30), "")
        except Exception as e:
            self.done.emit(None, str(e))


class NewsFetcher(QThread):
    """Fetch news from wherever the manifest says it lives.

    News belongs to the website, not to the patch manifest: it changes far more
    often than the game files, and republishing a 4 MB manifest to fix a typo
    in an announcement is absurd. So the manifest carries a `news_url` and the
    launcher follows it.

    Putting the pointer in the manifest rather than compiling it in means the
    website can take over later by adding one line server-side - no new
    launcher, no player doing anything. Until then the inline list is used.
    """
    done = Signal(object)          # list of items

    def __init__(self, manifest: dict):
        super().__init__()
        self.manifest = manifest

    def run(self):
        url = self.manifest.get("news_url")
        if not url:
            self.done.emit(self.manifest.get("news", []))
            return
        try:
            data = fetch_json(url, timeout=10)
            # Accept either a bare list or {"news": [...]}, since a website API
            # will usually wrap it.
            items = data.get("news", []) if isinstance(data, dict) else data
            self.done.emit(items or self.manifest.get("news", []))
        except Exception:
            # A website being down must not blank the launcher.
            self.done.emit(self.manifest.get("news", []))


class SelfUpdater(QThread):
    """Replace the launcher with the version the manifest advertises.

    A running executable cannot be overwritten on Windows, but it CAN be
    renamed. So the live file is moved aside, the new one takes its place, and
    the stale copy is cleaned up on the next start. On Linux the replace is
    allowed outright, but the same path is used so there is one behaviour to
    reason about.
    """
    done = Signal(bool, str)        # replaced, message

    def __init__(self, manifest: dict, root: pathlib.Path):
        super().__init__()
        self.manifest = manifest
        self.root = root

    def run(self):
        info = self.manifest.get("launcher") or {}
        remote_version = int(info.get("version", 0))
        if remote_version <= LAUNCHER_VERSION or not info.get("path"):
            self.done.emit(False, "")
            return

        # Belt and braces against a mispublished manifest. If we already
        # replaced ourselves for this version and are still reporting an older
        # one, the download did not contain what the manifest promised. Trying
        # again would restart forever, so stop and say so.
        stamp = self.root / ".launcher-update"
        try:
            if stamp.read_text().strip() == str(remote_version):
                self.done.emit(False, f"launcher update to v{remote_version} "
                                      f"did not take - staying on v{LAUNCHER_VERSION}")
                return
        except OSError:
            pass

        me = pathlib.Path(sys.executable if getattr(sys, "frozen", False) else __file__).resolve()
        url = f"{PATCH_BASE.rstrip('/')}/{info['path']}"
        try:
            tmp = me.with_suffix(me.suffix + ".new")
            with urllib.request.urlopen(url, timeout=60) as r, tmp.open("wb") as out:
                shutil.copyfileobj(r, out)
            if info.get("sha256") and sha256(tmp) != info["sha256"]:
                tmp.unlink(missing_ok=True)
                self.done.emit(False, "launcher update failed its checksum")
                return
            old = me.with_suffix(me.suffix + ".old")
            old.unlink(missing_ok=True)
            me.rename(old)               # permitted even while running
            tmp.rename(me)
            try:
                (self.root / ".launcher-update").write_text(str(remote_version))
            except OSError:
                pass
            if not getattr(sys, "frozen", False):
                me.chmod(0o755)
            self.done.emit(True, f"updated to version {remote_version}")
        except Exception as e:
            self.done.emit(False, f"launcher update failed: {e}")


class UpdateWorker(QThread):
    """Fetch the manifest, compare hashes, download what differs."""
    progress = Signal(int, str)
    detail = Signal(str)
    finished_ok = Signal(bool, str)

    def __init__(self, install_dir: pathlib.Path, manifest: dict, force: bool = False):
        super().__init__()
        self.install_dir = install_dir
        self.manifest = manifest
        self.force = force
        self._stop = False

    def stop(self):
        self._stop = True

    def run(self):
        if not self.force and quick_check(self.manifest, self.install_dir):
            self.progress.emit(100, "Game is up to date")
            self.finished_ok.emit(True, "")
            return

        self.progress.emit(2, "Verifying game files…")
        todo = []
        for entry, reason in diff(self.manifest, self.install_dir):
            if self._stop:
                self.finished_ok.emit(False, "Cancelled")
                return
            todo.append(entry)
            self.detail.emit(f"{reason} {entry['path']}")

        if not todo:
            write_state(self.manifest, self.install_dir)
            self.progress.emit(100, "Game is up to date")
            self.finished_ok.emit(True, "")
            return

        need = sum(e["size"] for e in todo)
        base = (self.manifest.get("base_url") or PATCH_BASE).rstrip("/")
        done = 0
        for entry in todo:
            if self._stop:
                self.finished_ok.emit(False, "Cancelled")
                return
            target = self.install_dir / entry["path"]
            target.parent.mkdir(parents=True, exist_ok=True)
            self.detail.emit(f"↓ {entry['path']}")
            try:
                # Download beside the target, verify, then move into place, so
                # an interrupted patch never leaves a truncated game file.
                fd, tmp = tempfile.mkstemp(dir=str(target.parent), suffix=".part")
                os.close(fd)
                tmp_path = pathlib.Path(tmp)
                with urllib.request.urlopen(f"{base}/{entry['path']}", timeout=60) as r, \
                        tmp_path.open("wb") as out:
                    while chunk := r.read(1 << 20):
                        out.write(chunk)
                        done += len(chunk)
                        self.progress.emit(
                            min(99, int(done / need * 100)) if need else 99,
                            f"Downloading   {human(done)} / {human(need)}")
                if sha256(tmp_path) != entry["sha256"]:
                    tmp_path.unlink(missing_ok=True)
                    self.finished_ok.emit(False, f"Checksum failed: {entry['path']}")
                    return
                shutil.move(str(tmp_path), str(target))
            except Exception as e:
                self.finished_ok.emit(False, f"{entry['path']}: {e}")
                return

        write_state(self.manifest, self.install_dir)
        self.progress.emit(100, f"Updated {len(todo)} file(s)")
        self.finished_ok.emit(True, "")


# ------------------------------------------------------------------------- ui

def banner(width: int, height: int) -> QPixmap:
    """Drawn rather than shipped, so the launcher stays a single file with no
    asset dependencies to keep in sync with the patch server."""
    pm = QPixmap(width, height)
    pm.fill(Qt.transparent)
    p = QPainter(pm)
    p.setRenderHint(QPainter.Antialiasing)
    g = QLinearGradient(0, 0, width, height)
    g.setColorAt(0.0, QColor("#08131c"))
    g.setColorAt(0.45, QColor("#123044"))
    g.setColorAt(1.0, QColor("#080f16"))
    p.fillRect(0, 0, width, height, g)
    p.setPen(QColor(79, 195, 247, 22))
    for x in range(0, width + height, 30):
        p.drawLine(x, 0, x - height, height)
    glow = QLinearGradient(0, height, 0, height - 60)
    glow.setColorAt(0.0, QColor(79, 195, 247, 46))
    glow.setColorAt(1.0, QColor(79, 195, 247, 0))
    p.fillRect(0, height - 60, width, 60, glow)
    p.setPen(QColor(79, 195, 247, 120))
    p.drawLine(0, height - 1, width, height - 1)
    p.end()
    return pm


class NewsEntry(QFrame):
    """One announcement. If the item carries a url, the title opens it in the
    player's browser - the launcher shows the summary, the site has the rest."""

    def __init__(self, item: dict):
        super().__init__()
        self.url = item.get("url", "")
        lay = QVBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 12)
        lay.setSpacing(2)
        head = QHBoxLayout()
        head.setSpacing(8)
        t = QLabel(item.get("title", ""))
        t.setObjectName("NewsTitleLink" if self.url else "NewsTitle")
        if self.url:
            t.setCursor(QCursor(Qt.PointingHandCursor))
            t.mousePressEvent = lambda _e: QDesktopServices.openUrl(QUrl(self.url))
        d = QLabel(item.get("date", "")); d.setObjectName("NewsDate")
        head.addWidget(t, 1); head.addWidget(d, 0, Qt.AlignRight | Qt.AlignVCenter)
        lay.addLayout(head)
        b = QLabel(item.get("body", "")); b.setObjectName("NewsBody")
        b.setWordWrap(True)
        lay.addWidget(b)


class Launcher(QMainWindow):
    W, H = 880, 600

    def __init__(self):
        super().__init__()
        self.dir = game_dir()
        self.manifest: dict | None = None
        self.worker: UpdateWorker | None = None
        self.ready = False
        self._force_verify = False
        self.setWindowTitle(f"{APP_NAME} Launcher")
        self.setFixedSize(QSize(self.W, self.H))
        self.setStyleSheet(STYLE)
        self._build()
        self._cleanup_old_launcher()
        QTimer.singleShot(60, self._start)

    # ---- layout
    def _build(self):
        root = QWidget(); self.setCentralWidget(root)
        outer = QVBoxLayout(root)
        outer.setContentsMargins(0, 0, 0, 0); outer.setSpacing(0)

        head = QLabel(); head.setPixmap(banner(self.W, 150)); head.setFixedHeight(150)
        outer.addWidget(head)
        box = QWidget(head); box.setGeometry(0, 0, self.W, 150)
        box.setStyleSheet("background: transparent;")
        bl = QVBoxLayout(box); bl.setContentsMargins(34, 40, 0, 0); bl.setSpacing(4)
        t = QLabel(APP_NAME.upper()); t.setObjectName("Title")
        s = QLabel(TAGLINE); s.setObjectName("Subtitle")
        bl.addWidget(t); bl.addWidget(s)

        body = QWidget(); outer.addWidget(body, 1)
        gl = QHBoxLayout(body)
        gl.setContentsMargins(24, 18, 24, 18); gl.setSpacing(16)

        # news, the left two thirds
        left = QVBoxLayout(); left.setSpacing(8)
        lab = QLabel("LATEST"); lab.setObjectName("SectionLabel")
        left.addWidget(lab)
        news_panel = QFrame(); news_panel.setObjectName("Panel")
        npl = QVBoxLayout(news_panel); npl.setContentsMargins(16, 14, 10, 14)
        self.news_area = QScrollArea(); self.news_area.setWidgetResizable(True)
        self.news_area.setFrameShape(QFrame.NoFrame)
        # The scroll area's viewport and its content widget both paint the
        # window background by default, which draws a darker rectangle inside
        # the panel. A stylesheet on the QScrollArea alone does not reach them.
        self.news_area.viewport().setStyleSheet("background: transparent;")
        self.news_inner = QWidget()
        self.news_inner.setStyleSheet("background: transparent;")
        self.news_lay = QVBoxLayout(self.news_inner)
        self.news_lay.setContentsMargins(0, 0, 8, 0); self.news_lay.setSpacing(0)
        self.news_lay.addStretch(1)
        self.news_area.setWidget(self.news_inner)
        npl.addWidget(self.news_area)
        left.addWidget(news_panel, 1)
        gl.addLayout(left, 2)

        # status, the right third
        right = QVBoxLayout(); right.setSpacing(8)
        lab2 = QLabel("SERVER"); lab2.setObjectName("SectionLabel")
        right.addWidget(lab2)
        st = QFrame(); st.setObjectName("Panel")
        sl = QVBoxLayout(st); sl.setContentsMargins(16, 14, 16, 14); sl.setSpacing(8)
        row = QHBoxLayout(); row.setSpacing(8)
        self.dot = QLabel("●"); self.dot.setObjectName("Dot")
        self.dot.setStyleSheet(f"color: {TEXT_DIM};")
        self.srv = QLabel("Checking…"); self.srv.setObjectName("Status")
        row.addWidget(self.dot, 0); row.addWidget(self.srv, 1)
        sl.addLayout(row)
        self.ping = QLabel(""); self.ping.setObjectName("Detail")
        sl.addWidget(self.ping)

        sep = QFrame(); sep.setObjectName("Sep")
        sl.addSpacing(6); sl.addWidget(sep); sl.addSpacing(6)

        # Links come from the manifest, so the website, Discord or a rankings
        # page can be added or moved without shipping a new launcher.
        self.links_box = QVBoxLayout(); self.links_box.setSpacing(6)
        sl.addLayout(self.links_box)

        sl.addStretch(1)
        self.ver = QLabel(f"launcher v{LAUNCHER_VERSION}"); self.ver.setObjectName("Detail")
        sl.addWidget(self.ver)
        right.addWidget(st, 1)
        gl.addLayout(right, 1)

        # footer
        foot = QWidget(); outer.addWidget(foot)
        fl = QVBoxLayout(foot)
        fl.setContentsMargins(24, 0, 24, 22); fl.setSpacing(8)
        self.bar = QProgressBar(); self.bar.setRange(0, 100); self.bar.setValue(0)
        fl.addWidget(self.bar)
        row2 = QHBoxLayout(); row2.setSpacing(16)
        col = QVBoxLayout(); col.setSpacing(1)
        self.status = QLabel("Starting…"); self.status.setObjectName("Status")
        self.detail = QLabel(""); self.detail.setObjectName("Detail")
        col.addWidget(self.status); col.addWidget(self.detail)
        row2.addLayout(col, 1)
        self.verify_btn = QPushButton("Verify files")
        self.verify_btn.setToolTip("Re-check every file against the patch server "
                                   "and repair anything that differs")
        self.verify_btn.clicked.connect(self._full_verify)
        row2.addWidget(self.verify_btn, 0, Qt.AlignBottom)
        self.play = QPushButton("PLAY"); self.play.setObjectName("Play")
        self.play.setFixedWidth(210); self.play.setEnabled(False)
        self.play.clicked.connect(self._play)
        row2.addWidget(self.play, 0)
        fl.addLayout(row2)

    # ---- behaviour
    def _full_verify(self):
        """Always do the real thing: hash every file, ignore the fast path."""
        self._force_verify = True
        self._start()

    def _cleanup_old_launcher(self):
        """Remove the copy left behind by a previous self-update, and the
        loop-guard stamp once we are demonstrably running the new build."""
        try:
            stamp = self.dir / ".launcher-update"
            if stamp.exists() and int(stamp.read_text().strip()) <= LAUNCHER_VERSION:
                stamp.unlink()
        except (OSError, ValueError):
            pass
        me = pathlib.Path(sys.executable if getattr(sys, "frozen", False) else __file__)
        try:
            me.resolve().with_suffix(me.suffix + ".old").unlink(missing_ok=True)
        except OSError:
            pass

    def _set_status(self, text: str, detail: str = ""):
        self.status.setText(text)
        self.detail.setText(detail)

    def _start(self):
        self.play.setEnabled(False)
        self.verify_btn.setEnabled(False)
        self.bar.setValue(0)
        self._set_status("Contacting the patch server…")

        self.pinger = ServerPing()
        self.pinger.result.connect(self._on_ping)
        self.pinger.start()

        self.fetcher = ManifestFetcher()
        self.fetcher.done.connect(self._on_manifest)
        self.fetcher.start()

    def _on_manifest(self, manifest, error: str):
        if manifest is None:
            # A patch server that is down must not stop someone playing. The
            # installed files are still whatever they were last verified to be.
            self._set_status("Could not reach the patch server — playing offline",
                             error[:90])
            self._render_news([])
            self._render_links([])
            self._enable_play()
            return

        self.manifest = manifest
        self._render_links(manifest.get("links", []))
        self._set_status("Checking for updates…")

        # News may live on the website; the manifest says where.
        self.newsfetch = NewsFetcher(manifest)
        self.newsfetch.done.connect(self._render_news)
        self.newsfetch.start()

        self.selfup = SelfUpdater(manifest, self.dir)
        self.selfup.done.connect(self._on_selfupdate)
        self.selfup.start()

    def _on_selfupdate(self, replaced: bool, message: str):
        if replaced:
            self._set_status("Launcher updated — restarting", message)
            QTimer.singleShot(700, self._restart_self)
            return
        if message:
            self.detail.setText(message)
        self._update_game(self._force_verify)
        self._force_verify = False

    def _restart_self(self):
        me = pathlib.Path(sys.executable if getattr(sys, "frozen", False) else __file__).resolve()
        if getattr(sys, "frozen", False):
            subprocess.Popen([str(me)], cwd=str(self.dir))
        else:
            subprocess.Popen([sys.executable, str(me)], cwd=str(self.dir))
        QApplication.quit()

    def _update_game(self, force: bool = False):
        self.worker = UpdateWorker(self.dir, self.manifest, force)
        self.worker.progress.connect(lambda p, m: (self.bar.setValue(p), self.status.setText(m)))
        self.worker.detail.connect(self.detail.setText)
        self.worker.finished_ok.connect(self._on_update)
        self.worker.start()

    def _on_update(self, ok: bool, error: str):
        if not ok:
            self.bar.setValue(0)
            self._set_status("Update failed", error)
            self.verify_btn.setEnabled(True)
            # The game may still be playable; let them decide.
            self._enable_play()
            return
        self.bar.setValue(100)
        self.detail.setText("")
        self._enable_play()

    def _enable_play(self):
        exe = self.dir / CLIENT_EXE
        self.verify_btn.setEnabled(True)
        if not exe.exists():
            self._set_status("Game files are missing",
                             f"{CLIENT_EXE} is not next to the launcher")
            self.play.setEnabled(False)
            return
        self.ready = True
        self.play.setEnabled(True)
        if self.status.text().startswith(("Update failed", "Could not reach")):
            return
        self._set_status("Ready to play")

    def _on_ping(self, up: bool, ms: int):
        if up:
            self.dot.setStyleSheet(f"color: {OK};")
            self.srv.setText("Online")
            self.ping.setText(f"{SERVER_HOST}   ·   {ms} ms")
        else:
            self.dot.setStyleSheet(f"color: {DANGER};")
            self.srv.setText("Offline")
            self.ping.setText(f"{SERVER_HOST}   ·   not responding")

    def _render_links(self, links: list):
        while self.links_box.count():
            w = self.links_box.takeAt(0).widget()
            if w:
                w.deleteLater()
        for link in links[:6]:
            label, url = link.get("label"), link.get("url")
            if not label or not url:
                continue
            lab = QLabel(f"›  {label}")
            lab.setObjectName("Link")
            lab.setCursor(QCursor(Qt.PointingHandCursor))
            lab.mousePressEvent = lambda _e, u=url: QDesktopServices.openUrl(QUrl(u))
            self.links_box.addWidget(lab)

    def _render_news(self, items: list):
        while self.news_lay.count() > 1:
            w = self.news_lay.takeAt(0).widget()
            if w:
                w.deleteLater()
        if not items:
            lab = QLabel("No announcements."); lab.setObjectName("NewsBody")
            self.news_lay.insertWidget(0, lab)
            return
        for i, item in enumerate(items[:8]):
            self.news_lay.insertWidget(i, NewsEntry(item))

    def _play(self):
        exe = self.dir / CLIENT_EXE
        try:
            if platform.system() == "Windows":
                subprocess.Popen([str(exe), LAUNCH_ARG], cwd=str(self.dir))
            else:
                env = dict(os.environ)
                env.setdefault("WINEDEBUG", "-all")
                subprocess.Popen(["wine", CLIENT_EXE, LAUNCH_ARG],
                                 cwd=str(self.dir), env=env)
        except FileNotFoundError:
            self._set_status("Cannot start the game",
                             "Wine is not installed" if platform.system() != "Windows"
                             else f"{CLIENT_EXE} not found")
            return
        self._set_status("Game starting…")
        QTimer.singleShot(2500, QApplication.quit)


def main() -> int:
    app = QApplication(sys.argv)
    app.setApplicationName(f"{APP_NAME} Launcher")
    w = Launcher()
    w.show()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())
