#!/usr/bin/env python3
"""
Synex Package Manager root helper.

This helper is intended to be executed as root through pkexec.

Supported actions:
- apt-update
- apt-upgrade-safe
- apt-package-transaction
- apt-holds-apply
- apt-repositories-apply
- apt-repository-delete
- synex-repository-repair
- known-repository-add
- update-timer-set
- update-timer-reset
- unattended-set
- unattended-run
- logs-set
- logs-clean
- local-deb-install
- software-list-import
- flatpak-support-enable
"""

from __future__ import annotations

import datetime
import json
import os
import pwd
import re
import shutil
import subprocess
import sys
from pathlib import Path


LOG_DIR = Path("/var/log/synex-package-manager")
LOGS_CONFIG_DIR = Path("/etc/synex-package-manager")
LOGS_CONFIG_FILE = LOGS_CONFIG_DIR / "logs.conf"
DEFAULT_LOG_RETENTION_DAYS = 30
LOG_FILE_PATTERN_RE = re.compile(
    r"^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_.+\.log$"
)
SYSTEM_RUNTIME_DIR = Path("/run/synex-package-manager")
SYSTEM_REFRESH_MARKER = SYSTEM_RUNTIME_DIR / "refresh-indicator"
LOCAL_DEB_CACHE_DIR = Path("/var/cache/synex-package-manager/local-debs")

UPDATE_TIMER_NAME = "synex-package-manager-update.timer"
UPDATE_TIMER_DROPIN_DIR = Path("/etc/systemd/system") / f"{UPDATE_TIMER_NAME}.d"
UPDATE_TIMER_OVERRIDE_FILE = UPDATE_TIMER_DROPIN_DIR / "override.conf"

UNATTENDED_TIMER_NAME = "synex-package-manager-unattended.timer"
UNATTENDED_SERVICE_NAME = "synex-package-manager-unattended.service"
UNATTENDED_TIMER_PATHS = (
    Path("/etc/systemd/system") / UNATTENDED_TIMER_NAME,
    Path("/lib/systemd/system") / UNATTENDED_TIMER_NAME,
    Path("/usr/lib/systemd/system") / UNATTENDED_TIMER_NAME,
)
UNATTENDED_TIMER_DROPIN_DIR = (
    Path("/etc/systemd/system")
    / f"{UNATTENDED_TIMER_NAME}.d"
)
UNATTENDED_TIMER_OVERRIDE_FILE = (
    UNATTENDED_TIMER_DROPIN_DIR
    / "override.conf"
)
UNATTENDED_CONFIG_DIR = Path("/etc/synex-package-manager")
UNATTENDED_CONFIG_FILE = UNATTENDED_CONFIG_DIR / "unattended.conf"
UNATTENDED_LAST_LOG_FILE = LOG_DIR / "unattended-last.log"

UNATTENDED_MODE_SECURITY = "security"
UNATTENDED_MODE_APT_SAFE = "apt-safe"
UNATTENDED_MODE_APT_SAFE_FLATPAK = "apt-safe-flatpak"
VALID_UNATTENDED_MODES = {
    UNATTENDED_MODE_SECURITY,
    UNATTENDED_MODE_APT_SAFE,
    UNATTENDED_MODE_APT_SAFE_FLATPAK,
}

VALID_PACKAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+_.:-]*$")

SOFTWARE_LIST_FORMAT = "synex-software-list"
SOFTWARE_LIST_FORMAT_VERSION = 1
SOFTWARE_LIST_MANAGED_FLATPAK_REMOTES = {"flathub", "flathub-verified"}

FLATPAK_ID_RE = re.compile(
    r"^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)+$"
)
FLATPAK_REMOTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")


def _usage() -> None:
    """
    Print usage information.
    """

    print("Usage:")
    print("  synexpm-root-helper apt-update")
    print("  synexpm-root-helper apt-upgrade-safe")
    print("  synexpm-root-helper apt-package-transaction [--purge] [--autoremove] [--install PKG...] [--remove PKG...]")
    print("  synexpm-root-helper apt-holds-apply CHANGES_JSON")
    print("  synexpm-root-helper apt-repositories-apply CHANGES_JSON")
    print("  synexpm-root-helper apt-repository-delete ENTRY_ID")
    print("  synexpm-root-helper synex-repository-repair")
    print("  synexpm-root-helper known-repository-add REPO_ID")
    print("  synexpm-root-helper update-timer-set HOURS")
    print("  synexpm-root-helper update-timer-reset")
    print("  synexpm-root-helper unattended-set ENABLED MODE HOURS")
    print("  synexpm-root-helper unattended-run")
    print("  synexpm-root-helper logs-set DAYS")
    print("  synexpm-root-helper logs-clean")
    print("  synexpm-root-helper local-deb-install DEB_FILE")
    print("  synexpm-root-helper software-list-import SOFTWARE_LIST_JSON")
    print("  synexpm-root-helper flatpak-support-enable")
    print("")


def _ensure_root() -> None:
    """
    Ensure helper is running as root.
    """

    if os.geteuid() != 0:
        print("ERROR: this helper must be run as root.", file=sys.stderr)
        raise SystemExit(1)


def _build_environment() -> dict[str, str]:
    """
    Return environment for APT operations.
    """

    env = os.environ.copy()
    env["LC_ALL"] = "C.UTF-8"
    env["LANG"] = "C.UTF-8"
    env["DEBIAN_FRONTEND"] = "noninteractive"

    return env


def _validate_package_name(package_name: str) -> str:
    """
    Validate and return package name.
    """

    package_name = package_name.strip()

    if not package_name:
        raise ValueError("Package name cannot be empty.")

    if not VALID_PACKAGE_RE.fullmatch(package_name):
        raise ValueError(f"Invalid package name: {package_name}")

    return package_name


def _parse_package_transaction_args(args: list[str]) -> tuple[list[str], list[str], bool, bool]:
    """
    Parse apt-package-transaction arguments.
    """

    install_packages: list[str] = []
    remove_packages: list[str] = []
    purge = False
    autoremove = False
    mode = ""

    for arg in args:
        if arg == "--purge":
            purge = True
            continue

        if arg == "--autoremove":
            autoremove = True
            continue

        if arg == "--install":
            mode = "install"
            continue

        if arg == "--remove":
            mode = "remove"
            continue

        if not mode:
            raise ValueError(
                "Package transaction arguments must be preceded by --install or --remove."
            )

        package_name = _validate_package_name(arg)

        if mode == "install":
            if package_name not in install_packages:
                install_packages.append(package_name)

        elif mode == "remove":
            if package_name not in remove_packages:
                remove_packages.append(package_name)

    if not install_packages and not remove_packages:
        raise ValueError("No package actions requested.")

    return install_packages, remove_packages, purge, autoremove


def _is_package_installed(package_name: str) -> bool:
    """
    Return True if a package is installed according to dpkg.
    """

    try:
        process = subprocess.run(
            ["dpkg-query", "-W", "-f=${Status}", package_name],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=_build_environment(),
            check=False,
        )
    except Exception:
        return False

    return process.returncode == 0 and process.stdout.strip() == "install ok installed"


