#!/usr/bin/env python3

"""Classroom window control.

Sway has no real minimise concept, so the classroom uses a dedicated
hidden workspace. A minimised window is parked there as an ordinary
tiled container and moved back to the visible workspace on restore,
which is why it returns maximised instead of floating the way the old
scratchpad approach did.
"""

import argparse
import json
import os
import re
import subprocess
import sys

HIDDEN_WORKSPACE = "__ekklesia_hidden"

STATE_DIR = os.path.join(
    os.environ.get("XDG_RUNTIME_DIR") or "/tmp",
    "ekklesia-classroom",
)
STATE_FILE = os.path.join(STATE_DIR, "minimized")


def swaymsg(*arguments):
    """Run swaymsg, returning stdout or None when sway is unavailable."""
    try:
        result = subprocess.run(
            ["swaymsg", *arguments],
            capture_output=True,
            text=True,
        )
    except (OSError, subprocess.SubprocessError):
        return None

    if result.returncode != 0:
        return None

    return result.stdout


def command(argument):
    """Run a sway command, ignoring criteria that match nothing."""
    swaymsg("--", argument)


def query(kind):
    output = swaymsg("-t", kind, "-r")

    if not output:
        return None

    try:
        return json.loads(output)
    except ValueError:
        return None


def collect(node, workspace, found):
    """Flatten the sway tree into the windows the classroom cares about."""
    if node.get("type") == "workspace":
        workspace = node.get("name")

    is_window = node.get("pid") is not None and not node.get("nodes")

    if is_window:
        properties = node.get("window_properties") or {}

        found.append(
            {
                "id": node.get("id"),
                "app_id": node.get("app_id") or "",
                "class": properties.get("class") or "",
                "instance": properties.get("instance") or "",
                "focused": bool(node.get("focused")),
                "workspace": workspace,
                "hidden": workspace == HIDDEN_WORKSPACE,
            }
        )

    for child in node.get("nodes", []) + node.get("floating_nodes", []):
        collect(child, workspace, found)

    return found


def windows():
    tree = query("get_tree")

    if tree is None:
        return []

    return collect(tree, None, [])


def matches(window, patterns):
    """Match a window against app_id / X11 class / X11 instance."""
    identities = [
        window["app_id"],
        window["class"],
        window["instance"],
    ]

    for pattern in patterns:
        expression = re.compile(pattern, re.IGNORECASE)

        for identity in identities:
            if identity and expression.fullmatch(identity):
                return True

    return False


def visible_workspace():
    """The workspace a restored window should come back to."""
    workspaces = query("get_workspaces") or []

    for workspace in workspaces:
        if workspace.get("focused") and workspace.get("name") != HIDDEN_WORKSPACE:
            return workspace.get("name")

    for workspace in workspaces:
        if workspace.get("visible") and workspace.get("name") != HIDDEN_WORKSPACE:
            return workspace.get("name")

    return "1"


def read_state():
    try:
        with open(STATE_FILE, "r", encoding="utf-8") as handle:
            return [int(line) for line in handle.read().split() if line.strip()]
    except (OSError, ValueError):
        return []


def write_state(identifiers):
    try:
        os.makedirs(STATE_DIR, exist_ok=True)

        with open(STATE_FILE, "w", encoding="utf-8") as handle:
            handle.write("\n".join(str(item) for item in identifiers))
    except OSError:
        pass


def remember(identifier):
    """Track minimise order so restore-last picks the newest window."""
    state = [item for item in read_state() if item != identifier]
    state.append(identifier)
    write_state(state[-32:])


def forget(identifier):
    write_state([item for item in read_state() if item != identifier])


def minimize(window):
    # A fullscreen container keeps rendering over everything else if it
    # is parked while still fullscreen, so always drop out of it first.
    command(f'[con_id={window["id"]}] fullscreen disable')
    command(f'[con_id={window["id"]}] move container to workspace {HIDDEN_WORKSPACE}')

    remember(window["id"])


def restore(window):
    workspace = visible_workspace()

    command(f'[con_id={window["id"]}] move container to workspace {workspace}')

    # Windows parked by older classroom builds came back floating because
    # they had been through the scratchpad. Force them back into the tiling
    # layout so a restored browser is maximised like the user expects.
    command(f'[con_id={window["id"]}] floating disable')
    command(f'[con_id={window["id"]}] focus')

    forget(window["id"])


def launch(argv):
    if not argv:
        return

    try:
        subprocess.Popen(argv, start_new_session=True)
    except OSError as error:
        print(f"classroom-window: unable to launch {argv[0]}: {error}", file=sys.stderr)


def action_toggle(patterns, argv):
    """Launch, focus, minimise or restore, in the order a user expects."""
    candidates = [window for window in windows() if matches(window, patterns)]

    if not candidates:
        launch(argv)
        return 0

    hidden = [window for window in candidates if window["hidden"]]

    if hidden:
        for window in hidden:
            restore(window)
        return 0

    if any(window["focused"] for window in candidates):
        for window in candidates:
            minimize(window)
        return 0

    command(f'[con_id={candidates[-1]["id"]}] focus')
    return 0


def action_minimize():
    for window in windows():
        if window["focused"] and not window["hidden"]:
            minimize(window)
            return 0

    return 0


def action_restore_last():
    hidden = {window["id"]: window for window in windows() if window["hidden"]}

    if not hidden:
        return 0

    for identifier in reversed(read_state()):
        if identifier in hidden:
            restore(hidden[identifier])
            return 0

    restore(list(hidden.values())[-1])
    return 0


def action_list():
    for window in windows():
        state = "minimized" if window["hidden"] else "visible"
        state = "focused" if window["focused"] else state

        print(
            f'{window["id"]}\t{state}\t'
            f'{window["app_id"] or window["class"]}\t{window["workspace"]}'
        )

    return 0


def main():
    parser = argparse.ArgumentParser(
        description="Minimise, restore and toggle classroom windows.",
    )
    subparsers = parser.add_subparsers(dest="action", required=True)

    toggle = subparsers.add_parser(
        "toggle",
        help="Launch, focus, minimise or restore an application",
    )
    toggle.add_argument(
        "--match",
        action="append",
        required=True,
        metavar="REGEX",
        help="app_id or X11 class to match (may be repeated)",
    )
    toggle.add_argument(
        "launch",
        nargs=argparse.REMAINDER,
        help="Command to run when the application is not running",
    )

    subparsers.add_parser("minimize", help="Minimise the focused window")
    subparsers.add_parser("restore-last", help="Restore the newest minimised window")
    subparsers.add_parser("list", help="List classroom windows and their state")

    arguments = parser.parse_args()

    if arguments.action == "toggle":
        argv = arguments.launch

        if argv and argv[0] == "--":
            argv = argv[1:]

        return action_toggle(arguments.match, argv)

    if arguments.action == "minimize":
        return action_minimize()

    if arguments.action == "restore-last":
        return action_restore_last()

    return action_list()


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