#!/usr/bin/env python3
"""Root-owned, signed update applicator for 8Core Scanner."""

from __future__ import annotations

import argparse
import base64
import datetime
import hashlib
import hmac
import json
import os
import re
import shutil
import ssl
import stat
import subprocess
import sys
import tempfile
import urllib.parse
import urllib.request
import zipfile
from pathlib import Path, PurePosixPath

MAX_ARCHIVE_BYTES = 75 * 1024 * 1024
MAX_EXPANDED_BYTES = 250 * 1024 * 1024
MAX_FILE_BYTES = 100 * 1024 * 1024
MAX_FILE_COUNT = 10_000
ALLOWED_HOST = "scanner.8core.hr"
ALLOWED_PATH_PREFIX = "/update/packages/"
VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
STAGE_DIR_RE = re.compile(r"^8core-update-[0-9a-f]{32}$")
INVENTORY_NAME = ".release-files.json"
INVENTORY_SCHEMA = 1


class UpdateError(RuntimeError):
    pass


class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        validate_package_url(newurl)
        return super().redirect_request(req, fp, code, msg, headers, newurl)


def validate_package_url(url: str) -> None:
    parsed = urllib.parse.urlsplit(url)
    if (
        parsed.scheme != "https"
        or parsed.hostname != ALLOWED_HOST
        or parsed.port not in (None, 443)
        or parsed.username is not None
        or parsed.password is not None
        or parsed.query
        or parsed.fragment
        or not parsed.path.startswith(ALLOWED_PATH_PREFIX)
        or not parsed.path.lower().endswith(".zip")
    ):
        raise UpdateError("Paket nije na dopuštenoj 8Core update lokaciji.")


def assert_root_owned_file(path: Path, label: str) -> None:
    if path.is_symlink() or not path.is_file():
        raise UpdateError(f"{label} nije regularna datoteka: {path}")
    info = path.stat()
    if info.st_uid != 0 or info.st_mode & 0o022:
        raise UpdateError(f"{label} mora biti root-owned i ne smije biti group/world writable.")


def download_package(url: str, destination: Path, expected_size: int | None) -> None:
    validate_package_url(url)
    opener = urllib.request.build_opener(
        urllib.request.HTTPSHandler(context=ssl.create_default_context()),
        SafeRedirectHandler(),
    )
    request = urllib.request.Request(
        url,
        headers={"User-Agent": "8Core-Scanner-Root-Updater/3.6.5"},
        method="GET",
    )
    total = 0
    with opener.open(request, timeout=30) as response, destination.open("xb") as output:
        validate_package_url(response.geturl())
        content_length = response.headers.get("Content-Length", "")
        if content_length.isdigit() and int(content_length) > MAX_ARCHIVE_BYTES:
            raise UpdateError("Udaljeni paket prelazi 75 MB.")
        while True:
            chunk = response.read(1024 * 1024)
            if not chunk:
                break
            total += len(chunk)
            if total > MAX_ARCHIVE_BYTES:
                raise UpdateError("Udaljeni paket prelazi 75 MB.")
            output.write(chunk)
        output.flush()
        os.fsync(output.fileno())
    if total < 1:
        raise UpdateError("Preuzeti paket je prazan.")
    if expected_size is not None and total != expected_size:
        raise UpdateError("Veličina preuzetog paketa ne odgovara manifestu.")


def copy_local_package(
    source: Path,
    destination: Path,
    expected_size: int | None,
    web_uid: int,
) -> Path:
    if not source.is_absolute() or source.name != "package.zip" or not STAGE_DIR_RE.fullmatch(source.parent.name):
        raise UpdateError("Lokalni paket nije u dopuštenom staging direktoriju.")
    stage_dir = source.parent
    if stage_dir.is_symlink() or not stage_dir.is_dir():
        raise UpdateError("Lokalni staging direktorij nije siguran.")
    stage_info = stage_dir.stat()
    if stage_info.st_uid != web_uid or stage_info.st_mode & 0o077:
        raise UpdateError("Lokalni staging direktorij nema očekivanog vlasnika ili dozvole.")

    flags = os.O_RDONLY
    if hasattr(os, "O_NOFOLLOW"):
        flags |= os.O_NOFOLLOW
    descriptor = os.open(source, flags)
    total = 0
    try:
        info = os.fstat(descriptor)
        if not stat.S_ISREG(info.st_mode) or info.st_uid != web_uid or info.st_mode & 0o077:
            raise UpdateError("Lokalni paket nije zatvorena regularna datoteka web vlasnika.")
        if info.st_size < 1 or info.st_size > MAX_ARCHIVE_BYTES:
            raise UpdateError("Lokalni paket ima nedopuštenu veličinu.")
        if expected_size is not None and info.st_size != expected_size:
            raise UpdateError("Veličina lokalnog paketa ne odgovara manifestu.")
        with os.fdopen(descriptor, "rb", closefd=False) as input_file, destination.open("xb") as output:
            while True:
                chunk = input_file.read(1024 * 1024)
                if not chunk:
                    break
                total += len(chunk)
                if total > MAX_ARCHIVE_BYTES:
                    raise UpdateError("Lokalni paket prelazi 75 MB.")
                output.write(chunk)
            output.flush()
            os.fsync(output.fileno())
        destination.chmod(0o600)
    finally:
        os.close(descriptor)
    if total < 1:
        raise UpdateError("Lokalni paket je prazan.")
    return stage_dir