def _load_hold_changes(json_path: Path) -> list[dict[str, object]]:
    """
    Load held package changes from a JSON file.
    """

    if not json_path.exists():
        raise ValueError(f"Held package changes file does not exist: {json_path}")

    if not json_path.is_file():
        raise ValueError(f"Held package changes path is not a file: {json_path}")

    if json_path.stat().st_size > 1024 * 1024:
        raise ValueError("Held package changes file is too large.")

    with json_path.open("r", encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict):
        raise ValueError("Held package changes payload must be a JSON object.")

    changes = payload.get("changes")

    if not isinstance(changes, list):
        raise ValueError("Held package changes payload must include a changes list.")

    parsed_changes: list[dict[str, object]] = []
    seen_packages: set[str] = set()

    for item in changes:
        if not isinstance(item, dict):
            raise ValueError("Each held package change must be an object.")

        package_name = _validate_package_name(str(item.get("package", "")))
        hold_value = item.get("hold")

        if not isinstance(hold_value, bool):
            raise ValueError(f"Invalid hold value for package: {package_name}")

        if package_name in seen_packages:
            continue

        # Holding is restricted to installed packages because the GUI is meant
        # to manage real installed packages only.
        #
        # Releasing a hold is intentionally allowed even if dpkg-query does not
        # currently report the package as installed. apt-mark showhold may still
        # contain a valid held selection, and apt-mark unhold is a safe,
        # non-destructive operation.
        if hold_value and not _is_package_installed(package_name):
            raise ValueError(f"Package is not installed: {package_name}")

        parsed_changes.append(
            {
                "package": package_name,
                "hold": hold_value,
            }
        )
        seen_packages.add(package_name)

    if not parsed_changes:
        raise ValueError("No held package changes requested.")

    return parsed_changes


def _run_apt_holds_apply(args: list[str]) -> int:
    """
    Apply apt-mark hold/unhold changes.
    """

    action = "apt-holds-apply"

    if len(args) != 1:
        print("ERROR: apt-holds-apply requires CHANGES_JSON.", file=sys.stderr)
        _usage()
        return 2

    try:
        changes = _load_hold_changes(Path(args[0]))
    except Exception as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    _write_log_line(log_file, "Requested held package changes:")

    for change in changes:
        package_name = str(change["package"])
        hold_value = bool(change["hold"])
        state = "hold" if hold_value else "unhold"
        _write_log_line(log_file, f"- {package_name}: {state}")

    return_code = 0

    for change in changes:
        package_name = str(change["package"])
        hold_value = bool(change["hold"])
        command = ["apt-mark", "hold" if hold_value else "unhold", package_name]

        return_code = _run_command_streaming(
            command,
            log_file,
            append=True,
        )

        if return_code != 0:
            break

    if return_code == 0:
        _touch_system_refresh_marker()

    return return_code


def _build_package_remove_command(
    remove_packages: list[str],
    *,
    purge: bool = False,
    autoremove: bool = False,
) -> list[str]:
    """
    Build apt-get command for package removals.
    """

    command = ["apt-get", "-y"]

    if autoremove:
        command.append("--auto-remove")

    command.append("purge" if purge else "remove")
    command.extend(remove_packages)

    return command


def _build_package_install_command(
    install_packages: list[str],
) -> list[str]:
    """
    Build apt-get command for package installations.
    """

    return ["apt-get", "-y", "install"] + install_packages


def _build_package_transaction_command(args: list[str]) -> list[str]:
    """
    Build the first apt-get command for a package transaction.
    """

    commands = _build_package_transaction_commands(args)

    return commands[0][1]


def _build_package_transaction_commands(args: list[str]) -> list[tuple[str, list[str]]]:
    """
    Build one or more real commands for a package transaction.

    Mixed install/remove transactions remain a single apt-get install command
    only when no purge/autoremove behavior is requested. When purge or
    autoremove is requested, removals are applied first as their own APT
    command, then installations are applied. This keeps the execution plan
    aligned with the pre-apply simulation summary.
    """

    install_packages, remove_packages, purge, autoremove = _parse_package_transaction_args(args)
    commands: list[tuple[str, list[str]]] = []

    if install_packages and remove_packages and not purge and not autoremove:
        commands.append(
            (
                "Apply selected package changes",
                ["apt-get", "-y", "install"]
                + install_packages
                + [f"{package_name}-" for package_name in remove_packages],
            )
        )
        return commands

    if remove_packages:
        commands.append(
            (
                "Apply selected package removals",
                _build_package_remove_command(
                    remove_packages,
                    purge=purge,
                    autoremove=autoremove,
                ),
            )
        )

    if install_packages:
        commands.append(
            (
                "Apply selected package installations",
                _build_package_install_command(install_packages),
            )
        )

    return commands


def _run_package_transaction(args: list[str]) -> int:
    """
    Run an APT package transaction, optionally followed by purge/autoremove.
    """

    action = "apt-package-transaction"

    try:
        commands = _build_package_transaction_commands(args)
    except Exception as error:
        print(f"ERROR: {error}", file=sys.stderr)
        _usage()
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    for label, command in commands:
        _append_log_section(log_file, label)

        return_code = _run_command_streaming(
            command,
            log_file,
            append=True,
        )

        if return_code != 0:
            return return_code

    _touch_system_refresh_marker()

    return 0


def _build_command(action: str, args: list[str]) -> list[str]:
    """
    Build command for helper action.
    """

    if action == "apt-update":
        if args:
            raise ValueError("apt-update does not accept extra arguments.")

        return ["apt-get", "update"]

    if action == "apt-upgrade-safe":
        if args:
            raise ValueError("apt-upgrade-safe does not accept extra arguments.")

        return ["apt-get", "-y", "--with-new-pkgs", "--no-remove", "upgrade"]

    if action == "apt-package-transaction":
        return _build_package_transaction_command(args)

    raise ValueError(f"Unknown action: {action}")


def _prepare_log_file(action: str) -> Path:
    """
    Prepare persistent log file path.
    """

    LOG_DIR.mkdir(parents=True, exist_ok=True)

    timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    log_file = LOG_DIR / f"{timestamp}_{action}.log"

    return log_file


def _print_header(action: str, log_file: Path) -> None:
    """
    Print helper header.
    """

    print("")
    print("Synex Package Manager root helper")
    print("=" * 60)
    print(f"Action: {action}")
    print(f"Log file: {log_file}")
    print("=" * 60)
    print("")


def _print_header_without_log(action: str) -> None:
    """
    Print helper header for actions that do not write disk logs.
    """

    print("")
    print("Synex Package Manager root helper")
    print("=" * 60)
    print(f"Action: {action}")
    print("Log file: none")
    print("=" * 60)
    print("")


def _write_console_line(message: str) -> None:
    """
    Write a line only to stdout.
    """

    print(message, flush=True)


def _validate_log_retention_days(value: str) -> int:
    """
    Validate log retention days.

    0 means keep logs forever.
    """

    try:
        days = int(value)
    except ValueError as error:
        raise ValueError("Log retention must be an integer number of days.") from error

    if days < 0 or days > 3650:
        raise ValueError("Log retention must be between 0 and 3650 days.")

    return days


def _read_log_retention_days() -> int:
    """
    Read configured log retention days.

    Missing or invalid configuration falls back to the default.
    """

    if not LOGS_CONFIG_FILE.exists():
        return DEFAULT_LOG_RETENTION_DAYS

    try:
        lines = LOGS_CONFIG_FILE.read_text(
            encoding="utf-8",
            errors="replace",
        ).splitlines()
    except OSError:
        return DEFAULT_LOG_RETENTION_DAYS

    for line in lines:
        stripped = line.strip()

        if not stripped or stripped.startswith("#") or "=" not in stripped:
            continue

        key, value = stripped.split("=", 1)

        if key.strip().upper() != "RETENTION_DAYS":
            continue

        try:
            return _validate_log_retention_days(value.strip().strip('"').strip("'"))
        except ValueError:
            return DEFAULT_LOG_RETENTION_DAYS

    return DEFAULT_LOG_RETENTION_DAYS


