#!/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*\)")


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])


# 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])
        else:
            raise BoundaryError(
                "Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
                "set-autostart DESKTOP_ID true|false | add-autostart 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:]))
