import os
import re
from dataclasses import dataclass

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, Gtk, GLib

from stillControl.i18n import _


AUTOSTART_DIR = os.path.join(GLib.get_user_config_dir(), "autostart")
DESKTOP_GROUP = "Desktop Entry"
ROW_ICON_SIZE = 32
_app_info_by_id = {}
_app_infos_by_executable = None


@dataclass
class AutostartEntry:
    desktop_id: str
    path: str
    name: str
    command: str
    comment: str
    enabled: bool
    source: str
    terminal: bool = False
    delay: int = 0
    icon: Gio.Icon = None
    system_path: str = ""


class StartupApplicationsPage(Gtk.Box):
    def __init__(self):
        super().__init__(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=18,
            margin_top=20,
            margin_bottom=20,
            margin_start=20,
            margin_end=20,
        )

        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        header.set_valign(Gtk.Align.CENTER)
        self.append(header)

        self.controls_group = Adw.PreferencesGroup()
        self.append(self.controls_group)

        add_row = Adw.ActionRow(
            title=_("Add Startup Application"),
            subtitle=_("Create a new entry that starts when you log in."),
        )
        add_row.set_icon_name("list-add-symbolic")
        add_row.set_activatable(True)
        add_row.connect("activated", self.on_add_clicked)
        self.controls_group.add(add_row)

        folder_row = Adw.ActionRow(
            title=_("Autostart Folder"),
            subtitle=_("Open the folder where user startup entries are stored."),
        )
        folder_row.set_icon_name("folder-open-symbolic")
        folder_row.set_activatable(True)
        folder_row.connect("activated", self.on_folder_clicked)
        self.controls_group.add(folder_row)

        self.user_group = Adw.PreferencesGroup(title=_("User Entries"))
        self.append(self.user_group)

        self.system_group = Adw.PreferencesGroup(title=_("System Entries"))
        self.system_group.set_visible(False)
        self.append(self.system_group)

        self.show_system_entries = False
        self.rows = []
        self.system_rows = []

        self.show_system_row = Adw.SwitchRow(
            title=_("Show System Entries"),
            subtitle=_("Advanced only. Changing these can break desktop features."),
        )
        self.show_system_row.add_css_class("error")
        self.show_system_row.add_css_class("destructive-action")
        self.show_system_row.set_active(False)
        self.show_system_row.connect("notify::active", self.on_show_system_changed)
        self.controls_group.add(self.show_system_row)

        self.refresh()

    def refresh(self):
        for row in self.rows:
            self.user_group.remove(row)
        for row in self.system_rows:
            self.system_group.remove(row)
        self.rows = []
        self.system_rows = []

        all_entries = load_autostart_entries(self.show_system_entries)
        entries = [entry for entry in all_entries if not is_system_entry(entry)]
        if not entries:
            row = Adw.ActionRow(
                title=_("No Startup Applications"),
                subtitle=_("Applications added here will open when you sign in."),
            )
            row.set_icon_name("emblem-ok-symbolic")
            self.user_group.add(row)
            self.rows.append(row)
        else:
            for entry in entries:
                row = StartupApplicationRow(entry, self)
                self.user_group.add(row)
                self.rows.append(row)

        system_entries = [
            entry for entry in all_entries if is_system_entry(entry)
        ] if self.show_system_entries else []
        self.system_group.set_visible(bool(system_entries))
        for entry in system_entries:
            row = StartupApplicationRow(entry, self)
            self.system_group.add(row)
            self.system_rows.append(row)

    def on_show_system_changed(self, switch_row, _active):
        self.show_system_entries = switch_row.get_active()
        self.refresh()

    def on_add_clicked(self, _button):
        StartupApplicationDialog(self).present(self.get_root())

    def on_folder_clicked(self, _button):
        os.makedirs(AUTOSTART_DIR, exist_ok=True)
        try:
            Gio.AppInfo.launch_default_for_uri(
                Gio.File.new_for_path(AUTOSTART_DIR).get_uri(), None
            )
        except GLib.Error as error:
            self.show_error(_("Could Not Open Autostart Folder"), str(error))

    def edit_entry(self, entry):
        StartupApplicationDialog(self, entry).present(self.get_root())

    def remove_entry(self, entry):
        dialog = Adw.AlertDialog.new(
            _("Remove Startup Application?"),
            _("This application will no longer open automatically when you sign in. If this is a system entry, there may not be an easy way to recover it."),
        )
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("remove", _("Remove"))
        dialog.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)
        dialog.set_default_response("cancel")
        dialog.set_close_response("cancel")

        def on_response(_dialog, response):
            if response == "remove":
                try:
                    remove_autostart_entry(entry)
                    self.refresh()
                except GLib.Error as error:
                    self.show_error(_("Could Not Remove Startup Application"), str(error))
                except OSError as error:
                    self.show_error(_("Could Not Remove Startup Application"), str(error))

        dialog.connect("response", on_response)
        dialog.present(self.get_root())

    def set_entry_enabled(self, entry, enabled):
        try:
            path = write_autostart_entry(
                entry.desktop_id,
                entry.name,
                entry.command,
                entry.comment,
                enabled,
                terminal=entry.terminal,
                delay=entry.delay,
                source_path=entry.path,
            )
            entry.enabled = enabled
            if entry.source == "system":
                entry.system_path = entry.path
                entry.source = "user"
                entry.path = path
        except GLib.Error as error:
            self.show_error(_("Could Not Update Startup Application"), str(error))
            self.refresh()
        except OSError as error:
            self.show_error(_("Could Not Update Startup Application"), str(error))
            self.refresh()

    def save_entry(self, name, command, comment, enabled, terminal, delay, entry=None):
        try:
            desktop_id = entry.desktop_id if entry else new_desktop_id(name)
            source_path = entry.path if entry else None
            write_autostart_entry(
                desktop_id,
                name,
                command,
                comment,
                enabled,
                terminal=terminal,
                delay=delay,
                source_path=source_path,
            )
            self.refresh()
            return True
        except GLib.Error as error:
            self.show_error(_("Could Not Save Startup Application"), str(error))
        except OSError as error:
            self.show_error(_("Could Not Save Startup Application"), str(error))
        return False

    def show_error(self, title, body):
        dialog = Adw.AlertDialog.new(title, body)
        dialog.add_response("ok", _("OK"))
        dialog.set_default_response("ok")
        dialog.set_close_response("ok")
        dialog.present(self.get_root())


