#!/usr/bin/env python3

"""Put the classroom's wallpaper on every desktop the shell has.

Tiny Pebbles came up after the 0.2.2 -> 0.2.6.4 upgrade on Plasma's own
purple wallpaper, with the right panel and the right package installed.
Its desktop was containment 10, with no Wallpaper group at all:

    [Containments][10]
    plugin=org.kde.plasma.folder
    wallpaperplugin=org.kde.image
    [Containments][10][General]
    positions={"1920x1080":[]}

The layout script writes the wallpaper, but only onto the desktops that
exist while it runs. Plasma makes a desktop for every screen, and every
activity, that appears afterwards — and it makes them without the layout.
0.2.2's updater rebooted at three in the morning with the television
switched off at the set, so the layout ran with no screen and no desktop
at all, and the desktop Plasma made when the television came on was
bare. Reproduced in the lab through a new activity, which takes the same
path through the shell: containment 11, the same four lines, Plasma's
wallpaper.

Two things now close that. The look and feel's default wallpaper is a
real wallpaper package, so a desktop Plasma makes on its own comes up
with the classroom's picture — that is the fix, and it needs nothing to
run. And this, which writes the picture into every desktop the file
already has, so that the configuration says what the screen shows.

WHICH CONTAINMENT IS THE DESKTOP is answered by what it is, never by its
number: a top-level containment whose plugin is one of Plasma's desktop
containments. Tiny Pebbles' desktop was 10 and River Kids' is 1, and the
panel's number is whatever was free when it was built.

ONLY THE DESKTOPS ARE TOUCHED. Every panel, applet and line that is not a
desktop's wallpaper is written back byte for byte, and a file that is
already right is not written at all.

WHEN IT RUNS. Before plasmashell starts, as the shell's own ExecStartPre,
because the shell owns this file: editing it underneath a live shell was
measured to destroy the panel. That is every start — a login, a reboot,
and the restart after a crash — and at a login it usually finds nothing,
because classroom-desktop-reset has just removed the file for the layout
to rebuild. Run by hand, it refuses while the shell is running and says
how to apply it. Live D-Bus scripting is not an option: the classroom
locks the desktop and forbids the scripting console, and Plasma answers
"Widgets are locked" — measured.

    classroom-desktop-wallpaper            repair, if the shell is stopped
    classroom-desktop-wallpaper --check    say what is wrong, change nothing
    classroom-desktop-wallpaper FILE       work on FILE instead
"""

import os
import re
import sys

WALLPAPER = "/usr/share/backgrounds/ekklesia/classroom-calm.png"

WALLPAPER_PLUGIN = "org.kde.image"

# Plasma's two desktop containments: Folder View, which is what the
# classroom's desktops are, and the plain Desktop. Panels, the system tray
# a 0.2.2-era profile may still carry and anything else are not desktops.
DESKTOP_PLUGINS = ("org.kde.plasma.folder", "org.kde.desktopcontainment")

# What the layout writes onto a desktop, so a repaired desktop and a
# freshly built one say the same thing.
WALLPAPER_KEYS = (
    ("FillMode", "2"),
    ("Image", f"file://{WALLPAPER}"),
)

APPLETSRC = "plasma-org.kde.plasma.desktop-appletsrc"

CONTAINMENT = re.compile(r"^\[Containments\]\[(\d+)\]$")

# key=value, with KConfig's optional [$i]-style flags or [locale] after
# the key. The flags are kept when a value is replaced.
ENTRY = re.compile(r"^([^=\[\s]+)((?:\[[^\]]*\])*)=(.*)$")


def say(message):
    print(f"classroom-desktop-wallpaper: {message}", flush=True)


def groups(lines):
    """[(name, header, end)]: lines[header] is the group's header line, or
    -1 for the lines before the first group, and end is exclusive."""
    found = []
    name, header = None, -1

    for index, line in enumerate(lines):
        stripped = line.strip()

        if stripped.startswith("[") and stripped.endswith("]"):
            found.append((name, header, index))
            name, header = stripped, index

    found.append((name, header, len(lines)))
    return found


def find_group(lines, name):
    for group, header, end in groups(lines):
        if group == name:
            return header, end

    return None


def entry(line):
    match = ENTRY.match(line.rstrip("\n"))
    return (match.group(1), match.group(2), match.group(3)) if match else None


def desktops(lines):
    """The ids of the desktop containments, whatever they are numbered."""
    ids = []

    for name, header, end in groups(lines):
        match = CONTAINMENT.match(name or "")

        if not match:
            continue

        for line in lines[header + 1:end]:
            parsed = entry(line)

            if parsed and parsed[0] == "plugin" and parsed[2] in DESKTOP_PLUGINS:
                ids.append(match.group(1))
                break

    return ids