def cleanup_local_stage(stage_dir: Path, web_uid: int) -> None:
    try:
        if (
            not stage_dir.is_absolute()
            or not STAGE_DIR_RE.fullmatch(stage_dir.name)
            or stage_dir.is_symlink()
            or not stage_dir.is_dir()
        ):
            return
        info = stage_dir.stat()
        if info.st_uid != web_uid or info.st_mode & 0o077:
            return
        shutil.rmtree(stage_dir)
    except OSError:
        pass


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def verify_signature(public_key: Path, expected_sha256: str, signature_b64: str, work: Path) -> None:
    assert_root_owned_file(public_key, "Javni update ključ")
    try:
        signature = base64.b64decode(signature_b64, validate=True)
    except ValueError as exc:
        raise UpdateError("Ed25519 potpis nije valjan Base64.") from exc
    if len(signature) != 64:
        raise UpdateError("Ed25519 potpis nema očekivanu duljinu.")

    data_path = work / "sha256.txt"
    signature_path = work / "signature.bin"
    data_path.write_bytes(expected_sha256.encode("ascii"))
    signature_path.write_bytes(signature)
    command = [
        "/usr/bin/openssl",
        "pkeyutl",
        "-verify",
        "-pubin",
        "-inkey",
        str(public_key),
        "-sigfile",
        str(signature_path),
        "-rawin",
        "-in",
        str(data_path),
    ]
    try:
        result = subprocess.run(command, capture_output=True, text=True, timeout=20, check=False)
    except (OSError, subprocess.SubprocessError) as exc:
        raise UpdateError("OpenSSL provjera potpisa nije dostupna.") from exc
    if result.returncode != 0:
        raise UpdateError("Ed25519 potpis paketa nije valjan.")


def normalized_entries(archive: zipfile.ZipFile):
    infos = archive.infolist()
    if not infos or len(infos) > MAX_FILE_COUNT:
        raise UpdateError("ZIP sadrži nedopušten broj zapisa.")

    raw_names: list[str] = []
    expanded = 0
    for info in infos:
        raw = info.filename
        if (
            not raw
            or any(ord(character) < 32 or ord(character) == 127 for character in raw)
            or "\\" in raw
            or raw.startswith("/")
            or re.match(r"^[A-Za-z]:", raw)
        ):
            raise UpdateError("ZIP sadrži nevaljanu putanju.")
        trimmed = raw.rstrip("/")
        parts = trimmed.split("/")
        if not trimmed or any(part in ("", ".", "..") for part in parts):
            raise UpdateError("ZIP sadrži nevaljanu putanju.")

        mode = (info.external_attr >> 16) & 0o170000
        if mode not in (0, stat.S_IFREG, stat.S_IFDIR):
            raise UpdateError("ZIP sadrži symlink ili posebnu datoteku.")
        if info.file_size < 0 or info.file_size > MAX_FILE_BYTES:
            raise UpdateError("Jedna datoteka u ZIP-u prelazi 100 MB.")
        if info.file_size > 5 * 1024 * 1024 and info.compress_size > 0:
            if info.file_size / info.compress_size > 1000:
                raise UpdateError("ZIP ima sumnjiv omjer kompresije.")
        expanded += info.file_size
        if expanded > MAX_EXPANDED_BYTES:
            raise UpdateError("Raspakirani ZIP prelazi 250 MB.")
        raw_names.append(trimmed)

    direct = any(
        name == "scanner"
        or name.startswith("scanner/")
        or name == "8core_scanner"
        or name.startswith("8core_scanner/")
        for name in raw_names
    )
    prefix = ""
    if not direct:
        first = {name.split("/", 1)[0] for name in raw_names}
        if len(first) != 1:
            raise UpdateError("ZIP nema valjanu Scanner strukturu.")
        prefix = next(iter(first)) + "/"

    seen: set[str] = set()
    entries = []
    version_info = None
    for info, raw_name in zip(infos, raw_names):
        if prefix:
            if raw_name == prefix.rstrip("/"):
                continue
            if not raw_name.startswith(prefix):
                raise UpdateError("ZIP nema valjanu Scanner strukturu.")
            normalized = raw_name[len(prefix) :]
        else:
            normalized = raw_name
        if not (
            normalized == "scanner"
            or normalized.startswith("scanner/")
            or normalized == "8core_scanner"
            or normalized.startswith("8core_scanner/")
        ):
            raise UpdateError("ZIP smije sadržavati samo scanner/ i 8core_scanner/.")
        if normalized == "scanner/modules" or normalized.startswith("scanner/modules/"):
            raise UpdateError("Core paket ne smije sadržavati scanner/modules/.")
        if normalized in seen:
            raise UpdateError("ZIP ima duplikat normalizirane putanje.")
        seen.add(normalized)
        entries.append((info, normalized))
        if normalized == "scanner/VERSION" and not info.is_dir():
            version_info = info
    if version_info is None:
        raise UpdateError("Paketu nedostaje scanner/VERSION.")
    return entries, version_info