class StartupApplicationRow(Adw.ActionRow):
    def __init__(self, entry, page):
        super().__init__()
        self.entry = entry
        self.page = page

        self.set_title(escape_text(entry.name))
        self.set_subtitle(escape_text(make_subtitle(entry)))
        self.add_prefix(row_icon(entry.icon))

        enabled_switch = Gtk.Switch(valign=Gtk.Align.CENTER)
        enabled_switch.set_active(entry.enabled)
        enabled_switch.set_tooltip_text(_("Run at Login"))
        enabled_switch.connect("state-set", self.on_switch_state_set)
        self.add_suffix(enabled_switch)
        self.set_activatable_widget(enabled_switch)

        edit_button = icon_button("document-edit-symbolic", _("Edit"))
        edit_button.connect("clicked", self.on_edit_clicked)
        self.add_suffix(edit_button)

        remove_button = icon_button("user-trash-symbolic", _("Remove"))
        remove_button.add_css_class("destructive-action")
        remove_button.connect("clicked", self.on_remove_clicked)
        self.add_suffix(remove_button)

    def on_switch_state_set(self, _switch, state):
        self.page.set_entry_enabled(self.entry, state)
        return False

    def on_edit_clicked(self, _button):
        self.page.edit_entry(self.entry)

    def on_remove_clicked(self, _button):
        self.page.remove_entry(self.entry)