def set_entry(lines, group, key, value):
    """Make `key=value` true in an existing group. Returns what changed,
    or None when it already was."""
    header, end = find_group(lines, group)
    last = header

    for index in range(header + 1, end):
        parsed = entry(lines[index])

        if lines[index].strip():
            last = index

        if parsed and parsed[0] == key:
            if parsed[2] == value:
                return None

            lines[index] = f"{key}{parsed[1]}={value}\n"
            return f"{key} was [{parsed[2]}]"

    lines.insert(last + 1, f"{key}={value}\n")
    return f"{key} was missing"


def add_group(lines, containment, group):
    """A new group, placed after the last of its containment's own."""
    prefix = f"[Containments][{containment}]"
    after = None

    for name, header, end in groups(lines):
        if name == prefix or (name or "").startswith(prefix + "["):
            after = end

    # Back over the blank lines that separate it from the next group, so
    # the new one sits with its containment and the spacing stays as
    # KConfig writes it.
    while after > 0 and not lines[after - 1].strip():
        after -= 1

    if after > 0 and not lines[after - 1].endswith("\n"):
        lines[after - 1] += "\n"

    block = ["\n", f"{group}\n"]
    block += [f"{key}={value}\n" for key, value in WALLPAPER_KEYS]
    lines[after:after] = block


def converge(text):
    """The file as it should be, and what was changed to make it so."""
    lines = text.splitlines(keepends=True)
    changes = []

    for containment in desktops(lines):
        top = f"[Containments][{containment}]"
        change = set_entry(lines, top, "wallpaperplugin", WALLPAPER_PLUGIN)

        if change:
            changes.append(f"desktop {containment}: {change}")

        group = f"{top}[Wallpaper][{WALLPAPER_PLUGIN}][General]"

        if find_group(lines, group) is None:
            add_group(lines, containment, group)
            changes.append(f"desktop {containment}: had no wallpaper configured")
            continue

        for key, value in WALLPAPER_KEYS:
            change = set_entry(lines, group, key, value)

            if change:
                changes.append(f"desktop {containment}: {change}")

    return "".join(lines), changes


def default_path():
    home = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    return os.path.join(home, APPLETSRC)


def shell_running(proc="/proc"):
    """plasmashell running as this user. Read from /proc rather than asked
    of systemd, so a shell started any way at all counts, and rather than
    of pgrep, which nothing guarantees is installed."""
    uid = os.getuid()

    for pid in os.listdir(proc):
        if not pid.isdigit():
            continue

        try:
            with open(os.path.join(proc, pid, "comm"), encoding="utf-8") as handle:
                name = handle.read().strip()

            if name == "plasmashell" and os.stat(os.path.join(proc, pid)).st_uid == uid:
                return True
        except OSError:
            # Gone while it was being looked at.
            continue

    return False


def write(path, text):
    """Replace the file whole, so a full disk or a power cut leaves the old
    one rather than half of a new one."""
    temporary = f"{path}.classroom-new"
    mode = os.stat(path).st_mode & 0o7777

    with open(temporary, "w", encoding="utf-8") as handle:
        handle.write(text)
        handle.flush()
        os.fsync(handle.fileno())

    os.chmod(temporary, mode)
    os.replace(temporary, path)


def main(argv):
    check = "--check" in argv
    paths = [arg for arg in argv if not arg.startswith("--")]
    path = paths[0] if paths else default_path()

    try:
        with open(path, "r", encoding="utf-8") as handle:
            before = handle.read()
    except FileNotFoundError:
        say("no desktop configuration yet; the layout writes the wallpaper")
        return 0
    except OSError as error:
        say(f"cannot read {path}: {error}")
        return 0

    after, changes = converge(before)
    found = desktops(before.splitlines(keepends=True))

    if not found:
        say("no desktop in the configuration yet; Plasma's default is the "
            "classroom's wallpaper package")
        return 0

    if not changes:
        say(f"desktop {', '.join(found)}: the classroom wallpaper already")
        return 0

    for change in changes:
        say(change)

    if check:
        say("not changed (--check)")
        return 1

    # The shell owns the live file. Anything else is just a file.
    if not paths and shell_running():
        say("plasmashell is running, and editing its file underneath it")
        say("destroys the panel. Apply it with:")
        say("    systemctl --user restart plasma-plasmashell.service")
        say("which repairs the file before the shell starts again.")
        return 1

    try:
        write(path, after)
    except OSError as error:
        say(f"cannot write {path}: {error}")
        return 1

    say(f"the wallpaper is now configured on desktop {', '.join(found)}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