def extract_package(package: Path, extract_root: Path, expected_version: str) -> None:
    with zipfile.ZipFile(package, "r") as archive:
        entries, version_info = normalized_entries(archive)
        package_version = archive.read(version_info).decode("utf-8", "strict").strip()
        if package_version != expected_version or not VERSION_RE.fullmatch(package_version):
            raise UpdateError("VERSION u paketu ne odgovara potpisanom manifestu.")

        for info, normalized in entries:
            destination = extract_root.joinpath(*PurePosixPath(normalized).parts)
            if info.is_dir() or info.filename.endswith("/"):
                destination.mkdir(parents=True, exist_ok=True, mode=0o755)
                continue
            destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
            with archive.open(info, "r") as source, destination.open("xb") as target:
                copied = shutil.copyfileobj(source, target, length=1024 * 1024)
                target.flush()
                os.fsync(target.fileno())
            if destination.stat().st_size != info.file_size:
                raise UpdateError("ZIP zapis nije potpuno raspakiran.")
            destination.chmod(0o750 if destination.suffix == ".sh" else 0o644)


# H2 popravak: CWP/CloudLinux serveri često NEMAJU /usr/bin/php, ili on
# pokazuje na osiromašen/drugačiji PHP nego onaj koji web panel stvarno
# koristi (alt-php iz /opt/alt/...). Popis kandidata namjerno je isti kao
# postojeći `mail_php_bin()` u scanner_worker.sh, radi dosljednosti između
# dva mjesta u projektu koja moraju pronaći "pravi" PHP CLI na serveru.
PHP_CLI_CANDIDATES = [
    "/opt/alt/php-fpm84/usr/bin/php", "/opt/alt/php-fpm83/usr/bin/php",
    "/opt/alt/php-fpm82/usr/bin/php", "/opt/alt/php-fpm81/usr/bin/php",
    "/opt/alt/php-fpm80/usr/bin/php", "/opt/alt/php-fpm74/usr/bin/php",
    "/opt/alt/php84/usr/bin/php", "/opt/alt/php83/usr/bin/php",
    "/opt/alt/php82/usr/bin/php", "/opt/alt/php81/usr/bin/php",
    "/opt/alt/php80/usr/bin/php", "/opt/alt/php74/usr/bin/php",
    "/usr/local/bin/php", "/usr/bin/php",
]



# Realan, ali namjerno konzervativan (PHP 7.4+ kompatibilan - bez match/enum/
# constructor promotion koji su 8.0+) uzorak koda: namespace + interface +
# trait + tipizirano svojstvo + arrow fn. Cilj je natjerati Zend kompajler da
# stvarno odradi posao, za razliku od `-v` self-testa koji ne dotakne iste
# code path-ove (vidi _php_cli_can_lint() ispod).
_PHP_CLI_SELF_TEST_SAMPLE = """<?php
namespace Scanner\\SelfTest;

interface Greeter { public function greet(): string; }

trait Loud { public function shout(string $s): string { return strtoupper($s); } }

class Sample implements Greeter {
    use Loud;
    private string $name;
    public function __construct(string $name) { $this->name = $name; }
    public function greet(): string {
        $fn = fn(string $x): string => "Hello, {$x}!";
        return $fn($this->name);
    }
}

$s = new Sample('world');
echo $s->greet();
"""


