#!/usr/bin/env python3
"""Standalone floating overlay panel showing the Bernina cheat sheet,
generated fresh from cheatsheet.md every time it's shown (via
cheatsheet_to_shortcuts_json) -- no gnome-shell extension involved, so
there's no "brand-new extension not recognized until Shell restarts"
risk: this is just a normal process, like any other keybinding here.

Toggle behavior: running this while an instance is already showing closes
it instead of opening a second one (tracked via a PID file). That PID file
is verified against /proc before ever being trusted or signaled -- on a
host where its directory isn't cleared across reboots (confirmed: some
hosts' /tmp isn't, which the PID file falls back to when XDG_RUNTIME_DIR
isn't set), a stale PID number could otherwise get reused by a completely
unrelated process after reboot.

Dismiss: Escape, the close button, or pressing the trigger key again.

Sub-sheets: a shortcut entry with a "link" field (see
cheatsheet_to_shortcuts_json -- a markdown link in a table's first column,
e.g. `[Admin & Account Setup](cheatsheet_admin.md)`) renders as a clickable
link instead of a plain shortcut. Clicking it loads and displays that file
in place of the current one, with a "< Back" affordance to return; the
"Search all sheets" checkbox instead loads the whole reachable set (root
plus everything linked from it, however deep) into one combined, filterable
view. Both always re-read from disk at the moment they're needed -- nothing
is cached across a click or a checkbox toggle, matching the top-level
per-invocation freshness described in wait_for_source() below -- so editing
any of these .md files takes effect immediately, no restart of anything
required.
"""
import json
import os
import signal
import subprocess
import sys
import tempfile
import time

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gdk, GLib, Gtk  # noqa: E402

MD_PATH = "/sf/bernina/bin/cheatsheet.md"
ROOT_TITLE = "Bernina Terminal Cheat Sheet"
GENERATOR = "/sf/bernina/bin/cheatsheet_to_shortcuts_json"
PID_FILE = os.path.join(
    os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()),
    "bernina-cheatsheet-overlay.pid",
)
MAX_COLUMNS = 3

CSS = b"""
window.cheatsheet-overlay {
    background-color: rgba(20, 22, 26, 0.94);
    border-radius: 14px;
    border: 1px solid rgba(255, 255, 255, 0.12);
}
.cs-titlebar {
    margin-bottom: 10px;
}
.cs-title {
    color: #ffffff;
    font-size: 18px;
    font-weight: 700;
}
.cs-close-btn {
    color: #e8eaed;
    background: transparent;
    border: none;
    box-shadow: none;
    padding: 2px 10px;
}
.cs-close-btn:hover {
    color: #ffffff;
    background-color: rgba(255, 255, 255, 0.15);
    border-radius: 6px;
}
.cs-back-btn {
    color: #8ab4f8;
    background: transparent;
    border: none;
    box-shadow: none;
    padding: 2px 10px;
}
.cs-back-btn:hover {
    color: #aecbfa;
    background-color: rgba(255, 255, 255, 0.1);
    border-radius: 6px;
}
.cs-category {
    color: #8ab4f8;
    font-size: 13px;
    font-weight: 700;
    margin-top: 10px;
    margin-bottom: 4px;
}
.cs-key {
    color: #ffe082;
    font-family: monospace;
    font-size: 12px;
}
.cs-link-btn {
    color: #8ab4f8;
    background: transparent;
    border: none;
    box-shadow: none;
    padding: 0;
}
.cs-link-btn:hover {
    color: #aecbfa;
}
.cs-link-btn label {
    text-decoration: underline;
    font-size: 12px;
}
.cs-desc {
    color: #e8eaed;
    font-size: 12px;
}
.cs-hint {
    color: #9aa0a6;
    font-size: 11px;
    margin-top: 10px;
}
.cs-search {
    background-color: rgba(255, 255, 255, 0.08);
    color: #ffffff;
    border: 1px solid rgba(255, 255, 255, 0.16);
}
.cs-search-all {
    color: #9aa0a6;
    font-size: 12px;
}
"""


def load_categories(path):
    """Runs the generator fresh against `path` every call -- no caching --
    so any edit to the source .md is picked up the moment it's needed,
    whether that's a fresh Super+F1 press or a link/checkbox click on an
    already-open overlay."""
    with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
        tmp_path = tmp.name
    try:
        subprocess.run([GENERATOR, path, tmp_path], check=True)
        with open(tmp_path, encoding="utf-8") as f:
            return json.load(f)
    finally:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass


