#!/usr/bin/env python3

import argparse
import fcntl
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.request
from urllib.parse import urljoin

CONFIG = "/etc/ekklesia-classroom/device.conf"
LOCKFILE = "/run/ekklesia-classroom-update.lock"
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
CACHE_DIR = "/var/cache/ekklesia-classroom"
PACKAGE = "ekklesia-classroom"

# What a published version looks like: 0.2.6 for a feature release,
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
# a mistake on the server, and a classroom does nothing with it. The same
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
# the two to one list of good and bad versions.
#
# Only the SHAPE is decided here. Which of two versions is newer is always
# dpkg's answer, because dpkg is what installs them.
VERSION_PATTERN = re.compile(
    r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
)
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")


def log(message):
    print(f"[ekklesia-classroom-update] {message}", flush=True)


def read_config():
    config = {
        "UPDATE_URL": "https://updates.riverofglorymn.org",
        "CHANNEL": "stable",
    }

    if os.path.exists(CONFIG):
        with open(CONFIG, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, value = line.split("=", 1)
                config[key.strip()] = value.strip()

    return config


def installed_version():
    result = subprocess.run(
        ["dpkg-query", "-W", "-f=${Version}", "ekklesia-classroom"],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        return "0.0.0"

    return result.stdout.strip()


def valid_version(version):
    return isinstance(version, str) and bool(VERSION_PATTERN.match(version))


def version_is_newer(candidate, current):
    result = subprocess.run(
        ["dpkg", "--compare-versions", candidate, "gt", current],
        stderr=subprocess.DEVNULL,
    )
    return result.returncode == 0


def manifest_problem(manifest):
    """Why this manifest must not be acted on, or None if it may be.

    A manifest names a version, the package file for exactly that
    version, and that file's checksum. The package name is checked
    against the version, not merely read, so a manifest that says 0.2.6.1
    and points at the 0.2.6 file is refused rather than installed and
    then offered again at every check.
    """
    if not isinstance(manifest, dict):
        return "the manifest is not an object"

    version = manifest.get("version")

    if not valid_version(version):
        return f"the manifest names an invalid version: {version!r}"

    package = manifest.get("package")
    expected = f"/packages/{PACKAGE}_{version}_amd64.deb"

    if package != expected:
        return f"the manifest names {package!r} for version {version}"

    checksum = manifest.get("sha256")

    if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
        return f"the manifest's sha256 is not a checksum: {checksum!r}"

    return None


def package_identity(path):
    """(Package, Version) as dpkg reads them out of a downloaded file."""
    result = subprocess.run(
        ["dpkg-deb", "--field", path, "Package", "Version"],
        capture_output=True,
        text=True,
    )

    if result.returncode != 0:
        return None, None

    fields = {}

    for line in result.stdout.splitlines():
        if ":" in line:
            key, value = line.split(":", 1)
            fields[key.strip()] = value.strip()

    return fields.get("Package"), fields.get("Version")


def fetch_json(url):
    request = urllib.request.Request(
        url,
        headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
    )

    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)


def download(url, destination):
    request = urllib.request.Request(
        url,
        headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
    )

    with urllib.request.urlopen(request, timeout=120) as response:
        with open(destination, "wb") as f:
            while True:
                chunk = response.read(1024 * 1024)
                if not chunk:
                    break
                f.write(chunk)


def sha256(path):
    digest = hashlib.sha256()

    with open(path, "rb") as f:
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            digest.update(chunk)

    return digest.hexdigest()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--no-reboot",
        action="store_true",
        help="Install the update but do not reboot automatically",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="Check for an update without installing it",
    )
    args = parser.parse_args()

    with open(LOCKFILE, "w", encoding="utf-8") as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            log("Another update is already running.")
            return 0

        config = read_config()

        base_url = config["UPDATE_URL"].rstrip("/") + "/"
        channel = config["CHANNEL"]
        manifest_url = urljoin(base_url, f"{channel}/manifest.json")

        current = installed_version()

        log(f"Current version: {current}")
        log(f"Channel: {channel}")
        log(f"Checking: {manifest_url}")

        try:
            manifest = fetch_json(manifest_url)
        except Exception as exc:
            log(f"Unable to fetch update manifest: {exc}")
            return 1

        problem = manifest_problem(manifest)

        if problem:
            log(f"Ignoring the {channel} manifest: {problem}.")
            return 1

        candidate = manifest["version"]

        log(f"Available version: {candidate}")

        if not version_is_newer(candidate, current):
            log("Already up to date.")
            return 0

        if args.check:
            log(f"Update available: {current} -> {candidate}")
            return 0

        package_url = urljoin(base_url, manifest["package"].lstrip("/"))
        expected_hash = manifest["sha256"].lower()

        os.makedirs(CACHE_DIR, exist_ok=True)

        package_path = os.path.join(
            CACHE_DIR,
            os.path.basename(manifest["package"]),
        )

        temporary_path = package_path + ".download"

        log(f"Downloading {package_url}")

        try:
            download(package_url, temporary_path)
        except Exception as exc:
            log(f"Download failed: {exc}")
            return 1

        actual_hash = sha256(temporary_path)

        if actual_hash != expected_hash:
            log("SHA-256 verification FAILED.")
            log(f"Expected: {expected_hash}")
            log(f"Received: {actual_hash}")
            os.unlink(temporary_path)
            return 1

        log("SHA-256 verification passed.")

        # The checksum proves the file is the one the manifest describes;
        # this proves the manifest described the right file. A package
        # that installs as some other version would be offered again at
        # every check, and installed again, and the room rebooted, for
        # ever.
        name, version = package_identity(temporary_path)

        if name != PACKAGE or version != candidate:
            log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
            os.unlink(temporary_path)
            return 1

        os.replace(temporary_path, package_path)

        log(f"Installing {candidate}")

        result = subprocess.run(
            [
                "apt-get",
                "install",
                "-y",
                package_path,
            ]
        )

        if result.returncode != 0:
            log("Package installation failed.")
            return result.returncode

        log(f"Update to {candidate} installed successfully.")

        if manifest.get("requires_reboot", False):
            if args.no_reboot:
                log("Update requires reboot, but --no-reboot was specified.")
            else:
                # Not now. At 03:00 nobody is there; after a missed 03:00,
                # checked when the computer is next switched on, somebody
                # usually is. classroom-reboot-when-idle waits until nobody
                # has touched it for half an hour and nothing is playing.
                log("Update requires reboot. Rebooting once nobody is using this computer.")
                subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])

        return 0


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