def _php_cli_can_lint(candidate: Path, sample_file: Path) -> bool:
    try:
        result = subprocess.run(
            [str(candidate), "-l", str(sample_file)], capture_output=True, text=True, timeout=10, check=False
        )
    except OSError:
        return False
    return result.returncode == 0


def find_php_cli() -> Path | None:
    """
    Pronalazi prvi PHP CLI koji stvarno radi. Za razliku od trusted-root-config
    provjera koje se koriste drugdje u projektu, ovdje se symlink NE odbija —
    /usr/bin/php je na mnogim distribucijama legitimno symlink
    (update-alternatives i slično), a ovo je samo lociranje binarnog alata za
    lint, ne čitanje povjerljivog configa.

    Ne vjerujemo golom `-v` self-testu: neki alt-php/lsphp bundleovi (npr. CLI
    binary koji dolazi UZ php-fpmNN paket, ne uz zaseban CLI paket) prođu `-v`
    bez problema, ali se sruše (SIGSEGV) čim trebaju stvarno kompajlirati kod
    — najčešće zbog slomljenog/nekompatibilnog zend_extensiona (opcache/
    ioncube) vezanog uz taj konkretan binary. Zato svakog kandidata dodatno
    provjeravamo pravim `-l` lint testom na reprezentativnom sample fajlu
    prije nego mu povjerimo bilo što.
    """
    seen: set[str] = set()
    candidates = list(PHP_CLI_CANDIDATES)
    which_php = shutil.which("php")
    if which_php:
        candidates.append(which_php)

    sample_file: Path | None = None
    try:
        fd, sample_path = tempfile.mkstemp(prefix="8core-phpcli-selftest-", suffix=".php")
        try:
            with os.fdopen(fd, "w") as fh:
                fh.write(_PHP_CLI_SELF_TEST_SAMPLE)
            sample_file = Path(sample_path)
        except OSError:
            try:
                os.close(fd)
            except OSError:
                pass
    except OSError:
        sample_file = None

    try:
        for raw in candidates:
            if not raw or raw in seen:
                continue
            seen.add(raw)
            candidate = Path(raw)
            if not candidate.exists() or not os.access(candidate, os.X_OK):
                continue
            try:
                result = subprocess.run(
                    [str(candidate), "-v"], capture_output=True, text=True, timeout=10, check=False
                )
            except OSError:
                continue
            if result.returncode != 0 or "PHP" not in (result.stdout or ""):
                continue
            if sample_file is not None and not _php_cli_can_lint(candidate, sample_file):
                continue
            return candidate
        return None
    finally:
        if sample_file is not None:
            try:
                sample_file.unlink()
            except OSError:
                pass


def lint_tree(extract_root: Path) -> None:
    php_files = [p for p in extract_root.rglob("*.php") if p.is_file() and not p.is_symlink()]
    sh_files = [p for p in extract_root.rglob("*.sh") if p.is_file() and not p.is_symlink()]

    php_cli = find_php_cli() if php_files else None
    if php_files and php_cli is None:
        # Prije ovog popravka, nedostatak /usr/bin/php je jednostavno PRESKOČIO
        # lint svih .php datoteka (tiho `continue`), pa je update mogao
        # završiti sa statusom 'completed' iako nijedna .php datoteka nikad
        # nije sintaktički provjerena. Paket koji sadrži PHP a nema
        # vjerodostojan CLI na serveru sada MORA pasti zatvoreno.
        raise UpdateError(
            f"Paket sadrži {len(php_files)} PHP datoteka, ali nijedan vjerodostojan "
            "PHP CLI nije pronađen na serveru (provjereno: "
            f"{', '.join(PHP_CLI_CANDIDATES)} i PATH). Sintaktička provjera se ne "
            "može izvršiti — update je zaustavljen."
        )

    bash = Path("/usr/bin/bash")
    if sh_files and not (bash.exists() and os.access(bash, os.X_OK)):
        raise UpdateError("bash nije dostupan na serveru — sintaktička provjera .sh datoteka nije moguća.")

    for path in php_files:
        command = [str(php_cli), "-l", str(path)]
        result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False)
        if result.returncode != 0:
            detail = (result.stderr or result.stdout).strip()
            raise UpdateError(f"Sintaktička provjera nije prošla: {path.name}: {detail[:500]}")

    for path in sh_files:
        command = [str(bash), "-n", str(path)]
        result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False)
        if result.returncode != 0:
            detail = (result.stderr or result.stdout).strip()
            raise UpdateError(f"Sintaktička provjera nije prošla: {path.name}: {detail[:500]}")


