#!/usr/bin/env python3

"""Read and update freedesktop defaults for Panama's settings page."""

from __future__ import annotations

import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile


# A role owns a FAMILY of types, not one representative.
#
# Each role used to carry a single mime type, so setting "images" changed
# image/png and left image/jpeg wherever it happened to land. That is exactly
# how this machine ended up opening PNGs in a pixel-art editor, MP3s in a video
# transcoder and PDFs in GIMP: nobody chose any of it, the applications
# registered themselves and the roles only ever governed one type each.
#
# The FIRST entry in each list is the one queried when reporting the current
# handler; all of them are written when the role is set, so a family cannot
# drift apart again.
ROLE_TARGETS = {
    "browser": ("settings", ["default-web-browser"]),
    "mail": ("mime", ["x-scheme-handler/mailto"]),
    "files": ("mime", ["inode/directory"]),
    "terminal": ("mime", ["x-scheme-handler/terminal"]),
    "music": ("mime", [
        "audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg",
        "audio/x-wav", "audio/mp4", "audio/aac", "audio/opus",
    ]),
    "images": ("mime", [
        "image/png", "image/jpeg", "image/gif", "image/webp",
        "image/tiff", "image/bmp", "image/svg+xml", "image/avif",
    ]),
    "video": ("mime", [
        "video/mp4", "video/x-matroska", "video/webm", "video/quicktime",
        "video/x-msvideo", "video/mpeg",
    ]),
    "documents": ("mime", [
        "application/pdf", "application/epub+zip",
    ]),
    "text": ("mime", [
        "text/plain", "text/markdown", "text/x-python", "text/x-csrc",
        "text/x-chdr", "text/x-c++src", "text/x-shellscript",
        "application/json", "application/x-yaml", "text/xml",
    ]),
    "archives": ("mime", [
        "application/zip", "application/x-tar", "application/gzip",
        "application/x-7z-compressed", "application/vnd.rar",
    ]),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
MIME_TYPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}"
                       r"/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$")

# A single-type override is a scalpel, not a browser: someone looking for "the
# thing that opens .heic" wants a short answer, and a list of six hundred types
# is not one. Anything past this is reported as truncated so the page can say
# "narrow the search" rather than pretending these are all of them.
TYPE_SEARCH_LIMIT = 20
TYPE_CANDIDATE_LIMIT = 12


class BoundaryError(RuntimeError):
    """A user-visible validation or command failure."""


def xdg_data_roots() -> list[Path]:
    data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
    data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
    return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]


def discovered_desktop_files() -> dict[str, Path]:
    desktop_files: dict[str, Path] = {}
    for root in xdg_data_roots():
        applications = root / "applications"
        if not applications.is_dir():
            continue
        for path in applications.rglob("*.desktop"):
            if not path.is_file():
                continue
            relative = path.relative_to(applications)
            desktop_files.setdefault("-".join(relative.parts), path)
    return desktop_files


def discovered_desktop_ids() -> set[str]:
    return set(discovered_desktop_files())


def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
    if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
        raise BoundaryError("That application is not available.")


def run(command: list[str]) -> str:
    completed = subprocess.run(command, check=False, capture_output=True, text=True)
    if completed.returncode != 0:
        detail = completed.stderr.strip()
        raise BoundaryError(detail or "The system default could not be updated.")
    return completed.stdout.strip()


def query_handlers() -> dict[str, str]:
    handlers: dict[str, str] = {}
    for role, (kind, targets) in ROLE_TARGETS.items():
        # The first type represents the family when reporting.
        target = targets[0]
        command = (
            ["xdg-settings", "get", target]
            if kind == "settings"
            else ["xdg-mime", "query", "default", target]
        )
        output = run(command)
        handlers[role] = output.splitlines()[0] if output else ""
    return handlers


def parse_desktop_entry(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    section = ""
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeError) as error:
        raise BoundaryError(f"Could not read {path.name}.") from error
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            section = stripped[1:-1]
            continue
        if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
            continue
        key, value = line.split("=", 1)
        values.setdefault(key.strip(), value.strip())
    return values


