#!/usr/bin/env python3

"""Generate one launcher command per settings page.

Settings has a search index and the launcher has script commands, but they did
not know about each other: finding a setting meant opening Settings first and
searching there. These commands close that gap, so typing "night light" into the
launcher opens the page that owns it.

The vocabulary is derived from the same sources the in-app search uses --
the page list in SettingsSidebar.qml, the group routing in SettingsSearch.qml,
and the labels in PreferenceSchema.qml -- so a setting that is searchable inside
Settings is searchable from the launcher without anyone maintaining a second
list.

    panama-settings-commands           write the commands
    panama-settings-commands --check   fail if what is on disk is stale

Run it after adding a settings page or renaming a setting.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[4]
SHELL = REPO / "config/dot/quickshell"
SIDEBAR = SHELL / "modules/settings/SettingsSidebar.qml"
SEARCH = SHELL / "services/SettingsSearch.qml"
SCHEMA = SHELL / "config/PreferenceSchema.qml"
OUTPUT_DIR = REPO / "config/local/share/vicinae/scripts"

# Home is what the plain "Open Settings" command already lands on, so a second
# command for it would be a duplicate under a different name.
SKIP_PAGES = {"home"}

# Enough vocabulary to find the page, not so much that one page matches
# everything. Ordered by the schema, so the settings at the top of a page --
# the ones it is named for -- are the ones that survive the cut.
MAX_KEYWORDS = 12

GENERATED_MARKER = "# Generated by scripts/panama-settings-commands -- do not edit by hand."


class ParseError(RuntimeError):
    """A source file did not look the way this generator expects."""


def read(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except OSError as error:
        raise ParseError(f"could not read {path}") from error


def pages() -> list[tuple[str, str]]:
    """The settings pages, in sidebar order, as (id, label)."""
    source = read(SIDEBAR)
    found = re.findall(r'\{ page: "([a-z-]+)", label: "([^"]+)"', source)
    if not found:
        raise ParseError("no pages found in SettingsSidebar.qml")
    return [(page, label) for page, label in found if page not in SKIP_PAGES]


def group_pages() -> dict[str, str]:
    source = read(SEARCH)
    table = re.search(r"readonly property var groupPages: \(\{(.*?)\n    \}\)", source, re.S)
    if not table:
        raise ParseError("groupPages not found in SettingsSearch.qml")
    return dict(re.findall(r'"([A-Za-z]+)":\s*"([a-z-]+)"', table.group(1)))


def extra_labels() -> dict[str, list[str]]:
    """Settings the system owns rather than Panama, which have no schema entry."""
    source = read(SEARCH)
    table = re.search(r"readonly property var extraEntries: \[(.*?)\n    \]", source, re.S)
    if not table:
        return {}
    labels: dict[str, list[str]] = {}
    for label, page in re.findall(r'\{ label: "([^"]+)".*?page: "([a-z-]+)" \}', table.group(1)):
        labels.setdefault(page, []).append(label)
    return labels


def schema_labels() -> list[tuple[str, str]]:
    """(group, label) for every user-facing setting, in schema order."""
    source = read(SCHEMA)
    entries: list[tuple[str, str]] = []
    chunks = source.split("key: ")
    for chunk in chunks[1:]:
        # Internal state is not a setting anyone searches for.
        if re.search(r"internal:\s*true", chunk[:600]):
            continue
        group = re.search(r'group:\s*"([A-Za-z]+)"', chunk[:600])
        label = re.search(r'label:\s*"([^"]+)"', chunk[:600])
        if group and label:
            entries.append((group.group(1), label.group(1)))
    if not entries:
        raise ParseError("no labelled settings found in PreferenceSchema.qml")
    return entries


def keywords_for(page: str, routing: dict[str, str], schema: list[tuple[str, str]],
                 extras: dict[str, list[str]]) -> list[str]:
    words: list[str] = []
    for group, label in schema:
        if routing.get(group) == page:
            lowered = label.lower()
            if lowered not in words:
                words.append(lowered)
    for label in extras.get(page, []):
        lowered = label.lower()
        if lowered not in words:
            words.append(lowered)
    return words[:MAX_KEYWORDS]


def command_for(page: str, label: str, words: list[str]) -> str:
    """One launcher command, titled "Settings: <page>".

    The qualifier is not product branding -- that was deliberately dropped from
    every command title. It is here because a bare page label collides with the
    feature of the same name: "Screen Intelligence" is both a thing you open and
    a page of settings about it, and two commands sharing one title are
    indistinguishable in a launcher. Qualifying the page also groups all of them
    under one word, which is what typing "settings" is for.
    """
    # "settings" is always a keyword so typing it lists every page at once.
    vocabulary = ["settings"] + [word for word in words if word != "settings"]
    keywords = ", ".join(f'"{word}"' for word in vocabulary)
    return f"""#!/usr/bin/env bash
{GENERATED_MARKER}
# @vicinae.schemaVersion 1
# @vicinae.title Settings: {label}
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open {label} in Settings.
# @vicinae.keywords [{keywords}]

exec "$HOME/.config/quickshell/scripts/panama-action" settings-page {page}
"""


def build() -> dict[Path, str]:
    routing = group_pages()
    schema = schema_labels()
    extras = extra_labels()
    files: dict[Path, str] = {}
    for page, label in pages():
        words = keywords_for(page, routing, schema, extras)
        files[OUTPUT_DIR / f"settings-{page}"] = command_for(page, label, words)
    return files


def existing() -> set[Path]:
    return {path for path in OUTPUT_DIR.glob("settings-*")}


def main(arguments: list[str]) -> int:
    check = arguments == ["--check"]
    if arguments and not check:
        print(__doc__, file=sys.stderr)
        return 2

    try:
        files = build()
    except ParseError as error:
        # Loud and empty-handed: writing a partial set would silently drop the
        # pages whose source stopped parsing.
        print(f"panama-settings-commands: {error}", file=sys.stderr)
        return 2

    stale = [path for path, body in files.items()
             if not path.is_file() or path.read_text(encoding="utf-8") != body]
    orphaned = sorted(existing() - set(files))

    if check:
        for path in stale:
            print(f"stale: {path.name}", file=sys.stderr)
        for path in orphaned:
            print(f"orphaned: {path.name}", file=sys.stderr)
        if stale or orphaned:
            print("panama-settings-commands: run it without --check to regenerate",
                  file=sys.stderr)
            return 1
        print(f"panama-settings-commands: {len(files)} commands are current")
        return 0

    for path in orphaned:
        path.unlink()
        print(f"removed {path.name}")
    for path, body in files.items():
        path.write_text(body, encoding="utf-8")
        path.chmod(0o755)
    print(f"panama-settings-commands: wrote {len(files)} commands"
          + (f", removed {len(orphaned)}" if orphaned else ""))
    return 0


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