#!/usr/bin/python3

# sd-debian: Software-defined Debian
# Copyright (C) 2024-2026 Daniel Baumann <daniel@debian.org>
# Copyright (C) 2024-2026 Patrik Dufresne <patrik@ikus-soft.com>
#
# SPDX-License-Identifier: GPL-3.0+
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import errno
import fcntl
import getpass
import logging
import os
import subprocess
import sys
import time
from pathlib import Path

VARDIR = Path("/var/lib/sd-debian")
RUNDIR = Path("/run/sd-debian")
LOCKFILE = RUNDIR / "dpkg_status-logger.lock"
LOGDIR = Path("/var/log/sd-debian")
LOGFILE = LOGDIR / "sd-debian.log"

SD_DEBIAN_BIN = Path("/usr/bin/sd-debian")

LOGUSER = getpass.getuser()

MAX_WAIT = 60

logger = logging.getLogger("apt_dpkg-post-invoke")


def setup_logger(path: Path):
    """
    Configure logging system to write DEBUG+ to the log file and
    INFO+ to stdout (mirroring apt's interactive behavior).
    """
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
    except OSError as e:
        print(
            f"[WARNING]: could not create log directory '{path.parent}': {e}, aborting\n",
            file=sys.stderr,
        )
        return

    if path.is_dir():
        print(
            f"[WARNING]: log path can not be a directory '{path}', aborting",
            file=sys.stderr,
        )
        return

    writable = (path.exists() and os.access(path, os.W_OK)) or (
        not path.exists() and os.access(path.parent, os.W_OK)
    )
    if not writable:
        print(
            f"[WARNING]: log file at '{path}' is not writeable and we cannot create it, aborting\n",
            file=sys.stderr,
        )
        return

    file_handler = logging.FileHandler(path)
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(
        logging.Formatter(
            f"%(asctime)s p=%(process)d u={LOGUSER} n=%(name)s %(levelname)s| %(message)s"
        )
    )

    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(logging.INFO)
    console_handler.setFormatter(logging.Formatter("%(message)s"))

    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    root.addHandler(file_handler)
    root.addHandler(console_handler)


def wait_for_dpkg_status_logger(timeout: int = MAX_WAIT):
    """
    Block until the lock held by dpkg_status-logger (if any) is released,
    or until timeout expires.
    """
    logger.debug("waiting for all dpkg_status-logger processes to terminate...")

    lock_fd = os.open(LOCKFILE, os.O_CREAT | os.O_RDWR)

    deadline = time.monotonic() + timeout
    while True:
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)
            return
        except OSError as e:
            if e.errno not in (errno.EACCES, errno.EAGAIN):
                raise
            if time.monotonic() >= deadline:
                logger.warning("timed out waiting for dpkg_status-logger lock")
                os.close(lock_fd)
                return
            time.sleep(0.2)


def build_args() -> list:
    """
    Build the command arguments dynamically, only including files that
    exist and are non-empty.
    """
    args = []

    for option, path in (
        ("--install", VARDIR / "install"),
        ("--remove", VARDIR / "remove"),
        ("--purge", VARDIR / "purge"),
    ):
        if path.exists() and path.stat().st_size > 0:
            args += [option, f"@{path}"]

    return args


def main() -> int:
    setup_logger(LOGFILE)

    wait_for_dpkg_status_logger()

    args = build_args()

    if not args:
        logger.info(
            "sd-debian is skipped because nothing has been installed, deleted or purged."
        )
        return 0

    # Declare this magic environment variable to skip apt related tasks.
    env = os.environ.copy()
    env["SDD_APT_LOCKED"] = "true"

    cmd = [str(SD_DEBIAN_BIN), "run"] + args
    logger.debug(f"executing {' '.join(cmd)}")

    try:
        result = subprocess.run(cmd, env=env)
    except FileNotFoundError:
        logger.info(
            f"sd-debian is missing: {SD_DEBIAN_BIN} not found. "
            "Install the 'sd-debian' package to enable this hook."
        )
        return 0
    except PermissionError:
        logger.info(
            f"sd-debian is not executable: {SD_DEBIAN_BIN} lacks execute permission. "
            f"Run 'chmod +x {SD_DEBIAN_BIN}' to enable this hook."
        )
        return 0

    status = result.returncode

    if status == 0:
        for name in ("install", "remove", "purge"):
            (VARDIR / name).unlink(missing_ok=True)
    else:
        logger.warning(
            f"sd-debian failed with status {status}, keeping state files for retry"
        )

    return status


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