def autostart_directory() -> Path:
    config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
    return config_home / "autostart"


def user_autostart_entries() -> list[dict[str, object]]:
    directory = autostart_directory()
    if not directory.is_dir():
        return []
    entries: list[dict[str, object]] = []
    for path in directory.glob("*.desktop"):
        if path.is_symlink() or not path.is_file():
            continue
        values = parse_desktop_entry(path)
        entries.append(
            {
                "id": path.name,
                "name": values.get("Name", path.stem),
                "enabled": values.get("Hidden", "false").lower() != "true",
            }
        )
    return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))


def hypr_autostart_path() -> Path:
    override = os.environ.get("PANAMA_HYPR_AUTOSTART")
    if override:
        return Path(override)
    return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"


def lua_autostart_entries() -> list[dict[str, object]]:
    path = hypr_autostart_path()
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeError):
        return []

    commands: list[str] = []
    in_start_handler = False
    for line in lines:
        if not in_start_handler:
            in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
            continue
        if line.strip() == "end)":
            break
        match = EXEC_CMD.search(line)
        if match:
            try:
                commands.append(ast.literal_eval(match.group(1)))
            except (SyntaxError, ValueError):
                continue

    return [
        {
            "id": f"hyprland:{index}",
            "name": command.split()[0].rsplit("/", 1)[-1],
            "command": command,
            "enabled": True,
            "readOnly": True,
            "source": "config/dot/hypr/autostart.lua",
        }
        for index, command in enumerate(commands, start=1)
    ]


def snapshot() -> dict[str, object]:
    return {
        "handlers": query_handlers(),
        "autostartEntries": user_autostart_entries(),
        "luaAutostartEntries": lua_autostart_entries(),
    }


def set_default(role: str, desktop_id: str) -> None:
    target = ROLE_TARGETS.get(role)
    if target is None:
        raise BoundaryError("That default application role is not supported.")
    require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
    kind, settings = target
    if kind == "settings":
        run(["xdg-settings", "set", settings[0], desktop_id])
        return
    # Every type in the family, so a role cannot be half-applied. xdg-mime
    # accepts several types in one call, but they are written individually so a
    # type this system does not know about cannot fail the whole role.
    for setting in settings:
        run(["xdg-mime", "default", desktop_id, setting])


# ── One file type at a time ──────────────────────────────────────────────────
#
# The roles above govern families, which is right nearly always and wrong
# exactly when a family is too broad: SVG belongs in an editor while the rest of
# the images belong in a viewer, and setting the whole "Images" role to the
# editor is not what anyone wanted. These two verbs are the escape hatch.
#
# Searching is over the type name and its file extensions, and says so on the
# page. Matching human descriptions would mean reading the whole shared-mime-info
# database -- some thousands of small XML files -- to answer a keystroke.


def mime_globs() -> dict[str, list[str]]:
    """Extension patterns per type, from shared-mime-info's globs2.

    Absent on a machine without shared-mime-info, which is not fatal: the search
    falls back to matching the type name, and every type still resolves.
    """
    globs: dict[str, list[str]] = {}
    for root in xdg_data_roots():
        path = root / "mime" / "globs2"
        if not path.is_file():
            continue
        try:
            lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
        except OSError:
            continue
        for line in lines:
            if line.startswith("#"):
                continue
            parts = line.split(":")
            # weight:type:glob, with optional trailing flags.
            if len(parts) < 3:
                continue
            mime, pattern = parts[1], parts[2]
            if not MIME_TYPE.fullmatch(mime):
                continue
            patterns = globs.setdefault(mime, [])
            if pattern not in patterns:
                patterns.append(pattern)
    return globs