def _write_log_retention_config(days: int) -> None:
    """
    Write log retention configuration.
    """

    LOGS_CONFIG_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)

    content = (
        "# Synex Package Manager log retention configuration\n"
        "# Managed by Synex Package Manager.\n"
        "# RETENTION_DAYS=0 keeps persistent logs forever.\n"
        f"RETENTION_DAYS={days}\n"
    )

    LOGS_CONFIG_FILE.write_text(content, encoding="utf-8")
    LOGS_CONFIG_FILE.chmod(0o644)


def _is_managed_timestamped_log(path: Path) -> bool:
    """
    Return True only for SPM timestamped operation logs.
    """

    return path.is_file() and LOG_FILE_PATTERN_RE.fullmatch(path.name) is not None


def _format_bytes(size: int) -> str:
    """
    Format bytes for console output.
    """

    if size >= 1024 * 1024:
        return f"{size / (1024 * 1024):.2f} MiB"

    if size >= 1024:
        return f"{size / 1024:.2f} KiB"

    return f"{size} B"


def _clean_all_logs() -> tuple[int, int]:
    """
    Remove all regular files and symlinks from the Synex Package Manager log directory.

    Returns:
        deleted_count, freed_bytes
    """

    if not LOG_DIR.exists():
        return 0, 0

    deleted_count = 0
    freed_bytes = 0

    for path in LOG_DIR.iterdir():
        if not path.is_file() and not path.is_symlink():
            continue

        try:
            size = path.lstat().st_size
            path.unlink()
        except OSError:
            continue

        deleted_count += 1
        freed_bytes += size

    return deleted_count, freed_bytes


def _clean_expired_logs(days: int) -> tuple[int, int]:
    """
    Remove timestamped logs older than the configured retention.

    Returns:
        deleted_count, freed_bytes
    """

    if days == 0:
        return 0, 0

    if not LOG_DIR.exists():
        return 0, 0

    now = datetime.datetime.now().timestamp()
    cutoff = now - (days * 24 * 60 * 60)

    deleted_count = 0
    freed_bytes = 0

    for path in LOG_DIR.iterdir():
        if not _is_managed_timestamped_log(path):
            continue

        try:
            stat_result = path.stat()
        except OSError:
            continue

        if stat_result.st_mtime >= cutoff:
            continue

        try:
            size = stat_result.st_size
            path.unlink()
        except OSError:
            continue

        deleted_count += 1
        freed_bytes += size

    return deleted_count, freed_bytes




def _validate_local_deb_file(value: str) -> Path:
    """
    Validate a local .deb path for installation.
    """

    path = Path(value).expanduser()

    if not path.exists():
        raise ValueError(f"File does not exist: {path}")

    if not path.is_file():
        raise ValueError(f"Path is not a regular file: {path}")

    if path.suffix.lower() != ".deb":
        raise ValueError("Only .deb packages are supported.")

    resolved = path.resolve()

    try:
        process = subprocess.run(
            ["dpkg-deb", "--field", str(resolved), "Package"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=_build_environment(),
            check=False,
        )
    except Exception as error:
        raise ValueError(f"Could not inspect Debian package: {error}") from error

    if process.returncode != 0 or not process.stdout.strip():
        message = (process.stderr or process.stdout or "").strip()
        raise ValueError(message or "Invalid Debian package.")

    return resolved


def _local_deb_cache_name(path: Path) -> str:
    """
    Return a safe cache filename for a local .deb package.
    """

    sanitized = re.sub(r"[^A-Za-z0-9._+-]", "_", path.name)

    if not sanitized:
        sanitized = "local-package.deb"

    if not sanitized.lower().endswith(".deb"):
        sanitized += ".deb"

    timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")

    return f"{timestamp}_{sanitized}"


def _run_local_deb_install(args: list[str]) -> int:
    """
    Install a local .deb package through APT.
    """

    action = "local-deb-install"

    if len(args) != 1:
        print("ERROR: local-deb-install requires DEB_FILE.", file=sys.stderr)
        _usage()
        return 2

    try:
        source_path = _validate_local_deb_file(args[0])
    except ValueError as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    cache_path: Path | None = None

    try:
        LOCAL_DEB_CACHE_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)

        cache_path = LOCAL_DEB_CACHE_DIR / _local_deb_cache_name(source_path)

        shutil.copy2(source_path, cache_path)
        cache_path.chmod(0o644)

        _write_log_line(log_file, f"Source package: {source_path}")
        _write_log_line(log_file, f"Cached package: {cache_path}")

        return_code = _run_command_streaming(
            ["apt-get", "-y", "install", str(cache_path)],
            log_file,
            append=True,
        )

        if return_code == 0:
            _touch_system_refresh_marker()

        return return_code

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    finally:
        if cache_path is not None and cache_path.exists():
            try:
                cache_path.unlink()
                _write_log_line(log_file, f"Removed cached package: {cache_path}")
            except OSError as error:
                _write_log_line(
                    log_file,
                    f"WARNING: could not remove cached package {cache_path}: {error}",
                )


def _run_logs_set(args: list[str]) -> int:
    """
    Configure persistent log retention.
    """

    action = "logs-set"

    if len(args) != 1:
        print("ERROR: logs-set requires DAYS.", file=sys.stderr)
        _usage()
        return 2

    try:
        days = _validate_log_retention_days(args[0])
    except ValueError as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2

    _print_header_without_log(action)

    try:
        _write_log_retention_config(days)
    except Exception as error:
        _write_console_line(f"ERROR: {error}")
        return 1

    _write_console_line(f"Wrote configuration: {LOGS_CONFIG_FILE}")
    _write_console_line(f"RETENTION_DAYS={days}")

    if days == 0:
        _write_console_line("Persistent log retention: keep forever.")
    else:
        _write_console_line(f"Persistent log retention: {days} days.")

    return 0


def _run_logs_clean(args: list[str]) -> int:
    """
    Clean all persistent logs.
    """

    action = "logs-clean"

    if args:
        print("ERROR: logs-clean does not accept extra arguments.", file=sys.stderr)
        _usage()
        return 2

    _print_header_without_log(action)

    deleted_count, freed_bytes = _clean_all_logs()

    _write_console_line(f"Log directory: {LOG_DIR}")
    _write_console_line(f"Deleted log files: {deleted_count}")
    _write_console_line(f"Freed space: {_format_bytes(freed_bytes)}")
    _write_console_line("Subdirectories, if any, were not touched.")

    return 0


def _run_command_streaming(
    command: list[str],
    log_file: Path,
    append: bool = False,
) -> int:
    """
    Run command and stream combined stdout/stderr to console and log.
    """

    print("Command:", " ".join(command))
    print("")

    env = _build_environment()

    mode = "a" if append else "w"

    with log_file.open(mode, encoding="utf-8", errors="replace") as log:
        if append:
            log.write("\n")

        log.write("Command: " + " ".join(command) + "\n\n")
        log.flush()

        process = subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            text=True,
            bufsize=1,
            env=env,
        )

        assert process.stdout is not None

        for line in process.stdout:
            print(line, end="", flush=True)
            log.write(line)
            log.flush()

        return_code = process.wait()

        print("")
        print("=" * 60)
        print(f"Return code: {return_code}")
        print(f"Log file: {log_file}")

        log.write("\n")
        log.write("=" * 60 + "\n")
        log.write(f"Return code: {return_code}\n")
        log.write(f"Log file: {log_file}\n")
        log.flush()

    try:
        log_file.chmod(0o664)
    except PermissionError:
        pass

    return return_code


