#!/usr/bin/env python3

"""Enable encrypted (commercial) DVD-Video playback.

Most retail discs are CSS encrypted. Debian cannot ship the decryption
library, so libdvdcss has to be built on the machine from the contrib
package libdvd-pkg. That needs the contrib component enabled, which this
does for either APT source format:

  classic  /etc/apt/sources.list and /etc/apt/sources.list.d/*.list
           deb http://deb.debian.org/debian/ trixie main non-free-firmware

  deb822   /etc/apt/sources.list.d/*.sources
           Components: main non-free-firmware

Only existing Debian entries are edited, and only by adding the missing
component, so running this repeatedly cannot produce a duplicate
repository or a duplicate component.

  sudo classroom-dvd-css-setup       # set this machine up now
  classroom-dvd-css-setup --status   # report without changing anything

Unencrypted discs — church curriculum, home-burned video — play without
any of this.
"""

import argparse
import glob
import os
import re
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
    0,
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)

from classroom_config import enabled, read_device_config  # noqa: E402

# Tests point this at a fixture tree instead of the live system.
APT_ROOT = Path(os.environ.get("CLASSROOM_APT_ROOT", "/"))

COMPONENT = "contrib"

# A Debian entry is one served from debian.org or from a path with a
# /debian segment, which covers local mirrors. Deliberately narrow: the
# Google Chrome repository must never be touched.
DEBIAN_URI = re.compile(
    r"^\w+://[^/]*debian\.org(/|$)|^\w+://[^/]*/[^ ]*\bdebian\b",
    re.IGNORECASE,
)

CLASSIC_LINE = re.compile(
    r"^(?P<indent>\s*)"
    r"(?P<type>deb|deb-src)"
    r"(?P<options>\s+\[[^\]]*\])?"
    r"(?P<gap>\s+)"
    r"(?P<body>[^#]*?)"
    r"(?P<comment>\s*#.*)?$"
)


def log(message):
    print(f"[classroom-dvd-css-setup] {message}", flush=True)


def sources_list():
    return APT_ROOT / "etc/apt/sources.list"


def sources_directory():
    return APT_ROOT / "etc/apt/sources.list.d"


def is_debian(uri):
    return bool(DEBIAN_URI.match(uri))


def rewrite_classic(text):
    """Add contrib to Debian entries in a classic sources.list.

    Returns the new text, whether anything changed, and whether a Debian
    entry already carried the component.
    """
    lines = text.splitlines(keepends=True)
    changed = False
    present = False

    for index, line in enumerate(lines):
        ending = "\n" if line.endswith("\n") else ""
        match = CLASSIC_LINE.match(line.rstrip("\n"))

        if not match:
            continue

        fields = match.group("body").split()

        # uri, suite, then one or more components. A flat repository
        # (no components) has nothing to add.
        if len(fields) < 3:
            continue

        uri = fields[0]
        components = fields[2:]

        if not is_debian(uri):
            continue

        if COMPONENT in components:
            present = True
            continue

        if "main" not in components:
            continue

        components.append(COMPONENT)

        lines[index] = "".join(
            [
                match.group("indent"),
                match.group("type"),
                match.group("options") or "",
                match.group("gap"),
                " ".join([uri, fields[1]] + components),
                match.group("comment") or "",
                ending,
            ]
        )
        changed = True

    return "".join(lines), changed, present


def rewrite_deb822(text):
    """Add contrib to the Components of Debian stanzas in a .sources file.

    Returns the new text, whether anything changed, and whether a Debian
    stanza already carried the component.
    """
    lines = text.splitlines(keepends=True)
    changed = False
    present = False

    # Stanza boundaries are blank lines.
    stanza_start = 0
    stanzas = []

    for index, line in enumerate(lines + ["\n"]):
        if line.strip():
            continue

        if index > stanza_start:
            stanzas.append((stanza_start, index))

        stanza_start = index + 1

    for start, end in stanzas:
        uris = None
        components_index = None

        for index in range(start, end):
            field, separator, value = lines[index].partition(":")

            if not separator:
                continue

            key = field.strip().lower()

            if key == "uris":
                uris = value.split()
            elif key == "components":
                components_index = index

        if components_index is None or not uris:
            continue

        if not any(is_debian(uri) for uri in uris):
            continue

        field, _, value = lines[components_index].partition(":")
        components = value.split()

        if COMPONENT in components:
            present = True
            continue

        if "main" not in components:
            continue

        components.append(COMPONENT)

        ending = "\n" if lines[components_index].endswith("\n") else ""
        lines[components_index] = f"{field}: {' '.join(components)}{ending}"
        changed = True

    return "".join(lines), changed, present


def source_files():
    """Every APT source file, newest format first."""
    found = []

    directory = sources_directory()

    if directory.is_dir():
        found.extend(sorted(directory.glob("*.sources")))
        found.extend(sorted(directory.glob("*.list")))

    if sources_list().is_file():
        found.append(sources_list())

    return found