class StartupApplicationDialog(Adw.Dialog):
    def __init__(self, page, entry=None):
        self.page = page
        self.entry = entry
        super().__init__(
            title=_("Edit Startup Application") if entry else _("Add Startup Application")
        )
        self.set_content_width(480)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        self.save_button = Gtk.Button(
            label=_("Save") if entry else _("Add"),
            valign=Gtk.Align.CENTER,
        )
        self.save_button.add_css_class("suggested-action")
        self.save_button.connect("clicked", self.on_save_clicked)
        header.pack_end(self.save_button)

        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=18,
            margin_top=18,
            margin_bottom=18,
            margin_start=18,
            margin_end=18,
        )

        enable_group = Adw.PreferencesGroup()
        content.append(enable_group)

        self.enable_row = Adw.SwitchRow(
            title=_("Enable"),
            subtitle=_("Enable this entry to be started when you log in."),
        )
        self.enable_row.set_active(True if entry is None else entry.enabled)
        enable_group.add(self.enable_row)

        group = Adw.PreferencesGroup()
        content.append(group)

        self.name_row = Adw.EntryRow(title=_("Name"))
        self.command_row = Adw.EntryRow(title=_("Command or Script"))
        self.comment_row = Adw.EntryRow(title=_("Comment"))
        command_file_button = icon_button("folder-open-symbolic", _("Choose Command"))
        command_file_button.connect("clicked", self.on_choose_command_clicked)
        self.command_row.add_suffix(command_file_button)

        if entry:
            self.name_row.set_text(entry.name)
            self.command_row.set_text(entry.command)
            self.comment_row.set_text(entry.comment)

        self.name_row.connect("notify::text", self.update_save_button)
        self.command_row.connect("notify::text", self.update_save_button)

        group.add(self.name_row)
        group.add(self.comment_row)
        group.add(self.command_row)

        options_group = Adw.PreferencesGroup()
        content.append(options_group)

        self.terminal_row = Adw.SwitchRow(
            title=_("Show Terminal"),
            subtitle=_("Show a terminal for this process to run in."),
        )
        self.terminal_row.set_active(False if entry is None else entry.terminal)
        options_group.add(self.terminal_row)

        adjustment = Gtk.Adjustment(lower=0, upper=600, step_increment=1)
        self.delay_row = Adw.SpinRow(
            title=_("Seconds of Delay"),
            subtitle=_("Amount of seconds to wait after logging in to run the command or script."),
            adjustment=adjustment,
        )
        self.delay_row.set_value(0 if entry is None else entry.delay)
        options_group.add(self.delay_row)

        toolbar_view.set_content(content)
        self.set_child(toolbar_view)
        self.update_save_button()

    def update_save_button(self, *_args):
        self.save_button.set_sensitive(
            bool(self.name_row.get_text().strip())
            and bool(self.command_row.get_text().strip())
        )

    def on_save_clicked(self, _button):
        if self.page.save_entry(
            self.name_row.get_text().strip(),
            self.command_row.get_text().strip(),
            self.comment_row.get_text().strip(),
            self.enable_row.get_active(),
            self.terminal_row.get_active(),
            int(self.delay_row.get_value()),
            self.entry,
        ):
            self.close()

    def on_choose_command_clicked(self, _button):
        dialog = Gtk.FileDialog()

        def on_file_chosen(_dialog, result):
            try:
                file = dialog.open_finish(result)
            except GLib.Error:
                return
            if file is not None and file.get_path():
                self.command_row.set_text(file.get_path())

        dialog.open(self.get_root(), None, on_file_chosen)


def icon_button(icon_name, tooltip):
    button = Gtk.Button.new_from_icon_name(icon_name)
    button.add_css_class("circular")
    button.add_css_class("flat")
    button.set_valign(Gtk.Align.CENTER)
    button.set_tooltip_text(tooltip)
    return button


def row_icon(icon):
    image = Gtk.Image()
    image.set_pixel_size(ROW_ICON_SIZE)
    image.set_size_request(ROW_ICON_SIZE, ROW_ICON_SIZE)
    image.set_halign(Gtk.Align.CENTER)
    image.set_valign(Gtk.Align.CENTER)

    if icon is None:
        image.set_from_icon_name("system-run-symbolic")
    else:
        image.set_from_gicon(icon)

    return image


def make_subtitle(entry):
    parts = []
    if entry.comment:
        parts.append(entry.comment)
    parts.append(entry.command)
    return "\n".join(parts)


def escape_text(text):
    return GLib.markup_escape_text(text or "")


def is_system_entry(entry):
    return entry.source == "system" or bool(entry.system_path)


def load_autostart_entries(include_system=False):
    entries = {}
    hidden_entries = set()
    system_paths = get_system_desktop_paths()

    if include_system:
        for desktop_id, path in system_paths.items():
            try:
                entry = read_autostart_entry(path, desktop_id, "system")
            except GLib.Error:
                continue
            if entry is not None and not is_hidden(path):
                entries[desktop_id] = entry

    for entry in load_entries_from_directory(AUTOSTART_DIR, "user", include_hidden=True):
        if is_hidden(entry.path):
            hidden_entries.add(entry.desktop_id)
            entries.pop(entry.desktop_id, None)
        else:
            entry.system_path = system_paths.get(entry.desktop_id, "")
            entries[entry.desktop_id] = entry

    return sorted(
        [entry for entry in entries.values() if entry.desktop_id not in hidden_entries],
        key=lambda entry: entry.name.casefold(),
    )


def get_system_desktop_paths():
    paths = {}
    for directory in reversed(get_system_autostart_dirs()):
        if not os.path.isdir(directory):
            continue
        for filename in os.listdir(directory):
            if filename.endswith(".desktop"):
                paths[filename] = os.path.join(directory, filename)
    return paths


def get_system_autostart_dirs():
    config_dirs = os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")
    return [
        os.path.join(directory, "autostart")
        for directory in config_dirs
        if directory
    ]