def _touch_system_refresh_marker() -> None:
    """
    Notify user tray indicators that package state may have changed.
    """

    try:
        SYSTEM_RUNTIME_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
        SYSTEM_REFRESH_MARKER.touch()
        SYSTEM_REFRESH_MARKER.chmod(0o644)
    except Exception:
        # Never fail an APT operation because tray notification failed.
        pass


def _load_repository_changes(json_path: Path) -> list[dict[str, object]]:
    """
    Load repository changes from a JSON file.
    """

    if not json_path.exists():
        raise ValueError(f"Repository changes file does not exist: {json_path}")

    if not json_path.is_file():
        raise ValueError(f"Repository changes path is not a file: {json_path}")

    if json_path.stat().st_size > 1024 * 1024:
        raise ValueError("Repository changes file is too large.")

    with json_path.open("r", encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict):
        raise ValueError("Repository changes payload must be a JSON object.")

    changes = payload.get("changes")

    if not isinstance(changes, list):
        raise ValueError("Repository changes payload must contain a changes list.")

    for change in changes:
        if not isinstance(change, dict):
            raise ValueError("Each repository change must be a JSON object.")

    return changes


def _write_log_line(log_file: Path, message: str) -> None:
    """
    Write a line to console and log file.
    """

    print(message, flush=True)

    with log_file.open("a", encoding="utf-8", errors="replace") as log:
        log.write(message + "\n")
        log.flush()


def _run_repository_apply(args: list[str]) -> int:
    """
    Apply repository changes and refresh package lists.
    """

    action = "apt-repositories-apply"

    if len(args) != 1:
        print("ERROR: apt-repositories-apply requires CHANGES_JSON.", file=sys.stderr)
        _usage()
        return 2

    changes_file = Path(args[0])
    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        lib_dir = Path(__file__).resolve().parents[1]

        if str(lib_dir) not in sys.path:
            sys.path.insert(0, str(lib_dir))

        from synexpm.repo_backend import apply_repository_changes

        _write_log_line(log_file, f"Changes file: {changes_file}")

        changes = _load_repository_changes(changes_file)
        messages = apply_repository_changes(changes)

        for message in messages:
            _write_log_line(log_file, message)

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return_code = _run_command_streaming(
        ["apt-get", "update"],
        log_file,
        append=True,
    )

    if return_code == 0:
        _touch_system_refresh_marker()

    return return_code


def _run_repository_delete(args: list[str]) -> int:
    """
    Delete one repository entry and refresh package lists.
    """

    action = "apt-repository-delete"

    if len(args) != 1:
        print("ERROR: apt-repository-delete requires ENTRY_ID.", file=sys.stderr)
        _usage()
        return 2

    entry_id = args[0].strip()

    if not entry_id:
        print("ERROR: repository entry id cannot be empty.", file=sys.stderr)
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        lib_dir = Path(__file__).resolve().parents[1]

        if str(lib_dir) not in sys.path:
            sys.path.insert(0, str(lib_dir))

        from synexpm.repo_backend import delete_repository_entry

        _write_log_line(log_file, f"Repository entry id: {entry_id}")

        messages = delete_repository_entry(entry_id)

        for message in messages:
            _write_log_line(log_file, message)

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return_code = _run_command_streaming(
        ["apt-get", "update"],
        log_file,
        append=True,
    )

    if return_code == 0:
        _touch_system_refresh_marker()

    return return_code


def _run_synex_repository_repair(args: list[str]) -> int:
    """
    Repair official Synex repository and refresh package lists.
    """

    action = "synex-repository-repair"

    if args:
        print("ERROR: synex-repository-repair does not accept extra arguments.", file=sys.stderr)
        _usage()
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        lib_dir = Path(__file__).resolve().parents[1]

        if str(lib_dir) not in sys.path:
            sys.path.insert(0, str(lib_dir))

        from synexpm.repo_backend import repair_synex_repository

        messages = repair_synex_repository()

        for message in messages:
            _write_log_line(log_file, message)

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return_code = _run_command_streaming(
        ["apt-get", "update"],
        log_file,
        append=True,
    )

    if return_code == 0:
        _touch_system_refresh_marker()

    return return_code


def _run_known_repository_add(args: list[str]) -> int:
    """
    Add or repair a known repository and refresh package lists.
    """

    action = "known-repository-add"

    if len(args) != 1:
        print("ERROR: known-repository-add requires REPO_ID.", file=sys.stderr)
        _usage()
        return 2

    repo_id = args[0].strip()

    if not repo_id:
        print("ERROR: repository id cannot be empty.", file=sys.stderr)
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        lib_dir = Path(__file__).resolve().parents[1]

        if str(lib_dir) not in sys.path:
            sys.path.insert(0, str(lib_dir))

        from synexpm.known_repos import install_known_repository

        _write_log_line(log_file, f"Repository id: {repo_id}")

        messages = install_known_repository(repo_id)

        for message in messages:
            _write_log_line(log_file, message)

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return_code = _run_command_streaming(
        ["apt-get", "update"],
        log_file,
        append=True,
    )

    if return_code == 0:
        _touch_system_refresh_marker()

    return return_code


def _validate_timer_hours(value: str) -> int:
    """
    Validate update timer interval in hours.
    """

    try:
        hours = int(value)
    except ValueError as error:
        raise ValueError("Timer interval must be an integer number of hours.") from error

    if hours < 1 or hours > 168:
        raise ValueError("Timer interval must be between 1 and 168 hours.")

    return hours


def _run_systemctl_command(
    args: list[str],
    log_file: Path,
) -> int:
    """
    Run systemctl command and append to the same log.
    """

    return _run_command_streaming(
        ["systemctl"] + args,
        log_file,
        append=True,
    )


def _run_command_streaming_without_log(command: list[str]) -> int:
    """
    Run command and stream combined stdout/stderr only to console.
    """

    print("Command:", " ".join(command))
    print("")

    env = _build_environment()

    process = subprocess.Popen(
        command,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        stdin=subprocess.DEVNULL,
        text=True,
        bufsize=1,
        env=env,
    )

    assert process.stdout is not None

    for line in process.stdout:
        print(line, end="", flush=True)

    return_code = process.wait()

    print("")
    print("=" * 60)
    print(f"Return code: {return_code}")

    return return_code


def _run_systemctl_command_without_log(args: list[str]) -> int:
    """
    Run systemctl command without writing a disk log.
    """

    return _run_command_streaming_without_log(["systemctl"] + args)


def _reload_and_restart_update_timer(log_file: Path) -> int:
    """
    Reload systemd and restart the update timer.
    """

    for command in (
        ["daemon-reload"],
        ["enable", UPDATE_TIMER_NAME],
        ["restart", UPDATE_TIMER_NAME],
    ):
        return_code = _run_systemctl_command(command, log_file)

        if return_code != 0:
            return return_code

    return 0


def _run_update_timer_set(args: list[str]) -> int:
    """
    Write systemd timer override for update refresh interval.
    """

    action = "update-timer-set"

    if len(args) != 1:
        print("ERROR: update-timer-set requires HOURS.", file=sys.stderr)
        _usage()
        return 2

    try:
        hours = _validate_timer_hours(args[0])
    except ValueError as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        UPDATE_TIMER_DROPIN_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)

        content = (
            "[Timer]\n"
            "OnBootSec=\n"
            "OnActiveSec=\n"
            "OnUnitActiveSec=\n"
            "OnActiveSec=5min\n"
            f"OnUnitActiveSec={hours}h\n"
        )

        UPDATE_TIMER_OVERRIDE_FILE.write_text(content, encoding="utf-8")
        UPDATE_TIMER_OVERRIDE_FILE.chmod(0o644)

        _write_log_line(
            log_file,
            f"Wrote override: {UPDATE_TIMER_OVERRIDE_FILE}",
        )
        _write_log_line(log_file, f"OnUnitActiveSec={hours}h")

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return _reload_and_restart_update_timer(log_file)