def assert_safe_root(path: Path, label: str) -> Path:
    if not path.is_absolute() or path.is_symlink() or not path.is_dir():
        raise UpdateError(f"{label} nije siguran postojeći direktorij: {path}")
    resolved = path.resolve(strict=True)
    if resolved == Path("/"):
        raise UpdateError(f"{label} ne smije biti filesystem root.")
    return resolved


def assert_safe_destination(root: Path, destination: Path) -> None:
    try:
        relative = destination.relative_to(root)
    except ValueError as exc:
        raise UpdateError(f"Odredište je izvan dopuštenog roota: {destination}") from exc
    cursor = root
    for part in relative.parts:
        cursor = cursor / part
        if cursor.exists() or cursor.is_symlink():
            if cursor.is_symlink():
                raise UpdateError(f"Symlink u update odredištu nije dopušten: {cursor}")


def protected(area: str, relative: str) -> bool:
    if area == "web":
        return (
            relative in ("includes/config.php", "includes/mail.key.php", "install/install.lock", "modules")
            or relative.startswith("modules/")
        )
    return (
        relative in ("scanner-db.conf", "logs", "quarantine", INVENTORY_NAME)
        or relative.startswith("logs/")
        or relative.startswith("quarantine/")
        or relative == "__pycache__"
        or relative.startswith("__pycache__/")
        or relative.endswith(".pyc")
    )


def file_digest(path: Path) -> str:
    return sha256_file(path)


def collect_changes(extract_root: Path, web_root: Path, engine_root: Path):
    changes = []
    for area, source_root, destination_root in (
        ("web", extract_root / "scanner", web_root),
        ("engine", extract_root / "8core_scanner", engine_root),
    ):
        if not source_root.is_dir():
            continue
        for source in sorted(source_root.rglob("*")):
            if not source.is_file() or source.is_symlink():
                continue
            relative = source.relative_to(source_root).as_posix()
            if protected(area, relative):
                continue
            destination = destination_root.joinpath(*PurePosixPath(relative).parts)
            assert_safe_destination(destination_root, destination)
            if destination.exists() and not destination.is_file():
                raise UpdateError(f"Update odredište nije regularna datoteka: {destination}")
            if destination.is_file() and file_digest(source) == file_digest(destination):
                continue
            changes.append((area, relative, source, destination))
    return changes


def build_release_inventory(extract_root: Path, version: str) -> dict:
    files = []
    for area, source_root in (
        ("web", extract_root / "scanner"),
        ("engine", extract_root / "8core_scanner"),
    ):
        if not source_root.is_dir():
            continue
        for source in sorted(source_root.rglob("*")):
            if not source.is_file() or source.is_symlink():
                continue
            relative = source.relative_to(source_root).as_posix()
            if protected(area, relative):
                continue
            files.append({"area": area, "path": relative})
    if not files or len(files) > MAX_FILE_COUNT:
        raise UpdateError("Novi release inventar nema valjan broj datoteka.")
    files.sort(key=lambda item: (item["area"], item["path"]))
    return {"schema": INVENTORY_SCHEMA, "version": version, "files": files}


def validate_inventory_relative(area: str, relative: str) -> str:
    if area not in ("web", "engine") or not relative or "\\" in relative:
        raise UpdateError("Release inventar sadrži nevaljanu putanju.")
    pure = PurePosixPath(relative)
    if pure.is_absolute() or any(part in ("", ".", "..") for part in pure.parts):
        raise UpdateError("Release inventar sadrži nevaljanu putanju.")
    normalized = pure.as_posix()
    if protected(area, normalized):
        raise UpdateError("Release inventar pokušava upravljati zaštićenom putanjom.")
    return normalized