def load_entries_from_directory(directory, source, include_hidden=False):
    if not os.path.isdir(directory):
        return []

    entries = []
    for filename in os.listdir(directory):
        if not filename.endswith(".desktop"):
            continue

        path = os.path.join(directory, filename)
        try:
            entry = read_autostart_entry(path, filename, source)
        except GLib.Error:
            continue

        if entry is None:
            continue
        if not include_hidden and is_hidden(path):
            continue
        entries.append(entry)

    return entries


def read_autostart_entry(path, desktop_id, source):
    keyfile = load_keyfile(path)
    if get_string(keyfile, "Type", "Application") != "Application":
        return None

    name = get_locale_string(keyfile, "Name", desktop_id.removesuffix(".desktop"))
    command = get_string(keyfile, "Exec", "")
    comment = get_locale_string(keyfile, "Comment", "")
    enabled = get_boolean(keyfile, "X-GNOME-Autostart-enabled", True)
    terminal = get_boolean(keyfile, "Terminal", False)
    delay = get_int(keyfile, "X-GNOME-Autostart-Delay", 0)
    icon = get_icon(keyfile, "Icon", None)
    app_info = find_app_info(desktop_id, command, keyfile)

    if app_info is not None:
        app_name = app_info.get_display_name() or app_info.get_name()
        if app_name and (
            is_flatpak_entry(command, keyfile) or should_prefer_app_name(name, desktop_id)
        ):
            name = app_name
        if not comment and app_info.get_description():
            comment = app_info.get_description()
        if icon is None and app_info.get_icon() is not None:
            icon = app_info.get_icon()

    return AutostartEntry(
        desktop_id, path, name, command, comment, enabled, source, terminal, delay, icon
    )


def load_keyfile(path):
    keyfile = GLib.KeyFile()
    keyfile.load_from_file(path, GLib.KeyFileFlags.KEEP_COMMENTS)
    return keyfile


def is_hidden(path):
    try:
        return get_boolean(load_keyfile(path), "Hidden", False)
    except GLib.Error:
        return False


def write_autostart_entry(
    desktop_id,
    name,
    command,
    comment,
    enabled,
    terminal=False,
    delay=0,
    source_path=None,
    hidden=False,
):
    os.makedirs(AUTOSTART_DIR, exist_ok=True)
    path = os.path.join(AUTOSTART_DIR, desktop_id)

    if source_path and os.path.exists(source_path):
        keyfile = load_keyfile(source_path)
    else:
        keyfile = GLib.KeyFile()

    keyfile.set_string(DESKTOP_GROUP, "Type", "Application")
    keyfile.set_string(DESKTOP_GROUP, "Name", name)
    keyfile.set_string(DESKTOP_GROUP, "Exec", command)
    keyfile.set_boolean(DESKTOP_GROUP, "X-GNOME-Autostart-enabled", enabled)
    keyfile.set_boolean(DESKTOP_GROUP, "Terminal", terminal)
    keyfile.set_integer(DESKTOP_GROUP, "X-GNOME-Autostart-Delay", delay)
    keyfile.set_boolean(DESKTOP_GROUP, "Hidden", hidden)

    if comment:
        keyfile.set_string(DESKTOP_GROUP, "Comment", comment)
    else:
        try:
            keyfile.remove_key(DESKTOP_GROUP, "Comment")
        except GLib.Error:
            pass

    keyfile.save_to_file(path)
    return path


def remove_autostart_entry(entry):
    if (
        entry.source == "user"
        and not entry.system_path
        and os.path.commonpath([AUTOSTART_DIR, entry.path]) == AUTOSTART_DIR
    ):
        os.remove(entry.path)
        return

    write_autostart_entry(
        entry.desktop_id,
        entry.name,
        entry.command,
        entry.comment,
        False,
        terminal=entry.terminal,
        delay=entry.delay,
        source_path=entry.path,
        hidden=True,
    )


def new_desktop_id(name):
    slug = re.sub(r"[^a-z0-9]+", "-", name.casefold()).strip("-")
    if not slug:
        slug = "startup-application"

    candidate = f"stillcontrol-{slug}.desktop"
    counter = 2
    while os.path.exists(os.path.join(AUTOSTART_DIR, candidate)):
        candidate = f"stillcontrol-{slug}-{counter}.desktop"
        counter += 1
    return candidate