def _run_update_timer_reset(args: list[str]) -> int:
    """
    Remove systemd timer override and restore package default.
    """

    action = "update-timer-reset"

    if args:
        print("ERROR: update-timer-reset does not accept extra arguments.", file=sys.stderr)
        _usage()
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        if UPDATE_TIMER_OVERRIDE_FILE.exists():
            UPDATE_TIMER_OVERRIDE_FILE.unlink()
            _write_log_line(
                log_file,
                f"Removed override: {UPDATE_TIMER_OVERRIDE_FILE}",
            )
        else:
            _write_log_line(log_file, "No override file was present.")

        try:
            UPDATE_TIMER_DROPIN_DIR.rmdir()
        except OSError:
            pass

    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return _reload_and_restart_update_timer(log_file)


def _validate_boolean_value(value: str) -> bool:
    """
    Validate a boolean value used by unattended-set.
    """

    normalized = value.strip().lower()

    if normalized in {"1", "true", "yes", "on", "enabled"}:
        return True

    if normalized in {"0", "false", "no", "off", "disabled"}:
        return False

    raise ValueError("Enabled value must be true or false.")


def _validate_unattended_mode(value: str) -> str:
    """
    Validate unattended update mode.
    """

    normalized = value.strip().lower()

    if normalized not in VALID_UNATTENDED_MODES:
        raise ValueError(
            "Mode must be one of: security, apt-safe, apt-safe-flatpak."
        )

    return normalized


def _unattended_timer_unit_exists() -> bool:
    """
    Return True if the unattended timer unit is installed.
    """

    return any(path.exists() for path in UNATTENDED_TIMER_PATHS)


def _read_unattended_config() -> dict[str, str]:
    """
    Read unattended update configuration.
    """

    data: dict[str, str] = {}

    if not UNATTENDED_CONFIG_FILE.exists():
        return data

    try:
        lines = UNATTENDED_CONFIG_FILE.read_text(
            encoding="utf-8",
            errors="replace",
        ).splitlines()
    except OSError:
        return data

    for line in lines:
        stripped = line.strip()

        if not stripped or stripped.startswith("#") or "=" not in stripped:
            continue

        key, value = stripped.split("=", 1)
        data[key.strip().upper()] = value.strip().strip('"').strip("'")

    return data


def _write_unattended_config(
    *,
    enabled: bool,
    mode: str,
    hours: int,
) -> None:
    """
    Write unattended update configuration.
    """

    UNATTENDED_CONFIG_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)

    content = (
        "# Synex Package Manager unattended update configuration\n"
        "# Managed by Synex Package Manager.\n"
        f"ENABLED={'true' if enabled else 'false'}\n"
        f"MODE={mode}\n"
        f"INTERVAL={hours}h\n"
    )

    UNATTENDED_CONFIG_FILE.write_text(content, encoding="utf-8")
    UNATTENDED_CONFIG_FILE.chmod(0o644)


def _write_unattended_timer_override(hours: int) -> None:
    """
    Write unattended update timer override.

    The packaged timer keeps a conservative default. User-selected intervals
    are stored as a systemd drop-in so package updates can safely replace the
    base unit without losing local configuration.

    Timer trigger entries are reset and rebuilt explicitly, matching the
    update timer override behavior. This prevents systemd from ending with an
    elapsed timer and no next trigger.
    """

    UNATTENDED_TIMER_DROPIN_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)

    content = (
        "[Timer]\n"
        "OnBootSec=\n"
        "OnActiveSec=\n"
        "OnUnitActiveSec=\n"
        "OnActiveSec=10min\n"
        f"OnUnitActiveSec={hours}h\n"
    )

    UNATTENDED_TIMER_OVERRIDE_FILE.write_text(content, encoding="utf-8")
    UNATTENDED_TIMER_OVERRIDE_FILE.chmod(0o644)

def _reload_and_apply_unattended_timer(
    *,
    enabled: bool,
    log_file: Path,
) -> int:
    """
    Reload systemd and enable or disable the unattended timer.

    During source-tree testing the unit may not be installed yet. In that case,
    keep the saved configuration and return success so the GUI can be tested
    before building the package.
    """

    if not _unattended_timer_unit_exists():
        _write_log_line(
            log_file,
            "Unattended timer unit is not installed yet. Configuration was saved.",
        )
        return 0

    return_code = _run_systemctl_command(["daemon-reload"], log_file)

    if return_code != 0:
        return return_code

    if enabled:
        for command in (
            ["enable", UNATTENDED_TIMER_NAME],
            ["restart", UNATTENDED_TIMER_NAME],
        ):
            return_code = _run_systemctl_command(command, log_file)

            if return_code != 0:
                return return_code

        return 0

    return _run_systemctl_command(
        ["disable", "--now", UNATTENDED_TIMER_NAME],
        log_file,
    )


def _reload_and_apply_unattended_timer_without_log(*, enabled: bool) -> int:
    """
    Reload systemd and enable or disable the unattended timer without disk logs.
    """

    if not _unattended_timer_unit_exists():
        _write_console_line(
            "Unattended timer unit is not installed yet. Configuration was saved."
        )
        return 0

    return_code = _run_systemctl_command_without_log(["daemon-reload"])

    if return_code != 0:
        return return_code

    if enabled:
        for command in (
            ["enable", UNATTENDED_TIMER_NAME],
            ["restart", UNATTENDED_TIMER_NAME],
        ):
            return_code = _run_systemctl_command_without_log(command)

            if return_code != 0:
                return return_code

        return 0

    return _run_systemctl_command_without_log(
        ["disable", "--now", UNATTENDED_TIMER_NAME]
    )


def _run_unattended_set(args: list[str]) -> int:
    """
    Configure unattended updates.
    """

    action = "unattended-set"

    if len(args) != 3:
        print("ERROR: unattended-set requires ENABLED MODE HOURS.", file=sys.stderr)
        _usage()
        return 2

    try:
        enabled = _validate_boolean_value(args[0])
        mode = _validate_unattended_mode(args[1])
        hours = _validate_timer_hours(args[2])
    except ValueError as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 2

    _print_header_without_log(action)

    if enabled and shutil.which("unattended-upgrade") is None:
        _write_console_line(
            "ERROR: unattended-upgrade is not installed. "
            "Install the unattended-upgrades package before enabling "
            "unattended updates."
        )
        return 1

    try:
        _write_unattended_config(
            enabled=enabled,
            mode=mode,
            hours=hours,
        )
        _write_unattended_timer_override(hours)

        _write_console_line(f"Wrote configuration: {UNATTENDED_CONFIG_FILE}")
        _write_console_line(f"Wrote timer override: {UNATTENDED_TIMER_OVERRIDE_FILE}")
        _write_console_line(f"ENABLED={'true' if enabled else 'false'}")
        _write_console_line(f"MODE={mode}")
        _write_console_line(f"INTERVAL={hours}h")

    except Exception as error:
        _write_console_line(f"ERROR: {error}")
        return 1

    return _reload_and_apply_unattended_timer_without_log(enabled=enabled)


