#!/usr/bin/python3
"""Check the signed LogGraphic repository; install only this application's update."""
import argparse
import hashlib
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import tempfile

LOGGRAPHIC_UPDATE_PROTOCOL = 1
REPOSITORY = "https://repo.sb-i.jp/loggraphic/"
KEY_SHA256 = "53f4110b3125a346569704ede87fead29da85965c1a8403698521111131c15b1"
KEY = Path(__file__).resolve().parent.parent / "share/LogGraphic/loggraphic-archive-keyring.gpg"
VERSION = re.compile(r"[0-9][A-Za-z0-9.+:~\-]*\Z")


def run(argv, *, env=None, cwd=None, timeout=55, check=True):
    result = subprocess.run(argv, env=env, cwd=cwd, text=True,
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
    if check and result.returncode:
        raise RuntimeError(result.stdout[-6000:].strip() or f"{argv[0]} failed ({result.returncode})")
    return result


def installed_version():
    result = run(["/usr/bin/dpkg-query", "-W", "-f=${db:Status-Status}\t${Version}", "loggraphic"], check=False)
    fields = result.stdout.strip().split("\t")
    if result.returncode or len(fields) != 2 or fields[0] != "installed" or not VERSION.fullmatch(fields[1]):
        raise RuntimeError("LogGraphic is not installed as a Debian package. Install the .deb first.")
    return fields[1]


def newer(candidate, installed):
    result = run(["/usr/bin/dpkg", "--compare-versions", candidate, "gt", installed], check=False)
    if result.returncode not in (0, 1):
        raise RuntimeError("Cannot compare Debian package versions.")
    return result.returncode == 0


def candidate_version(output):
    # madison reports repository versions, without policy's installed-version fallback.
    versions = []
    for line in output.splitlines():
        fields = [field.strip() for field in line.split("|")]
        origin = fields[2].split() if len(fields) == 3 else []
        if len(fields) == 3 and fields[0] == "loggraphic" and VERSION.fullmatch(fields[1]) and origin and origin[0].rstrip("/") == REPOSITORY.rstrip("/"):
            versions.append(fields[1])
    if not versions:
        raise RuntimeError("No LogGraphic amd64 package found in the signed repository.")
    latest = versions[0]
    for version in versions[1:]:
        if newer(version, latest):
            latest = version
    return latest


def apt_environment(folder):
    # Separate indexes avoid root privileges, stale caches and edits to system sources.
    # Ignore global APT hooks/config during the repository query and download.
    key_bytes = KEY.read_bytes()
    if hashlib.sha256(key_bytes).hexdigest() != KEY_SHA256:
        raise RuntimeError("Repository signing key mismatch. Reinstall LogGraphic from a trusted package.")
    key = folder / "key.gpg"
    key.write_bytes(key_bytes)
    source = folder / "sources.list"
    source.write_text(f"deb [arch=amd64 signed-by={key}] {REPOSITORY} ./\n")
    (folder / "lists/partial").mkdir(parents=True)
    (folder / "archives/partial").mkdir(parents=True)
    (folder / "empty").mkdir()
    config = folder / "apt.conf"
    config.write_text(f'''Dir::Etc::parts "{folder}/empty";
Dir::Etc::main "/dev/null";
Dir::Etc::sourcelist "{source}";
Dir::Etc::sourceparts "{folder}/empty";
Dir::Etc::preferences "/dev/null";
Dir::Etc::preferencesparts "{folder}/empty";
Dir::State::lists "{folder}/lists";
Dir::Cache::archives "{folder}/archives";
Dir::Cache::pkgcache "";
Dir::Cache::srcpkgcache "";
APT::Architecture "amd64";
Acquire::Languages "none";
Acquire::Retries "0";
Acquire::http::Timeout "20";
Acquire::https::Timeout "20";
APT::Update::Error-Mode "any";
''')
    env = os.environ.copy()
    env.update(APT_CONFIG=str(config), LC_ALL="C", LANG="C")
    # _apt must be able to read this operation's public metadata.
    folder.chmod(0o755)
    for file in (key, source, config):
        file.chmod(0o644)
    return env


def check_or_install(install=False):
    if run(["/usr/bin/dpkg", "--print-architecture"]).stdout.strip() != "amd64":
        raise RuntimeError("This repository supports Debian/Kali amd64 only.")
    current = installed_version()
    if install and os.geteuid() != 0:
        raise RuntimeError("Installation needs administrator privileges. Run loggraphic-update.")
    with tempfile.TemporaryDirectory(prefix="loggraphic-update-") as directory:
        folder = Path(directory)
        env = apt_environment(folder)
        run(["/usr/bin/apt-get", "update"], env=env)
        latest = candidate_version(run(["/usr/bin/apt-cache", "madison", "loggraphic"], env=env).stdout)
        available = newer(latest, current)
        if not install:
            print(f"{'UPDATE' if available else 'CURRENT'}\t{current}\t{latest}", flush=True)
            return 0
        print(f"Repository: {REPOSITORY}\nInstalled: {current}\nAvailable: {latest}", flush=True)
        if not available:
            print("No newer update is available.")
            return 0
        # APT verifies the .deb hash against the newly signature-checked index.
        # Installing this exact file prevents another repository replacing the update.
        run(["/usr/bin/apt-get", "download", f"loggraphic={latest}"], env=env, cwd=folder, timeout=180)
        packages = list(folder.glob("*.deb"))
        if len(packages) != 1:
            raise RuntimeError("Expected exactly one verified update package.")
        clean_env = os.environ.copy()
        clean_env.pop("APT_CONFIG", None)
        result = subprocess.run(["/usr/bin/apt-get", "--no-remove", "--only-upgrade", "install", str(packages[0])], env=clean_env)
        if result.returncode:
            raise RuntimeError(f"Installation failed or was cancelled ({result.returncode}).")
        if installed_version() != latest:
            raise RuntimeError("Update was not installed. The installed version has not changed.")
        print("Update complete. Close and restart LogGraphic to use the new version.")
        return 0


def main():
    # Closing the check window must also release its temporary indexes.
    def cancelled(signum, frame):
        raise KeyboardInterrupt
    signal.signal(signal.SIGTERM, cancelled)
    parser = argparse.ArgumentParser(description=__doc__)
    modes = parser.add_mutually_exclusive_group()
    modes.add_argument("--check", action="store_true", help="Read-only signed repository check (no sudo)")
    modes.add_argument("--install", action="store_true", help="Install after an independent signed check (root)")
    modes.add_argument("--terminal", action="store_true", help="Interactive update; keep results visible")
    args = parser.parse_args()
    if not args.check and not args.install:
        command = [str(Path(__file__).resolve()), "--install"]
        if os.geteuid() != 0:
            command = ["/usr/bin/sudo", "--"] + command
        result = subprocess.run(command)
        if args.terminal:
            try:
                input("\nPress Enter to close this window... ")
            except EOFError:
                pass
        return result.returncode
    try:
        return check_or_install(args.install)
    except KeyboardInterrupt:
        return 130
    except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
        print(f"Update failed: {error}", file=sys.stderr)
        return 1


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