def load_release_inventory(engine_root: Path) -> dict | None:
    path = engine_root / INVENTORY_NAME
    if not path.exists():
        return None
    assert_root_owned_file(path, "Release inventar")
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        raise UpdateError("Postojeći release inventar nije čitljiv ili nije valjan JSON.") from exc
    if not isinstance(data, dict) or data.get("schema") != INVENTORY_SCHEMA or not isinstance(data.get("files"), list):
        raise UpdateError("Postojeći release inventar nema podržanu strukturu.")
    if len(data["files"]) > MAX_FILE_COUNT:
        raise UpdateError("Postojeći release inventar je prevelik.")
    normalized = []
    seen = set()
    for item in data["files"]:
        if not isinstance(item, dict):
            raise UpdateError("Postojeći release inventar sadrži nevaljan zapis.")
        area = str(item.get("area", ""))
        relative = validate_inventory_relative(area, str(item.get("path", "")))
        key = (area, relative)
        if key in seen:
            raise UpdateError("Postojeći release inventar ima duplikat putanje.")
        seen.add(key)
        normalized.append({"area": area, "path": relative})
    return {"schema": INVENTORY_SCHEMA, "version": str(data.get("version", "")), "files": normalized}


def collect_stale_files(
    previous_inventory: dict | None,
    current_inventory: dict,
    web_root: Path,
    engine_root: Path,
):
    if previous_inventory is None:
        return []
    current = {(item["area"], item["path"]) for item in current_inventory["files"]}
    stale = []
    for item in previous_inventory["files"]:
        key = (item["area"], item["path"])
        if key in current:
            continue
        area, relative = key
        destination_root = web_root if area == "web" else engine_root
        destination = destination_root.joinpath(*PurePosixPath(relative).parts)
        assert_safe_destination(destination_root, destination)
        if destination.is_symlink():
            raise UpdateError(f"Symlink se ne smije ukloniti kao zastarjela release datoteka: {destination}")
        if destination.exists() and not destination.is_file():
            raise UpdateError(f"Zastarjelo release odredište nije regularna datoteka: {destination}")
        if destination.is_file():
            stale.append((area, relative, destination))
    return stale


def ensure_parent(path: Path, root: Path, uid: int, gid: int) -> None:
    missing = []
    cursor = path.parent
    while cursor != root and not cursor.exists():
        missing.append(cursor)
        cursor = cursor.parent
    assert_safe_destination(root, path)
    for directory in reversed(missing):
        directory.mkdir(mode=0o755)
        os.chown(directory, uid, gid)


# B5 popravak: mod bitovi UNSAFE_MODE_BITS (grupno/globalno pisiv, setuid, setgid)
# se nikad ne smiju naslijediti s postojeceg cilja. Prije ovog popravka je
# atomic_copy() bezuvjetno cuvao postojeci mod ("existing_mode or default") — pa
# je datoteka jednom instalirana s prevelikim modom (npr. 0666 iz pokvarenog
# fresh-installa, vidi B5 u install_root.sh) tako ostajala 0666 zauvijek, kroz
# svaki sljedeci update, jer je updater samo ponavljao gresku koju je nasao.
UNSAFE_MODE_BITS = stat.S_IWGRP | stat.S_IWOTH | stat.S_ISUID | stat.S_ISGID


def safe_target_mode(source: Path, destination: Path) -> int:
    default_mode = 0o750 if source.suffix == ".sh" else 0o644
    if destination.exists() and not destination.is_symlink():
        existing_mode = stat.S_IMODE(destination.stat().st_mode)
        if not (existing_mode & UNSAFE_MODE_BITS):
            # Postojeci mod je vec siguran (moguce namjerno pooštren, npr. 0640)
            # — zadrzi ga umjesto da ga nepotrebno olabavimo na default.
            return existing_mode
    return default_mode


def atomic_copy(source: Path, destination: Path, root: Path, uid: int, gid: int) -> None:
    ensure_parent(destination, root, uid, gid)
    mode = safe_target_mode(source, destination)
    descriptor, temporary_name = tempfile.mkstemp(prefix=".8core-update-", dir=destination.parent)
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "wb") as output, source.open("rb") as input_file:
            shutil.copyfileobj(input_file, output, length=1024 * 1024)
            output.flush()
            os.fsync(output.fileno())
        os.chmod(temporary, mode)
        os.chown(temporary, uid, gid)
        os.replace(temporary, destination)
    finally:
        if temporary.exists():
            temporary.unlink()


def prepare_backup_root(path: Path) -> Path:
    if not path.is_absolute() or path == Path("/") or path.is_symlink():
        raise UpdateError("Backup root nije sigurna apsolutna putanja.")
    path.mkdir(parents=True, exist_ok=True, mode=0o700)
    resolved = path.resolve(strict=True)
    info = resolved.stat()
    if info.st_uid != 0 or info.st_mode & 0o022:
        raise UpdateError("Backup root mora biti root-owned i zatvoren za druge korisnike.")
    return resolved


def snapshot_file(source: Path, destination: Path) -> None:
    destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    shutil.copy2(source, destination, follow_symlinks=False)
    os.chown(destination, 0, 0)
    os.chmod(destination, 0o600)