def _prepare_unattended_last_log() -> Path:
    """
    Prepare the single last-run unattended update log.
    """

    LOG_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
    UNATTENDED_LAST_LOG_FILE.write_text("", encoding="utf-8")
    UNATTENDED_LAST_LOG_FILE.chmod(0o644)

    return UNATTENDED_LAST_LOG_FILE


def _append_unattended_header(
    *,
    log_file: Path,
    mode: str,
    interval: str,
) -> None:
    """
    Write unattended run header.
    """

    _write_log_line(log_file, "Synex Package Manager unattended updates")
    _write_log_line(log_file, "=" * 60)
    _write_log_line(
        log_file,
        "Date: " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    )
    _write_log_line(log_file, f"Mode: {mode}")
    _write_log_line(log_file, f"Interval: {interval}")
    _write_log_line(log_file, "=" * 60)


def _run_unattended_command_sequence(
    commands: list[list[str]],
    log_file: Path,
) -> int:
    """
    Run unattended command sequence.
    """

    for command in commands:
        return_code = _run_command_streaming(
            command,
            log_file,
            append=True,
        )

        if return_code != 0:
            return return_code

    return 0


def _is_regular_user_for_flatpak(user_info: pwd.struct_passwd) -> bool:
    """
    Return True if a passwd entry looks like a real desktop user.
    """

    if user_info.pw_uid < 1000 or user_info.pw_uid >= 60000:
        return False

    if not user_info.pw_name:
        return False

    home = Path(user_info.pw_dir)

    if not home.exists() or not home.is_dir():
        return False

    return True


def _user_flatpak_installation_exists(user_info: pwd.struct_passwd) -> bool:
    """
    Return True if the user has a Flatpak user installation.
    """

    home = Path(user_info.pw_dir)

    return (home / ".local/share/flatpak").exists()


def _build_user_flatpak_update_commands() -> list[list[str]]:
    """
    Build Flatpak user update commands for real users.

    The unattended service runs as root. Running 'flatpak --user' directly as
    root would update root's user Flatpak installation, not the desktop user's
    installation. For that reason, user Flatpak updates are executed through
    runuser for each real user that has a user-level Flatpak installation.
    """

    if shutil.which("flatpak") is None:
        return []

    if shutil.which("runuser") is None:
        return []

    commands: list[list[str]] = []

    for user_info in pwd.getpwall():
        if not _is_regular_user_for_flatpak(user_info):
            continue

        if not _user_flatpak_installation_exists(user_info):
            continue

        commands.append(
            [
                "runuser",
                "-u",
                user_info.pw_name,
                "--",
                "env",
                f"HOME={user_info.pw_dir}",
                f"USER={user_info.pw_name}",
                f"LOGNAME={user_info.pw_name}",
                "flatpak",
                "--user",
                "update",
                "-y",
            ]
        )

    return commands


FLATHUB_FLATPAKREPO_URL = "https://dl.flathub.org/repo/flathub.flatpakrepo"


def _get_invoking_user_info() -> pwd.struct_passwd | None:
    """
    Return the real desktop user that invoked pkexec/sudo, when available.
    """

    uid_value = os.environ.get("PKEXEC_UID") or os.environ.get("SUDO_UID")

    if not uid_value:
        return None

    try:
        uid = int(uid_value)
    except ValueError:
        return None

    if uid <= 0:
        return None

    try:
        return pwd.getpwuid(uid)
    except KeyError:
        return None


def _build_user_flatpak_command(
    user_info: pwd.struct_passwd,
    flatpak_args: list[str],
) -> list[str]:
    """
    Build a flatpak --user command for the invoking desktop user.
    """

    env_args = [
        f"HOME={user_info.pw_dir}",
        f"USER={user_info.pw_name}",
        f"LOGNAME={user_info.pw_name}",
    ]

    runtime_dir = Path("/run/user") / str(user_info.pw_uid)

    if runtime_dir.exists():
        env_args.append(f"XDG_RUNTIME_DIR={runtime_dir}")

    return (
        [
            "runuser",
            "-u",
            user_info.pw_name,
            "--",
            "env",
        ]
        + env_args
        + [
            "flatpak",
        ]
        + flatpak_args
    )


def _append_log_section(log_file: Path, title: str) -> None:
    """
    Write a visible section separator to stdout and log.
    """

    section = "\n" + title.upper() + "\n" + "=" * 60 + "\n"

    print(section, end="", flush=True)

    with log_file.open("a", encoding="utf-8", errors="replace") as log:
        log.write(section)
        log.flush()


def _software_list_has_flatpaks(payload: dict) -> bool:
    """
    Return True if a validated software list contains Flatpak entries.
    """

    flatpak = payload.get("flatpak")

    if not isinstance(flatpak, dict):
        return False

    system_items = flatpak.get("system", [])
    user_items = flatpak.get("user", [])

    return bool(system_items or user_items)


def _run_flatpak_support_enable(args: list[str]) -> int:
    """
    Install Flatpak if needed and ensure Synex default Flathub remotes.

    This action is idempotent. It can be used both to enable Flatpak support
    for the first time and to repair missing Flathub remotes.
    """

    action = "flatpak-support-enable"

    if args:
        print("ERROR: flatpak-support-enable does not accept extra arguments.", file=sys.stderr)
        _usage()
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    commands: list[tuple[str, list[str]]] = []

    if shutil.which("flatpak") is None:
        commands.append(
            (
                "Refresh APT package lists",
                ["apt-get", "update"],
            )
        )
        commands.append(
            (
                "Install Flatpak package",
                ["apt-get", "-y", "install", "flatpak"],
            )
        )

    commands.extend(
        [
            (
                "Add system Flathub remote",
                [
                    "flatpak",
                    "--system",
                    "remote-add",
                    "--if-not-exists",
                    "flathub",
                    FLATHUB_FLATPAKREPO_URL,
                ],
            ),
            (
                "Add system Flathub Verified remote",
                [
                    "flatpak",
                    "--system",
                    "remote-add",
                    "--if-not-exists",
                    "--subset=verified",
                    "flathub-verified",
                    FLATHUB_FLATPAKREPO_URL,
                ],
            ),
        ]
    )

    user_info = _get_invoking_user_info()

    if user_info is not None:
        commands.extend(
            [
                (
                    "Add user Flathub remote",
                    _build_user_flatpak_command(
                        user_info,
                        [
                            "--user",
                            "remote-add",
                            "--if-not-exists",
                            "flathub",
                            FLATHUB_FLATPAKREPO_URL,
                        ],
                    ),
                ),
                (
                    "Add user Flathub Verified remote",
                    _build_user_flatpak_command(
                        user_info,
                        [
                            "--user",
                            "remote-add",
                            "--if-not-exists",
                            "--subset=verified",
                            "flathub-verified",
                            FLATHUB_FLATPAKREPO_URL,
                        ],
                    ),
                ),
            ]
        )
    else:
        _write_console_line(
            "WARNING: invoking user could not be detected. User Flatpak remotes will be skipped."
        )
        _write_log_line(
            log_file,
            "WARNING: invoking user could not be detected. User Flatpak remotes will be skipped.",
        )

    commands.append(
        (
            "Refresh system Flatpak AppStream metadata",
            [
                "flatpak",
                "--system",
                "update",
                "--appstream",
            ],
        )
    )

    if user_info is not None:
        commands.append(
            (
                "Refresh user Flatpak AppStream metadata",
                _build_user_flatpak_command(
                    user_info,
                    [
                        "--user",
                        "update",
                        "--appstream",
                    ],
                ),
            )
        )

    for label, command in commands:
        _append_log_section(log_file, label)

        return_code = _run_command_streaming(
            command,
            log_file,
            append=True,
        )

        if return_code != 0:
            return return_code

    _touch_system_refresh_marker()

    return 0