def find_app_info(desktop_id, command, keyfile):
    flatpak_id = get_string(keyfile, "X-Flatpak", "")
    if flatpak_id:
        flatpak_app_info = find_flatpak_app_info(flatpak_id)
        if flatpak_app_info is not None:
            return flatpak_app_info

    for app_id in get_candidate_app_ids(desktop_id, command, keyfile):
        app_info = get_desktop_app_info(app_id)
        if app_info is not None:
            return app_info

    executable = get_command_executable(command)
    if not executable or os.path.basename(executable) == "flatpak":
        return None

    return get_app_info_by_executable(os.path.basename(executable))


def get_candidate_app_ids(desktop_id, command, keyfile):
    candidates = [desktop_id]
    flatpak_id = get_string(keyfile, "X-Flatpak", "")
    if flatpak_id:
        candidates.append(f"{flatpak_id}.desktop")

    seen = set()
    unique = []
    for candidate in candidates:
        if candidate and candidate not in seen:
            seen.add(candidate)
            unique.append(candidate)
    return unique


def is_flatpak_entry(command, keyfile):
    return bool(get_string(keyfile, "X-Flatpak", ""))


def find_flatpak_app_info(flatpak_id):
    app_info = get_desktop_app_info(f"{flatpak_id}.desktop")
    if app_info is not None:
        return app_info

    desktop_path = find_flatpak_desktop_path(flatpak_id)
    if desktop_path is None:
        return None

    try:
        return Gio.DesktopAppInfo.new_from_filename(desktop_path)
    except TypeError:
        return None


def find_flatpak_desktop_path(flatpak_id):
    for directory in get_flatpak_application_dirs():
        path = os.path.join(directory, f"{flatpak_id}.desktop")
        if os.path.exists(path):
            return path
    return None


def get_flatpak_application_dirs():
    data_dirs = [
        os.path.join(GLib.get_user_data_dir(), "flatpak", "exports", "share"),
        "/var/lib/flatpak/exports/share",
    ]
    data_dirs.extend(
        os.path.join(directory, "flatpak", "exports", "share")
        for directory in GLib.get_system_data_dirs()
    )

    application_dirs = []
    seen = set()
    for directory in data_dirs:
        app_dir = os.path.join(directory, "applications")
        if app_dir not in seen:
            seen.add(app_dir)
            application_dirs.append(app_dir)
    return application_dirs


def get_desktop_app_info(app_id):
    if app_id not in _app_info_by_id:
        try:
            _app_info_by_id[app_id] = Gio.DesktopAppInfo.new(app_id)
        except TypeError:
            _app_info_by_id[app_id] = None
    return _app_info_by_id[app_id]


def get_app_info_by_executable(executable_name):
    global _app_infos_by_executable

    if _app_infos_by_executable is None:
        _app_infos_by_executable = {}
        for app_info in Gio.AppInfo.get_all():
            app_executable = get_app_executable(app_info)
            if app_executable:
                _app_infos_by_executable.setdefault(
                    os.path.basename(app_executable), app_info
                )

    return _app_infos_by_executable.get(executable_name)


def get_app_executable(app_info):
    if hasattr(app_info, "get_executable"):
        executable = app_info.get_executable()
        if executable:
            return executable

    commandline = app_info.get_commandline()
    if commandline:
        return get_command_executable(commandline)

    return None


def get_command_executable(command):
    cleaned = remove_desktop_exec_codes(command)
    try:
        _success, argv = GLib.shell_parse_argv(cleaned)
    except GLib.Error:
        return ""
    return argv[0] if argv else ""


def remove_desktop_exec_codes(command):
    return re.sub(r"%[fFuUdDnNickvm]", "", command).strip()


def should_prefer_app_name(name, desktop_id):
    desktop_stem = desktop_id.removesuffix(".desktop")
    return (
        not name
        or name == desktop_id
        or name == desktop_stem
        or looks_like_application_id(name)
    )


def looks_like_application_id(name):
    return bool(re.fullmatch(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+", name))


def get_icon(keyfile, key, default):
    icon_name = get_string(keyfile, key, "")
    if not icon_name:
        return default

    if os.path.isabs(icon_name):
        return Gio.FileIcon.new(Gio.File.new_for_path(icon_name))

    return Gio.ThemedIcon.new(icon_name)


def get_string(keyfile, key, default):
    try:
        return keyfile.get_string(DESKTOP_GROUP, key)
    except GLib.Error:
        return default


def get_locale_string(keyfile, key, default):
    try:
        return keyfile.get_locale_string(DESKTOP_GROUP, key, None)
    except GLib.Error:
        return default


def get_boolean(keyfile, key, default):
    try:
        return keyfile.get_boolean(DESKTOP_GROUP, key)
    except GLib.Error:
        return default


def get_int(keyfile, key, default):
    try:
        return keyfile.get_integer(DESKTOP_GROUP, key)
    except GLib.Error:
        return default