def atomic_write_inventory(path: Path, inventory: dict, engine_root: Path) -> None:
    descriptor, temporary_name = tempfile.mkstemp(prefix=".8core-inventory-", dir=engine_root)
    temporary = Path(temporary_name)
    try:
        payload = (json.dumps(inventory, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
        with os.fdopen(descriptor, "wb") as output:
            output.write(payload)
            output.flush()
            os.fsync(output.fileno())
        os.chown(temporary, 0, 0)
        os.chmod(temporary, 0o600)
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


def remove_empty_parents(path: Path, root: Path) -> None:
    cursor = path.parent
    while cursor != root:
        try:
            if cursor.is_symlink():
                return
            cursor.rmdir()
        except OSError:
            return
        cursor = cursor.parent


def apply_transaction(
    changes,
    stale_files,
    web_root: Path,
    engine_root: Path,
    backup_root: Path | None,
    rollback_root: Path,
    version: str,
    inventory: dict,
):
    web_info = web_root.stat()
    backup_dir = None
    journal = []
    if backup_root is not None:
        stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
        backup_dir = backup_root / f"{stamp}-to-v{version}"
        suffix = 0
        while backup_dir.exists():
            suffix += 1
            backup_dir = backup_root / f"{stamp}-to-v{version}-{suffix}"
        backup_dir.mkdir(mode=0o700)

    def remember(area: str, relative: str, destination: Path, action: str):
        destination_root = web_root if area == "web" else engine_root
        uid, gid = (web_info.st_uid, web_info.st_gid) if area == "web" else (0, 0)
        assert_safe_destination(destination_root, destination)
        if destination.is_symlink():
            raise UpdateError(f"Symlink update odredište nije dopušteno: {destination}")
        existed = destination.is_file()
        rollback = None
        persistent = None
        if existed:
            rollback = rollback_root / area / Path(*PurePosixPath(relative).parts)
            snapshot_file(destination, rollback)
            if backup_dir is not None:
                persistent = backup_dir / area / Path(*PurePosixPath(relative).parts)
                snapshot_file(destination, persistent)
        entry = {
            "area": area,
            "relative": relative,
            "destination_root": destination_root,
            "destination": destination,
            "existed": existed,
            "rollback": rollback,
            "persistent": persistent,
            "uid": uid,
            "gid": gid,
            "action": action,
        }
        journal.append(entry)
        return entry

    inventory_path = engine_root / INVENTORY_NAME
    try:
        for area, relative, source, destination in changes:
            entry = remember(area, relative, destination, "replace")
            atomic_copy(source, destination, entry["destination_root"], entry["uid"], entry["gid"])

        for area, relative, destination in stale_files:
            entry = remember(area, relative, destination, "remove")
            if not entry["existed"]:
                continue
            destination.unlink()

        for area, relative, source, destination in changes:
            if not destination.is_file() or destination.is_symlink():
                raise UpdateError(f"Primijenjena release datoteka nije regularna: {destination}")
            if not hmac.compare_digest(file_digest(source), file_digest(destination)):
                raise UpdateError(f"Provjera primijenjene datoteke nije prošla: {area}/{relative}")
        for _, _, destination in stale_files:
            if destination.exists() or destination.is_symlink():
                raise UpdateError(f"Zastarjela release datoteka nije uklonjena: {destination}")
        installed = (web_root / "VERSION").read_text(encoding="utf-8").strip()
        if installed != version:
            raise UpdateError("VERSION nakon primjene nije očekivana.")

        inventory_entry = remember("engine", INVENTORY_NAME, inventory_path, "inventory")
        atomic_write_inventory(inventory_path, inventory, engine_root)

        if backup_dir is not None:
            manifest = {
                "created_at": datetime.datetime.now().astimezone().isoformat(),
                "target_version": version,
                "files": [
                    {
                        "area": entry["area"],
                        "path": entry["relative"],
                        "action": entry["action"],
                        "destination": str(entry["destination"]),
                        "backup": str(entry["persistent"]) if entry["persistent"] else None,
                        "existed": entry["existed"],
                    }
                    for entry in journal
                    if entry is not inventory_entry
                ],
            }
            manifest_path = backup_dir / "backup-manifest.json"
            manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
            os.chown(manifest_path, 0, 0)
            os.chmod(manifest_path, 0o600)
    except Exception as original_error:
        rollback_errors = []
        for entry in reversed(journal):
            try:
                destination = entry["destination"]
                if entry["existed"] and entry["rollback"] and entry["rollback"].is_file():
                    atomic_copy(
                        entry["rollback"],
                        destination,
                        entry["destination_root"],
                        entry["uid"],
                        entry["gid"],
                    )
                elif not entry["existed"] and destination.is_file() and not destination.is_symlink():
                    destination.unlink()
            except Exception as rollback_error:
                rollback_errors.append(str(rollback_error))
        if rollback_errors:
            raise UpdateError(
                f"{original_error} Privremeni rollback nije potpun: {'; '.join(rollback_errors[:3])}"
            ) from original_error
        raise

    for area, _, destination in stale_files:
        remove_empty_parents(destination, web_root if area == "web" else engine_root)
    return backup_dir


def main() -> int:
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("--web-root", required=True)
    parser.add_argument("--engine-root", required=True)
    parser.add_argument("--public-key", required=True)
    parser.add_argument("--package-source", choices=("remote", "local"), default="remote")
    parser.add_argument("--package-url", default="")
    parser.add_argument("--package-path", default="")
    parser.add_argument("--sha256", required=True)
    parser.add_argument("--signature", required=True)
    parser.add_argument("--version", required=True)
    parser.add_argument("--package-size", type=int, default=0)
    parser.add_argument("--backup", choices=("0", "1"), required=True)
    parser.add_argument("--backup-root", required=True)
    args = parser.parse_args()

    if os.geteuid() != 0:
        raise UpdateError("Update helper mora raditi kao root.")
    if not VERSION_RE.fullmatch(args.version):
        raise UpdateError("Ciljna verzija nije valjana.")
    expected_sha256 = args.sha256.lower()
    if not SHA256_RE.fullmatch(expected_sha256):
        raise UpdateError("Očekivani SHA-256 nije valjan.")
    if args.package_size < 0 or args.package_size > MAX_ARCHIVE_BYTES:
        raise UpdateError("Deklarirana veličina paketa nije valjana.")
    if args.package_source == "remote":
        validate_package_url(args.package_url)
    elif not args.package_path:
        raise UpdateError("Putanja lokalnog paketa nije zadana.")

    web_root = assert_safe_root(Path(args.web_root), "Web root")
    engine_root = assert_safe_root(Path(args.engine_root), "Engine root")
    web_uid = web_root.stat().st_uid
    public_key = Path(args.public_key)
    backup_root = prepare_backup_root(Path(args.backup_root)) if args.backup == "1" else None

    local_stage = None
    try:
        with tempfile.TemporaryDirectory(prefix="8core-root-update-", dir="/var/tmp") as temp:
            work = Path(temp)
            package = work / "package.zip"
            extract_root = work / "extract"
            rollback_root = work / "rollback"
            extract_root.mkdir(mode=0o700)
            rollback_root.mkdir(mode=0o700)
            expected_size = args.package_size if args.package_size else None
            if args.package_source == "remote":
                download_package(args.package_url, package, expected_size)
            else:
                local_stage = copy_local_package(Path(args.package_path), package, expected_size, web_uid)
            actual_sha256 = sha256_file(package)
            if not hmac.compare_digest(actual_sha256, expected_sha256):
                raise UpdateError("SHA-256 paketa ne odgovara potpisanom zahtjevu.")
            verify_signature(public_key, expected_sha256, args.signature, work)
            extract_package(package, extract_root, args.version)
            lint_tree(extract_root)
            current_inventory = build_release_inventory(extract_root, args.version)
            previous_inventory = load_release_inventory(engine_root)
            changes = collect_changes(extract_root, web_root, engine_root)
            stale_files = collect_stale_files(previous_inventory, current_inventory, web_root, engine_root)
            backup_dir = apply_transaction(
                changes,
                stale_files,
                web_root,
                engine_root,
                backup_root,
                rollback_root,
                args.version,
                current_inventory,
            )
    finally:
        if local_stage is not None:
            cleanup_local_stage(local_stage, web_uid)

    print(f"RESULT_VERSION={args.version}")
    print(f"RESULT_FILES={len(changes)}")
    print(f"RESULT_REMOVED={len(stale_files)}")
    print(f"RESULT_BACKUP={backup_dir if backup_dir else '-'}")
    print("RESULT_ROLLBACK=temporary")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except UpdateError as error:
        print(f"UPDATE_ERROR={error}", file=sys.stderr)
        raise SystemExit(1)
    except Exception as error:
        print(f"UPDATE_ERROR=Neočekivana greška: {error}", file=sys.stderr)
        raise SystemExit(1)