def _validate_software_list_package_list(
    values,
    field_name: str,
) -> list[str]:
    """
    Validate package names from a software list.
    """

    if not isinstance(values, list):
        raise ValueError(f"{field_name} must be a list.")

    packages: list[str] = []
    seen: set[str] = set()

    for value in values:
        if not isinstance(value, str):
            raise ValueError(f"{field_name} contains a non-string value.")

        package_name = _validate_package_name(value)

        if package_name in seen:
            continue

        packages.append(package_name)
        seen.add(package_name)

    return sorted(packages)


def _validate_software_list_flatpak_items(
    values,
    field_name: str,
) -> list[dict[str, str]]:
    """
    Validate Flatpak entries from a software list.
    """

    if not isinstance(values, list):
        raise ValueError(f"{field_name} must be a list.")

    items: dict[str, dict[str, str]] = {}

    for value in values:
        if not isinstance(value, dict):
            raise ValueError(f"{field_name} contains a non-object value.")

        app_id = str(value.get("id") or "").strip()
        origin = str(value.get("origin") or "").strip()

        if not FLATPAK_ID_RE.fullmatch(app_id):
            raise ValueError(f"Invalid Flatpak application ID in {field_name}: {app_id}")

        if not FLATPAK_REMOTE_RE.fullmatch(origin):
            raise ValueError(f"Invalid Flatpak origin in {field_name}: {origin}")

        items[app_id] = {
            "id": app_id,
            "origin": origin,
        }

    return [
        items[app_id]
        for app_id in sorted(items)
    ]


def _load_software_list_import_payload(source: Path) -> dict:
    """
    Load and validate a Synex software list for privileged import.

    Held packages are intentionally ignored here. If a held package is manually
    installed, it is already included in apt.manual_packages by export logic.
    """

    if not source.exists():
        raise ValueError(f"Software list does not exist: {source}")

    if not source.is_file():
        raise ValueError(f"Software list path is not a file: {source}")

    if source.stat().st_size > 8 * 1024 * 1024:
        raise ValueError("Software list file is too large.")

    with source.open("r", encoding="utf-8") as file:
        payload = json.load(file)

    if not isinstance(payload, dict):
        raise ValueError("Software list payload must be a JSON object.")

    if payload.get("format") != SOFTWARE_LIST_FORMAT:
        raise ValueError("Unsupported software list format.")

    if payload.get("format_version") != SOFTWARE_LIST_FORMAT_VERSION:
        raise ValueError("Unsupported software list format version.")

    apt = payload.get("apt")

    if not isinstance(apt, dict):
        raise ValueError("Software list is missing apt data.")

    flatpak = payload.get("flatpak")

    if not isinstance(flatpak, dict):
        raise ValueError("Software list is missing flatpak data.")

    return {
        "apt": {
            "manual_packages": _validate_software_list_package_list(
                apt.get("manual_packages", []),
                "apt.manual_packages",
            ),
        },
        "flatpak": {
            "system": _validate_software_list_flatpak_items(
                flatpak.get("system", []),
                "flatpak.system",
            ),
            "user": _validate_software_list_flatpak_items(
                flatpak.get("user", []),
                "flatpak.user",
            ),
        },
    }


def _software_list_apt_packages(payload: dict) -> list[str]:
    """
    Return APT packages requested by a validated software list.
    """

    apt = payload.get("apt", {})

    if not isinstance(apt, dict):
        return []

    packages = apt.get("manual_packages", [])

    if not isinstance(packages, list):
        return []

    return [
        str(package)
        for package in packages
        if isinstance(package, str) and package.strip()
    ]


def _software_list_flatpak_items(payload: dict, scope: str) -> list[dict[str, str]]:
    """
    Return Flatpak items for one scope from a validated software list.
    """

    flatpak = payload.get("flatpak", {})

    if not isinstance(flatpak, dict):
        return []

    items = flatpak.get(scope, [])

    if not isinstance(items, list):
        return []

    return [
        item
        for item in items
        if isinstance(item, dict)
    ]


def _apt_candidate_available(package_name: str) -> bool:
    """
    Return True when APT reports an installable candidate.
    """

    try:
        process = subprocess.run(
            ["apt-cache", "policy", package_name],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=_build_environment(),
            check=False,
        )
    except Exception:
        return False

    if process.returncode != 0:
        return False

    for line in process.stdout.splitlines():
        stripped = line.strip()

        if not stripped.startswith("Candidate:"):
            continue

        candidate = stripped.split(":", 1)[1].strip()

        return bool(candidate and candidate != "(none)")

    return False