def enable_contrib():
    """Enable contrib in the existing Debian sources. Idempotent."""
    touched = []

    for path in source_files():
        try:
            original = path.read_text()
        except OSError as error:
            log(f"skipping {path}: {error}")
            continue

        if path.suffix == ".sources":
            updated, changed, _ = rewrite_deb822(original)
        else:
            updated, changed, _ = rewrite_classic(original)

        if not changed:
            continue

        path.write_text(updated)
        touched.append(path)

    if touched:
        for path in touched:
            log(f"enabled {COMPONENT} in {path}")
    else:
        log(f"{COMPONENT} was already enabled")

    return touched


def contrib_available():
    """True when some Debian entry already carries the contrib component."""
    for path in source_files():
        try:
            text = path.read_text()
        except OSError:
            continue

        if path.suffix == ".sources":
            _, _, present = rewrite_deb822(text)
        else:
            _, _, present = rewrite_classic(text)

        if present:
            return True

    return False


def libdvdcss_installed():
    # ldconfig lives in /sbin, which is not on an ordinary user's PATH.
    for candidate in ("/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"):
        try:
            result = subprocess.run(
                [candidate, "-p"],
                capture_output=True,
                text=True,
            )
        except OSError:
            continue

        return "libdvdcss.so.2" in result.stdout

    return any(
        Path(directory, "libdvdcss.so.2").exists()
        for directory in ("/usr/lib/x86_64-linux-gnu", "/usr/lib", "/lib")
    )


def apt(*arguments):
    environment = dict(os.environ)
    environment["DEBIAN_FRONTEND"] = "noninteractive"

    return subprocess.run(
        [
            "apt-get",
            # An OTA install or the update timer may hold the lock.
            # Waiting is far better than failing the provisioning run.
            "-o",
            "DPkg::Lock::Timeout=600",
            *arguments,
        ],
        env=environment,
    ).returncode


def preseed():
    """Answer libdvd-pkg's debconf questions so nothing blocks on a prompt."""
    answers = "\n".join(
        [
            "libdvd-pkg libdvd-pkg/build boolean true",
            "libdvd-pkg libdvd-pkg/post-invoke_hook-install boolean true",
            "libdvd-pkg libdvd-pkg/post-invoke_hook-remove boolean true",
        ]
    )

    subprocess.run(
        ["debconf-set-selections"],
        input=answers + "\n",
        text=True,
    )


def install():
    log("updating package lists")

    if apt("update") != 0:
        log("apt-get update failed; is this machine online?")
        return 1

    preseed()

    log("installing libdvd-pkg")

    if apt("install", "-y", "libdvd-pkg") != 0:
        log("could not install libdvd-pkg")
        return 1

    log("building libdvdcss")

    environment = dict(os.environ)
    environment["DEBIAN_FRONTEND"] = "noninteractive"

    subprocess.run(
        ["dpkg-reconfigure", "-f", "noninteractive", "libdvd-pkg"],
        env=environment,
    )

    if not libdvdcss_installed():
        log("libdvdcss was not built; commercial discs will not play")
        return 1

    log("libdvdcss installed; commercial discs should now play")
    return 0


def status():
    print(f"contrib enabled: {contrib_available()}")
    print(f"libdvdcss:       {'installed' if libdvdcss_installed() else 'missing'}")

    config = read_device_config()
    print(f"optical drive:   {', '.join(sorted(glob.glob('/dev/sr[0-9]*'))) or 'none'}")

    return 0 if libdvdcss_installed() else 1


def main():
    parser = argparse.ArgumentParser(
        description="Enable commercial DVD playback on this classroom.",
    )
    parser.add_argument(
        "--status",
        action="store_true",
        help="Report the current state without changing anything",
    )
    parser.add_argument(
        "--enable-contrib-only",
        action="store_true",
        help="Only enable the contrib component, install nothing",
    )
    parser.add_argument(
        "--if-enabled",
        action="store_true",
        help="Do nothing unless this machine has an optical drive",
    )
    arguments = parser.parse_args()

    if arguments.status:
        return status()

    if arguments.if_enabled:
        # Ask the machine rather than a setting. A computer that moves
        # between rooms takes its drive with it, and nobody remembers to
        # edit a file when it does.
        if not glob.glob("/dev/sr[0-9]*"):
            log("this classroom has no optical drive; nothing to do")
            return 0

    # Only the real system needs privilege. Pointed at a fixture tree
    # through CLASSROOM_APT_ROOT, this is just a file rewrite.
    if APT_ROOT == Path("/") and os.geteuid() != 0:
        log("must be run as root: sudo classroom-dvd-css-setup")
        return 1

    if arguments.enable_contrib_only:
        enable_contrib()
        return 0

    if libdvdcss_installed():
        log("libdvdcss is already installed; nothing to do")
        return 0

    enable_contrib()

    return install()


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