def mime_registrations() -> dict[str, list[str]]:
    """Which installed applications declare which types.

    Read from the desktop files themselves rather than mimeinfo.cache, because
    the cache is regenerated by update-desktop-database and is stale on exactly
    the machine where an application was just installed.
    """
    registrations: dict[str, list[str]] = {}
    for desktop_id, path in discovered_desktop_files().items():
        try:
            values = parse_desktop_entry(path)
        except BoundaryError:
            continue
        if values.get("NoDisplay", "false").lower() == "true":
            continue
        for mime in values.get("MimeType", "").split(";"):
            mime = mime.strip()
            if not MIME_TYPE.fullmatch(mime):
                continue
            registered = registrations.setdefault(mime, [])
            if desktop_id not in registered:
                registered.append(desktop_id)
    return registrations


def mime_label(mime: str) -> str:
    """shared-mime-info's own description, or "" when it has none.

    Read only for the handful of types a search actually returns.
    """
    media, _, subtype = mime.partition("/")
    if not media or not subtype:
        return ""
    for root in xdg_data_roots():
        path = root / "mime" / media / f"{subtype}.xml"
        if not path.is_file():
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        # The untranslated <comment> comes first; the xml:lang ones follow.
        found = re.search(r"<comment>([^<]*)</comment>", text)
        if found:
            return found.group(1).strip()
    return ""


def application_names() -> dict[str, str]:
    names: dict[str, str] = {}
    for desktop_id, path in discovered_desktop_files().items():
        try:
            values = parse_desktop_entry(path)
        except BoundaryError:
            continue
        names[desktop_id] = values.get("Name", desktop_id[:-len(".desktop")])
    return names


def current_handler(mime: str) -> str:
    completed = subprocess.run(["xdg-mime", "query", "default", mime],
                               check=False, capture_output=True, text=True)
    if completed.returncode != 0:
        return ""
    output = completed.stdout.strip().splitlines()
    return output[0] if output else ""


def search_types(query: str) -> dict[str, object]:
    needle = (query or "").strip().lower().lstrip(".")
    if len(needle) < 2:
        return {"query": query, "types": [], "truncated": False}

    globs = mime_globs()
    registrations = mime_registrations()
    names = application_names()
    known = sorted(set(globs) | set(registrations))

    def score(mime: str) -> tuple[int, str]:
        patterns = globs.get(mime, [])
        extensions = [pattern[2:].lower() for pattern in patterns
                      if pattern.startswith("*.")]
        if needle in extensions:
            return (0, mime)
        if mime.lower() == needle or mime.lower().split("/")[-1] == needle:
            return (1, mime)
        return (2, mime)

    matched = []
    for mime in known:
        patterns = globs.get(mime, [])
        haystack = " ".join([mime.lower(),
                             *(pattern.lower() for pattern in patterns)])
        if needle in haystack:
            matched.append(mime)
    matched.sort(key=score)

    truncated = len(matched) > TYPE_SEARCH_LIMIT
    types = []
    for mime in matched[:TYPE_SEARCH_LIMIT]:
        handler = current_handler(mime)
        candidates = list(registrations.get(mime, []))
        # The current handler belongs in the list even when it never declared
        # the type -- an override put it there, and a picker that cannot show
        # the answer it is displaying is a picker nobody trusts.
        if handler and handler not in candidates:
            candidates.insert(0, handler)
        candidates = candidates[:TYPE_CANDIDATE_LIMIT]
        types.append({
            "mime": mime,
            "label": mime_label(mime),
            "extensions": [pattern[1:] for pattern in globs.get(mime, [])
                           if pattern.startswith("*.")][:6],
            "handler": handler,
            "handlerName": names.get(handler, ""),
            "candidates": [{"id": desktop_id, "name": names.get(desktop_id, desktop_id)}
                           for desktop_id in candidates],
        })
    return {"query": query, "types": types, "truncated": truncated}


def set_type(mime: str, desktop_id: str) -> None:
    """Point one type at one application, leaving its family alone."""
    if not MIME_TYPE.fullmatch(mime or ""):
        raise BoundaryError("That is not a file type.")
    globs = mime_globs()
    registrations = mime_registrations()
    if mime not in globs and mime not in registrations:
        raise BoundaryError("This system does not know that file type.")
    require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
    run(["xdg-mime", "default", desktop_id, mime])