def _apt_package_installed(package_name: str) -> bool:
    """
    Return True when dpkg reports the package as installed.
    """

    try:
        process = subprocess.run(
            [
                "dpkg-query",
                "-W",
                "-f=${Status}",
                package_name,
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            env=_build_environment(),
            check=False,
        )
    except Exception:
        return False

    return (
        process.returncode == 0
        and process.stdout.strip() == "install ok installed"
    )


def _software_list_available_apt_packages(
    payload: dict,
    log_file: Path,
) -> list[str]:
    """
    Return APT packages that are available and not already installed.

    Software list import is a deployment helper, not an upgrade operation.
    Already installed packages are intentionally skipped so locally held
    packages are not changed by a list import.
    """

    requested = _software_list_apt_packages(payload)
    packages_to_install: list[str] = []
    installed_count = 0
    missing_count = 0

    if not requested:
        _write_log_line(log_file, "No APT packages requested.")
        return []

    _write_log_line(log_file, f"APT packages requested: {len(requested)}")

    for package_name in requested:
        if _apt_package_installed(package_name):
            installed_count += 1
            continue

        if _apt_candidate_available(package_name):
            packages_to_install.append(package_name)
            continue

        missing_count += 1
        _write_log_line(
            log_file,
            f"WARNING: APT package is not available and will be skipped: {package_name}",
        )

    _write_log_line(log_file, f"APT packages already installed: {installed_count}")
    _write_log_line(log_file, f"APT packages available to install: {len(packages_to_install)}")
    _write_log_line(log_file, f"APT packages unavailable: {missing_count}")

    return packages_to_install

def _run_software_list_flatpak_install_command(
    label: str,
    command: list[str],
    log_file: Path,
) -> int:
    """
    Run one Flatpak install command with a visible log section.
    """

    _append_log_section(log_file, label)

    return _run_command_streaming(
        command,
        log_file,
        append=True,
    )


def _install_software_list_flatpaks(
    payload: dict,
    log_file: Path,
) -> int:
    """
    Install Flatpak applications from a validated software list.
    """

    failed_count = 0
    skipped_count = 0

    system_items = _software_list_flatpak_items(payload, "system")
    user_items = _software_list_flatpak_items(payload, "user")

    if not system_items and not user_items:
        _write_log_line(log_file, "No Flatpak applications requested.")
        return 0

    _write_log_line(log_file, f"System Flatpaks requested: {len(system_items)}")
    _write_log_line(log_file, f"User Flatpaks requested: {len(user_items)}")

    for item in system_items:
        app_id = str(item["id"])
        origin = str(item["origin"])

        if origin not in SOFTWARE_LIST_MANAGED_FLATPAK_REMOTES:
            skipped_count += 1
            _write_log_line(
                log_file,
                f"WARNING: unmanaged Flatpak remote skipped: {app_id} ({origin})",
            )
            continue

        return_code = _run_software_list_flatpak_install_command(
            f"Install system Flatpak {app_id}",
            [
                "flatpak",
                "--system",
                "install",
                "-y",
                origin,
                app_id,
            ],
            log_file,
        )

        if return_code != 0:
            failed_count += 1

    if user_items:
        user_info = _get_invoking_user_info()

        if user_info is None:
            skipped_count += len(user_items)
            _write_log_line(
                log_file,
                "WARNING: invoking user could not be detected. "
                "User Flatpak applications will be skipped.",
            )
        else:
            for item in user_items:
                app_id = str(item["id"])
                origin = str(item["origin"])

                if origin not in SOFTWARE_LIST_MANAGED_FLATPAK_REMOTES:
                    skipped_count += 1
                    _write_log_line(
                        log_file,
                        f"WARNING: unmanaged user Flatpak remote skipped: {app_id} ({origin})",
                    )
                    continue

                return_code = _run_software_list_flatpak_install_command(
                    f"Install user Flatpak {app_id}",
                    _build_user_flatpak_command(
                        user_info,
                        [
                            "--user",
                            "install",
                            "-y",
                            origin,
                            app_id,
                        ],
                    ),
                    log_file,
                )

                if return_code != 0:
                    failed_count += 1

    _write_log_line(log_file, f"Flatpak applications skipped: {skipped_count}")
    _write_log_line(log_file, f"Flatpak application install failures: {failed_count}")

    if failed_count:
        return 1

    return 0


def _run_software_list_import(args: list[str]) -> int:
    """
    Import a Synex software list.

    This intentionally restores package/application selections only:
    - manually installed APT packages;
    - system Flatpak applications;
    - user Flatpak applications.

    It does not restore repositories, exact versions or APT hold state.
    """

    action = "software-list-import"

    if len(args) != 1:
        print("ERROR: software-list-import requires SOFTWARE_LIST_JSON.", file=sys.stderr)
        _usage()
        return 2

    source = Path(args[0])
    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    try:
        payload = _load_software_list_import_payload(source)
    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 2

    _write_log_line(log_file, f"Software list source: {source}")

    apt_packages = _software_list_available_apt_packages(payload, log_file)

    if apt_packages:
        _append_log_section(log_file, "Install APT packages from software list")

        return_code = _run_command_streaming(
            ["apt-get", "-y", "--no-upgrade", "install"] + apt_packages,
            log_file,
            append=True,
        )

        if return_code != 0:
            return return_code

    if _software_list_has_flatpaks(payload):
        _append_log_section(
            log_file,
            "Enable or repair Flatpak support before importing Flatpaks",
        )

        flatpak_support_return_code = _run_flatpak_support_enable([])

        if flatpak_support_return_code != 0:
            _write_log_line(
                log_file,
                "ERROR: Could not enable or repair Flatpak support.",
            )
            return flatpak_support_return_code

        flatpak_return_code = _install_software_list_flatpaks(payload, log_file)

        if flatpak_return_code != 0:
            return flatpak_return_code
    else:
        _write_log_line(log_file, "No Flatpak applications requested.")

    _touch_system_refresh_marker()

    _write_log_line(log_file, "Software list import finished.")

    return 0



def _build_unattended_commands(mode: str) -> list[list[str]]:
    """
    Build command list for unattended updates.

    security:
        Uses Debian's unattended-upgrade mechanism.

    apt-safe:
        Uses the same conservative APT upgrade flow as SPM:
        apt-get --with-new-pkgs --no-remove upgrade.

    apt-safe-flatpak:
        Runs the conservative APT flow, updates system Flatpaks and then
        updates user-level Flatpak installations for real desktop users.
    """

    if mode == UNATTENDED_MODE_SECURITY:
        if shutil.which("unattended-upgrade") is None:
            raise RuntimeError(
                "unattended-upgrade is not installed. "
                "Install the unattended-upgrades package to use security mode."
            )

        return [
            ["apt-get", "update"],
            ["unattended-upgrade"],
        ]

    commands = [
        ["apt-get", "update"],
        ["apt-get", "-y", "--with-new-pkgs", "--no-remove", "upgrade"],
    ]

    if mode == UNATTENDED_MODE_APT_SAFE_FLATPAK and shutil.which("flatpak") is not None:
        commands.append(["flatpak", "--system", "update", "-y"])
        commands.extend(_build_user_flatpak_update_commands())

    return commands

def _run_unattended_updates(args: list[str]) -> int:
    """
    Run unattended updates according to configuration.
    """

    action = "unattended-run"

    if args:
        print("ERROR: unattended-run does not accept extra arguments.", file=sys.stderr)
        _usage()
        return 2

    config = _read_unattended_config()
    enabled = config.get("ENABLED", "false").strip().lower() in {
        "1",
        "true",
        "yes",
        "on",
        "enabled",
    }
    mode = config.get("MODE", UNATTENDED_MODE_SECURITY).strip().lower()
    interval = config.get("INTERVAL", "24h").strip() or "24h"

    log_file = _prepare_unattended_last_log()
    _append_unattended_header(
        log_file=log_file,
        mode=mode,
        interval=interval,
    )

    if not enabled:
        _write_log_line(log_file, "Unattended updates are disabled. Nothing to do.")
        return 0

    try:
        mode = _validate_unattended_mode(mode)
        commands = _build_unattended_commands(mode)
    except Exception as error:
        _write_log_line(log_file, f"ERROR: {error}")
        return 1

    return_code = _run_unattended_command_sequence(commands, log_file)

    if return_code == 0:
        _touch_system_refresh_marker()
        _write_log_line(log_file, "Unattended update run finished successfully.")
    else:
        _write_log_line(
            log_file,
            f"Unattended update run finished with return code: {return_code}",
        )

    return return_code


def main() -> int:
    """
    Main entrypoint.
    """

    _ensure_root()

    if len(sys.argv) < 2:
        _usage()
        return 2

    action = sys.argv[1]
    args = sys.argv[2:]

    if action == "apt-package-transaction":
        return _run_package_transaction(args)

    if action == "apt-repositories-apply":
        return _run_repository_apply(args)

    if action == "apt-repository-delete":
        return _run_repository_delete(args)

    if action == "synex-repository-repair":
        return _run_synex_repository_repair(args)

    if action == "known-repository-add":
        return _run_known_repository_add(args)

    if action == "update-timer-set":
        return _run_update_timer_set(args)

    if action == "update-timer-reset":
        return _run_update_timer_reset(args)

    if action == "unattended-set":
        return _run_unattended_set(args)

    if action == "unattended-run":
        return _run_unattended_updates(args)

    if action == "logs-set":
        return _run_logs_set(args)

    if action == "logs-clean":
        return _run_logs_clean(args)

    if action == "local-deb-install":
        return _run_local_deb_install(args)

    if action == "flatpak-support-enable":
        return _run_flatpak_support_enable(args)

    if action == "software-list-import":
        return _run_software_list_import(args)

    if action == "apt-holds-apply":
        return _run_apt_holds_apply(args)

    try:
        command = _build_command(action, args)
    except Exception as error:
        print(f"ERROR: {error}", file=sys.stderr)
        _usage()
        return 2

    log_file = _prepare_log_file(action)
    _print_header(action, log_file)

    return_code = _run_command_streaming(command, log_file)

    if return_code == 0 and action in {
        "apt-update",
        "apt-upgrade-safe",
        "apt-package-transaction",
    }:
        _touch_system_refresh_marker()

    return return_code


if __name__ == "__main__":
    raise SystemExit(main())
