#!/usr/bin/env python3
"""Download the newest R5R CN Launcher x64 installer from GitHub Releases."""

import fcntl
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path


REPOSITORY = "sleep1223/r5r-cn-launcher"
API_URL = f"https://api.github.com/repos/{REPOSITORY}/releases/latest"
ASSET_PATTERN = re.compile(r"^R5R-CN-Launcher_.+_x64-setup\.exe$", re.IGNORECASE)
DESTINATION = Path("/var/www/r5r-patch/R5R-CN-Launcher_x64-setup.exe")
STATE_FILE = DESTINATION.with_suffix(DESTINATION.suffix + ".json")
LOCK_FILE = DESTINATION.with_suffix(DESTINATION.suffix + ".lock")
USER_AGENT = "r5r-cn-launcher-updater/1.0"
TIMEOUT = 60


def log(message):
    print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] {message}", flush=True)


def request_json(url):
    request = urllib.request.Request(
        url,
        headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT},
    )
    with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
        return json.load(response)


def find_installer(release):
    matches = [
        asset for asset in release.get("assets", [])
        if ASSET_PATTERN.match(asset.get("name", ""))
    ]
    if not matches:
        raise RuntimeError(f"release {release.get('tag_name', '<unknown>')} has no x64 setup asset")
    if len(matches) > 1:
        raise RuntimeError("latest release contains multiple matching x64 setup assets")
    return matches[0]


def read_state():
    try:
        with STATE_FILE.open("r", encoding="utf-8") as file:
            return json.load(file)
    except (FileNotFoundError, OSError, ValueError):
        return {}


def download(asset):
    request = urllib.request.Request(
        asset["browser_download_url"],
        headers={"Accept": "application/octet-stream", "User-Agent": USER_AGENT},
    )
    temporary_path = None
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
            expected_size = response.headers.get("Content-Length")
            with tempfile.NamedTemporaryFile(
                mode="wb", prefix=DESTINATION.name + ".", suffix=".part",
                dir=DESTINATION.parent, delete=False,
            ) as output:
                temporary_path = Path(output.name)
                total = 0
                while True:
                    chunk = response.read(1024 * 1024)
                    if not chunk:
                        break
                    output.write(chunk)
                    total += len(chunk)
                output.flush()
                os.fsync(output.fileno())

        if expected_size is not None and total != int(expected_size):
            raise RuntimeError(f"incomplete download: expected {expected_size} bytes, got {total}")
        if asset.get("size") and total != int(asset["size"]):
            raise RuntimeError(f"asset size mismatch: expected {asset['size']} bytes, got {total}")

        os.chmod(temporary_path, 0o644)
        os.replace(temporary_path, DESTINATION)
        temporary_path = None
        return total
    finally:
        if temporary_path is not None:
            try:
                temporary_path.unlink()
            except FileNotFoundError:
                pass


def write_state(release, asset):
    state = {
        "release_id": release.get("id"),
        "tag_name": release.get("tag_name"),
        "asset_id": asset.get("id"),
        "asset_name": asset.get("name"),
        "download_url": asset.get("browser_download_url"),
        "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
    }
    temporary = STATE_FILE.with_suffix(STATE_FILE.suffix + ".tmp")
    with temporary.open("w", encoding="utf-8") as file:
        json.dump(state, file, ensure_ascii=False, indent=2)
        file.write("\n")
        file.flush()
        os.fsync(file.fileno())
    os.replace(temporary, STATE_FILE)


def main():
    DESTINATION.parent.mkdir(parents=True, exist_ok=True)
    with LOCK_FILE.open("a+") as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            log("another updater process is already running; skipped")
            return 0

        release = request_json(API_URL)
        asset = find_installer(release)
        state = read_state()
        if (
            DESTINATION.is_file()
            and state.get("release_id") == release.get("id")
            and state.get("asset_id") == asset.get("id")
        ):
            log(f"already current: {release.get('tag_name')} ({asset['name']})")
            return 0

        log(f"downloading {release.get('tag_name')}: {asset['name']}")
        size = download(asset)
        write_state(release, asset)
        log(f"updated {DESTINATION} ({size} bytes)")
        return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except (urllib.error.URLError, TimeoutError, OSError, ValueError, RuntimeError) as error:
        log(f"ERROR: {error}")
        sys.exit(1)