# What this desktop opens a file with, when nobody has said otherwise.
#
# Applications register themselves for every type they can technically read, so
# an unattended machine decides these by installation order: a pixel-art editor
# claims PNG, a video transcoder claims MP3, an image editor claims PDF. None of
# that is a choice anyone made, and it is only discovered by double-clicking.
#
# Each role lists candidates best-first; the first one installed wins. A role
# with no candidate installed is left alone rather than forced.
PREFERRED_HANDLERS = {
    "images": ["org.gnome.Loupe.desktop", "org.gnome.eog.desktop"],
    "music": ["org.gnome.Decibels.desktop", "io.bassi.Amberol.desktop", "io.mpv.Mpv.desktop"],
    "video": ["io.mpv.Mpv.desktop", "mpv.desktop", "org.gnome.Totem.desktop"],
    "documents": ["org.gnome.Papers.desktop", "org.gnome.Evince.desktop"],
    "text": ["panama-nvim.desktop"],
    "archives": ["org.gnome.Nautilus.desktop", "org.gnome.FileRoller.desktop"],
}


def user_mimeapps() -> Path:
    config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
    return config_home / "mimeapps.list"


def chosen_types() -> set[str]:
    """Types the person using this machine has already assigned by hand.

    A type listed under [Default Applications] in the user's own mimeapps.list
    got there because someone picked "Open With" and made it stick, or because
    the settings page wrote it. Seeding must never overrule that.
    """
    path = user_mimeapps()
    if not path.is_file():
        return set()
    chosen: set[str] = set()
    section = ""
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeError):
        return set()
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            section = stripped[1:-1]
            continue
        if section != "Default Applications" or "=" not in line or stripped.startswith("#"):
            continue
        chosen.add(line.split("=", 1)[0].strip())
    return chosen


def seed() -> None:
    """Apply Panama's curated defaults to roles nobody has chosen for."""
    discovered = discovered_desktop_ids()
    already = chosen_types()
    for role, candidates in PREFERRED_HANDLERS.items():
        kind, targets = ROLE_TARGETS[role]
        if kind != "mime":
            continue
        if any(target in already for target in targets):
            print(f"{role}: keeping the existing choice")
            continue
        preferred = next((entry for entry in candidates if entry in discovered), None)
        if preferred is None:
            print(f"{role}: no preferred application installed, leaving it alone")
            continue
        set_default(role, preferred)
        print(f"{role}: {preferred}")


def with_hidden(original: str, *, hidden: bool) -> str:
    lines = original.splitlines()
    output: list[str] = []
    section = ""
    found_section = False
    wrote_hidden = False
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            if section == "Desktop Entry" and not wrote_hidden:
                output.append(f"Hidden={'true' if hidden else 'false'}")
                wrote_hidden = True
            section = stripped[1:-1]
            found_section = found_section or section == "Desktop Entry"
            output.append(line)
            continue
        if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
            if not wrote_hidden:
                output.append(f"Hidden={'true' if hidden else 'false'}")
                wrote_hidden = True
            continue
        output.append(line)

    if not found_section:
        raise BoundaryError("That autostart entry is not a desktop file.")
    if not wrote_hidden:
        output.append(f"Hidden={'true' if hidden else 'false'}")
    return "\n".join(output) + "\n"


def write_atomic(path: Path, text: str, *, mode: int) -> None:
    temporary_path: Path | None = None
    try:
        with tempfile.NamedTemporaryFile(
            "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
        ) as temporary:
            temporary.write(text)
            temporary.flush()
            os.fsync(temporary.fileno())
            temporary_path = Path(temporary.name)
        temporary_path.chmod(mode)
        os.replace(temporary_path, path)
    except OSError as error:
        if temporary_path is not None:
            temporary_path.unlink(missing_ok=True)
        raise BoundaryError("That autostart entry could not be updated.") from error


def update_hidden(path: Path, *, hidden: bool) -> None:
    try:
        original = path.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as error:
        raise BoundaryError("That autostart entry could not be read.") from error

    mode = path.stat().st_mode
    write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)