def discover_sheets(root_path, root_label):
    """BFS from root_path following every "link" target reachable from it
    (however deep), re-reading each file fresh (see load_categories).
    Returns [(path, label, categories), ...], root first; a link target
    that fails to load (missing file, bad markdown) is skipped rather than
    aborting the whole walk, and an already-visited path (a cycle, or two
    sheets linking to the same target) is never re-queued.
    Link paths are resolved relative to the *linking* file's own
    directory, so a sub-sheet can itself link to siblings without needing
    the full path spelled out."""
    visited = set()
    order = []
    queue = [(os.path.normpath(root_path), root_label)]
    while queue:
        path, label = queue.pop(0)
        if path in visited:
            continue
        visited.add(path)
        try:
            categories = load_categories(path)
        except (subprocess.CalledProcessError, OSError, json.JSONDecodeError):
            continue
        order.append((path, label, categories))
        base_dir = os.path.dirname(path)
        for category in categories:
            for shortcut in category["shortcuts"]:
                link = shortcut.get("link")
                if link:
                    target = os.path.normpath(os.path.join(base_dir, link))
                    if target not in visited:
                        queue.append((target, shortcut["key"]))
    return order


def _is_our_process(pid):
    """True only if `pid` is actually still running *this* script -- not
    just any process that happens to exist. Without this check, a PID
    file surviving somewhere that isn't cleared across reboots (confirmed:
    /tmp isn't, on at least one host here) could have its old PID number
    reused by a completely unrelated process, which we'd otherwise
    mistakenly SIGTERM and treat as "still showing"."""
    try:
        with open(f"/proc/{pid}/cmdline", "rb") as f:
            return b"cheatsheet_overlay" in f.read()
    except OSError:
        return False


def already_showing():
    """If a live instance is already up, kill it and return True (caller
    should then just exit, giving Super+F1 toggle-to-close behavior)."""
    try:
        with open(PID_FILE, encoding="utf-8") as f:
            pid = int(f.read().strip())
        if _is_our_process(pid):
            os.kill(pid, signal.SIGTERM)
            os.unlink(PID_FILE)
            return True
        os.unlink(PID_FILE)
        return False
    except (FileNotFoundError, ValueError, ProcessLookupError):
        try:
            os.unlink(PID_FILE)
        except OSError:
            pass
        return False


def category_box(category, on_link):
    """Returns (box, rows) -- rows is [(key_widget, desc_label, search_text)],
    handed to _apply_filter so it can show/hide individual shortcuts (and,
    via the whole box, an entire category) without rebuilding anything.
    key_widget is a plain label for an ordinary shortcut, or a clickable
    link-styled button (calling on_link(target) when pressed) for an entry
    with a "link" field."""
    box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
    header = Gtk.Label(label=category["name"], xalign=0)
    header.get_style_context().add_class("cs-category")
    header.set_line_wrap(True)
    box.pack_start(header, False, False, 0)

    grid = Gtk.Grid(row_spacing=2, column_spacing=10)
    rows = []
    for row, shortcut in enumerate(category["shortcuts"]):
        link = shortcut.get("link")
        if link:
            key_widget = Gtk.Button(label=shortcut["key"])
            key_widget.get_style_context().add_class("cs-link-btn")
            key_widget.set_relief(Gtk.ReliefStyle.NONE)
            key_widget.connect(
                "clicked",
                lambda _b, target=link, label=shortcut["key"]: on_link(target, label),
            )
        else:
            key_widget = Gtk.Label(label=shortcut["key"], xalign=0)
            key_widget.get_style_context().add_class("cs-key")
            key_widget.set_line_wrap(True)
        desc_label = Gtk.Label(label=shortcut["description"], xalign=0)
        desc_label.get_style_context().add_class("cs-desc")
        desc_label.set_line_wrap(True)
        desc_label.set_max_width_chars(40)
        grid.attach(key_widget, 0, row, 1, 1)
        grid.attach(desc_label, 1, row, 1, 1)
        search_text = (shortcut["key"] + " " + shortcut["description"]).lower()
        rows.append((key_widget, desc_label, search_text))
    box.pack_start(grid, False, False, 0)
    return box, rows


def _apply_filter(query, entries):
    """entries: [(category_box, category_name_lower, rows), ...] from
    category_box() above. A category whose *name* matches shows all its
    shortcuts regardless of their own text (e.g. searching "gtile" shows
    the whole gTile category); otherwise each shortcut is matched
    individually against its own key + description text."""
    query = query.strip().lower()
    for cat_box, cat_name_lower, rows in entries:
        if not query:
            for key_widget, desc_label, _search_text in rows:
                key_widget.set_visible(True)
                desc_label.set_visible(True)
            cat_box.set_visible(True)
            continue
        cat_matches = query in cat_name_lower
        any_visible = False
        for key_widget, desc_label, search_text in rows:
            match = cat_matches or query in search_text
            key_widget.set_visible(match)
            desc_label.set_visible(match)
            any_visible = any_visible or match
        cat_box.set_visible(any_visible)