def remove_autostart(desktop_id: str) -> None:
    """Delete a user autostart entry.

    Disabling writes Hidden=true and is reversible; this is not, so it is
    confined to files this directory owns. A symlink is refused rather than
    followed, because deleting through one would remove whatever it points at --
    which is somewhere else entirely, and not ours.
    """
    if not DESKTOP_ID.fullmatch(desktop_id):
        raise BoundaryError("That is not an autostart entry name.")

    directory = autostart_directory()
    target = directory / desktop_id

    # Resolved and compared, so a name like "../../.bashrc" cannot escape.
    try:
        resolved = target.resolve(strict=True)
    except OSError as error:
        raise BoundaryError("That autostart entry no longer exists.") from error
    if resolved.parent != directory.resolve(strict=False):
        raise BoundaryError("That autostart entry is not in the autostart directory.")
    if target.is_symlink() or not target.is_file():
        raise BoundaryError("That autostart entry is not a file this can remove.")
    if target.suffix != ".desktop":
        raise BoundaryError("That autostart entry is not a desktop file.")

    try:
        target.unlink()
    except OSError as error:
        raise BoundaryError("That autostart entry could not be removed.") from error


def add_autostart(desktop_id: str) -> None:
    desktop_files = discovered_desktop_files()
    require_desktop_id(desktop_id, discovered=set(desktop_files))
    source = desktop_files[desktop_id]
    directory = autostart_directory()
    try:
        directory.mkdir(parents=True, exist_ok=True)
    except OSError as error:
        raise BoundaryError("The user autostart directory could not be created.") from error

    target = directory / desktop_id
    if target.is_symlink():
        raise BoundaryError("That autostart entry is not available.")
    if target.exists():
        if not target.is_file():
            raise BoundaryError("That autostart entry is not available.")
        update_hidden(target, hidden=False)
        return

    try:
        original = source.read_text(encoding="utf-8")
    except (OSError, UnicodeError) as error:
        raise BoundaryError("That application could not be read.") from error
    write_atomic(target, with_hidden(original, hidden=False), mode=0o644)


def set_autostart(desktop_id: str, enabled_text: str) -> None:
    if enabled_text not in {"true", "false"}:
        raise BoundaryError("Autostart state must be true or false.")
    if not DESKTOP_ID.fullmatch(desktop_id):
        raise BoundaryError("That autostart entry is not available.")

    directory = autostart_directory()
    path = directory / desktop_id
    try:
        resolved_directory = directory.resolve(strict=True)
        resolved_path = path.resolve(strict=True)
    except OSError as error:
        raise BoundaryError("That autostart entry is not available.") from error
    if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
        raise BoundaryError("That autostart entry is not available.")
    update_hidden(resolved_path, hidden=enabled_text == "false")


def main(arguments: list[str]) -> int:
    try:
        if arguments == ["snapshot"]:
            print(json.dumps(snapshot(), separators=(",", ":")))
        elif arguments == ["seed"]:
            seed()
        elif len(arguments) == 3 and arguments[0] == "set-default":
            set_default(arguments[1], arguments[2])
        elif len(arguments) == 3 and arguments[0] == "set-autostart":
            set_autostart(arguments[1], arguments[2])
        elif len(arguments) == 2 and arguments[0] == "remove-autostart":
            remove_autostart(arguments[1])
        elif len(arguments) == 2 and arguments[0] == "add-autostart":
            add_autostart(arguments[1])
        elif len(arguments) == 2 and arguments[0] == "search-types":
            print(json.dumps(search_types(arguments[1]), separators=(",", ":")))
        elif len(arguments) == 3 and arguments[0] == "set-type":
            set_type(arguments[1], arguments[2])
        else:
            raise BoundaryError(
                "Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
                "set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID | "
                "remove-autostart DESKTOP_ID | search-types QUERY | set-type MIME DESKTOP_ID"
            )
    except BoundaryError as error:
        print(str(error), file=sys.stderr)
        return 2
    return 0


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