class Overlay:
    """Holds the mutable pieces that change as the user navigates between
    sheets or toggles "search all sheets", so link clicks and the checkbox
    can rebuild just the content area in place rather than tearing down
    and recreating the whole window."""

    def __init__(self):
        self.history = []       # stack of (path, label) visited via links
        self.current = (MD_PATH, ROOT_TITLE)
        self.search_all = False
        self.entries = []       # current [(box, name_lower, rows), ...]

    def build_chrome(self):
        self.win = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
        self.win.set_title(ROOT_TITLE)
        self.win.get_style_context().add_class("cheatsheet-overlay")
        self.win.set_decorated(False)
        self.win.set_position(Gtk.WindowPosition.CENTER)
        self.win.set_keep_above(True)
        self.win.set_skip_taskbar_hint(True)
        self.win.set_skip_pager_hint(True)
        self.win.set_type_hint(Gdk.WindowTypeHint.DIALOG)

        screen = self.win.get_screen()
        visual = screen.get_rgba_visual()
        if visual and screen.is_composited():
            self.win.set_visual(visual)

        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.set_border_width(18)
        outer.pack_start(self._title_bar(), False, False, 0)

        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.back_btn = Gtk.Button(label="← Back")
        self.back_btn.get_style_context().add_class("cs-back-btn")
        self.back_btn.set_relief(Gtk.ReliefStyle.NONE)
        self.back_btn.connect("clicked", lambda *_: self.go_back())
        toolbar.pack_start(self.back_btn, False, False, 0)

        self.search_entry = Gtk.SearchEntry()
        self.search_entry.get_style_context().add_class("cs-search")
        self.search_entry.set_placeholder_text("Search shortcuts... (Ctrl+F)")
        self.search_entry.connect(
            "changed", lambda e: _apply_filter(e.get_text(), self.entries)
        )
        toolbar.pack_start(self.search_entry, True, True, 0)

        self.search_all_check = Gtk.CheckButton(label="Search all sheets")
        self.search_all_check.get_style_context().add_class("cs-search-all")
        self.search_all_check.connect("toggled", self._on_search_all_toggled)
        toolbar.pack_start(self.search_all_check, False, False, 0)
        outer.pack_start(toolbar, False, False, 0)

        self.scroller = Gtk.ScrolledWindow()
        self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        outer.pack_start(self.scroller, True, True, 0)

        hint = Gtk.Label(
            label="Esc or Super+F1 again to close · Ctrl+F to search",
            xalign=0,
        )
        hint.get_style_context().add_class("cs-hint")
        outer.pack_start(hint, False, False, 0)

        self.win.add(outer)

        geo = self.win.get_screen().get_monitor_geometry(
            self.win.get_screen().get_primary_monitor()
        )
        self.win.set_default_size(
            min(1100, int(geo.width * 0.8)), min(800, int(geo.height * 0.8))
        )

        self.win.connect("key-press-event", self._on_key_press)
        self.win.connect("destroy", lambda *_: Gtk.main_quit())

    def _title_bar(self):
        """No window decorations are used (they'd break the floating-overlay
        look), so dragging and closing are reimplemented by hand here --
        click-drag anywhere on the bar moves the window, the button on the
        right closes it."""
        bar = Gtk.EventBox()
        bar.get_style_context().add_class("cs-titlebar")

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.title_label = Gtk.Label(label=ROOT_TITLE, xalign=0)
        self.title_label.get_style_context().add_class("cs-title")
        row.pack_start(self.title_label, True, True, 0)

        close_btn = Gtk.Button(label="✕")
        close_btn.get_style_context().add_class("cs-close-btn")
        close_btn.set_relief(Gtk.ReliefStyle.NONE)
        close_btn.connect("clicked", lambda *_: Gtk.main_quit())
        row.pack_start(close_btn, False, False, 0)

        bar.add(row)
        bar.connect(
            "button-press-event",
            lambda widget, event: self.win.begin_move_drag(
                event.button, int(event.x_root), int(event.y_root), event.time
            )
            if event.button == 1
            else False,
        )
        return bar

    def _on_key_press(self, _widget, event):
        ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
        if ctrl and event.keyval in (Gdk.KEY_f, Gdk.KEY_F):
            self.search_entry.grab_focus()
            self.search_entry.select_region(0, -1)
            return True
        if event.keyval == Gdk.KEY_Escape:
            # First Escape just clears an active search (typing Ctrl+F, then
            # Escape to see everything again shouldn't also close the whole
            # overlay) -- only close once there's no search text left to
            # clear.
            if self.search_entry.get_text():
                self.search_entry.set_text("")
                return True
            Gtk.main_quit()
        return False

    def _on_search_all_toggled(self, check):
        self.search_all = check.get_active()
        self._render()

    def navigate(self, target, label):
        base_dir = os.path.dirname(self.current[0])
        path = os.path.normpath(os.path.join(base_dir, target))
        # A link clicked from within a combined "search all" view still
        # means "go look at that one sheet" -- drop out of that mode first.
        if self.search_all:
            self.search_all = False
            self.search_all_check.set_active(False)  # fires _on_search_all_toggled itself
        self.history.append(self.current)
        self.current = (path, label)
        self._render()

    def go_back(self):
        if not self.history:
            return
        self.current = self.history.pop()
        if self.search_all:
            self.search_all = False
            self.search_all_check.set_active(False)
        self._render()

    def _render(self):
        """Rebuilds just the content area (and title/back-button state)
        for whatever self.current / self.search_all currently say -- the
        window chrome built in build_chrome() is untouched."""
        if self.search_all:
            # Always the full reachable set from the true root, regardless
            # of which sheet is "current" -- "all sheets" should mean all,
            # not just what's reachable from wherever you happen to be.
            sheets = discover_sheets(MD_PATH, ROOT_TITLE)
            categories = []
            for path, label, sheet_categories in sheets:
                is_root = path == os.path.normpath(MD_PATH)
                for category in sheet_categories:
                    name = category["name"] if is_root else f"{label} — {category['name']}"
                    categories.append({"name": name, "shortcuts": category["shortcuts"]})
            self.title_label.set_label(f"{ROOT_TITLE} — all sheets")
        else:
            try:
                categories = load_categories(self.current[0])
            except (subprocess.CalledProcessError, OSError, json.JSONDecodeError):
                categories = []
            if self.current[0] == os.path.normpath(MD_PATH):
                self.title_label.set_label(ROOT_TITLE)
            else:
                self.title_label.set_label(f"{ROOT_TITLE} › {self.current[1]}")

        self.back_btn.set_visible(bool(self.history) and not self.search_all)

        columns = [
            Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
            for _ in range(MAX_COLUMNS)
        ]
        entries = []
        for i, category in enumerate(categories):
            box, rows = category_box(category, self.navigate)
            columns[i % MAX_COLUMNS].pack_start(box, False, False, 0)
            entries.append((box, category["name"].lower(), rows))
        self.entries = entries

        columns_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
        for col in columns:
            columns_row.pack_start(col, False, False, 0)

        old_child = self.scroller.get_child()
        if old_child:
            self.scroller.remove(old_child)
        self.scroller.add(columns_row)
        columns_row.show_all()

        _apply_filter(self.search_entry.get_text(), self.entries)

    def show(self):
        self.build_chrome()
        self._render()
        self.win.show_all()
        self.back_btn.set_visible(False)
        self.search_entry.grab_focus()


def wait_for_source(timeout=10):
    """Block until MD_PATH is actually readable, up to `timeout` seconds.
    /sf/bernina is a real NFS mount (not autofs) so it's normally already
    up by login, but this is cheap insurance against a race at boot, and
    means the file always gets read fresh from the network filesystem on
    every call -- no local copy to ever go stale."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if os.access(MD_PATH, os.R_OK):
            return True
        time.sleep(0.2)
    return os.access(MD_PATH, os.R_OK)


def main():
    wait_for_source()

    if already_showing():
        return

    with open(PID_FILE, "w", encoding="utf-8") as f:
        f.write(str(os.getpid()))

    def cleanup(*_args):
        try:
            os.unlink(PID_FILE)
        except OSError:
            pass
        Gtk.main_quit()

    signal.signal(signal.SIGTERM, lambda *_: GLib.idle_add(cleanup))

    provider = Gtk.CssProvider()
    provider.load_from_data(CSS)
    Gtk.StyleContext.add_provider_for_screen(
        Gdk.Screen.get_default(),
        provider,
        Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
    )

    overlay = Overlay()
    overlay.show()
    Gtk.main()
    try:
        os.unlink(PID_FILE)
    except OSError:
        pass


if __name__ == "__main__":
    main()
