#!/usr/bin/env python3
# -------------------------------------------------------------------------------------------------
# Name: Lite User Manager
# Description: A simple GTK4 front-end for user password and group management
# License: GNU GPL v2
# -------------------------------------------------------------------------------------------------
import os
import sys
import pwd
import grp
import subprocess
import getpass
from typing import List, Tuple, Dict, Optional

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

APP_ID = "com.linuxlite.UserManager"
APP_NAME = "Lite User Manager"
ICON_NAME = "liteusermanager"   # Ensure .desktop uses this icon name

# Preferred on-disk icon fallbacks (same pattern you use across Lite apps)
FALLBACK_ICON_PATHS = [
    "/usr/share/icons/hicolor/256x256/apps/liteusermanager.png",
    "/usr/share/icons/hicolor/128x128/apps/liteusermanager.png",
    "/usr/share/icons/hicolor/64x64/apps/liteusermanager.png",
    "/usr/share/pixmaps/liteusermanager.png",
]

# If you bundle icons as a GResource (like the Upgrade app), list those resource roots here
ICON_RESOURCE_PATHS = [
    # e.g. "/com/linuxlite/LiteUserManager/icons"
]

SYSTEM_UID_MIN = 1000  # filter out system users by default

# -------------------------------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------------------------------

def is_root() -> bool:
    try:
        return os.geteuid() == 0
    except Exception:
        # Non-POSIX environments
        return False


def run_cmd(cmd: List[str], input_bytes: Optional[bytes] = None) -> Tuple[int, str]:
    """Run a command, return (exit_code, combined_output).
    If not root, try pkexec. Assumes the app itself was started via pkexec,
    but this is a safety net for direct execution.
    """
    full_cmd = cmd
    if not is_root():
        full_cmd = ["pkexec", *cmd]
    try:
        p = subprocess.Popen(
            full_cmd,
            stdin=subprocess.PIPE if input_bytes else None,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=False,
        )
        out, _ = p.communicate(input_bytes)
        return p.returncode, out.decode(errors="replace")
    except FileNotFoundError as e:
        return 127, f"Command not found: {e}"
    except Exception as e:
        return 127, f"Error: {e}"


def list_real_users() -> List[pwd.struct_passwd]:
    # Return users with UID >= SYSTEM_UID_MIN and valid shells
    users = []
    for u in pwd.getpwall():
        if u.pw_uid >= SYSTEM_UID_MIN and \
           not u.pw_name.startswith("_") and \
           "/nologin" not in (u.pw_shell or "") and \
           "/false" not in (u.pw_shell or ""):
            users.append(u)
    # Sort with current user first if present, then alphabetically
    current = getpass.getuser()
    users.sort(key=lambda x: (x.pw_name != current, x.pw_name))
    return users


def list_all_groups() -> List[grp.struct_group]:
    gs = list(grp.getgrall())
    # Filter out extremely systemy groups but keep common admin/dev groups
    def keep(g: grp.struct_group):
        name = g.gr_name
        if name in {"root", "sudo", "adm", "cdrom", "dip", "plugdev", "lpadmin", "lxd", "docker", "audio", "video"}:
            return True
        # Drop some very low-level system groups by gid
        return g.gr_gid >= 100
    return sorted([g for g in gs if keep(g)], key=lambda g: g.gr_name)


def groups_for_user(username: str) -> List[str]:
    user_groups = []
    for g in grp.getgrall():
        if username in g.gr_mem:
            user_groups.append(g.gr_name)
    # Also include primary group name
    try:
        primary_gid = pwd.getpwnam(username).pw_gid
        primary_group = grp.getgrgid(primary_gid).gr_name
        if primary_group not in user_groups:
            user_groups.append(primary_group)
    except Exception:
        pass
    return sorted(set(user_groups))


def set_password(username: str, new_password: str) -> Tuple[bool, str]:
    """Set a user's password using chpasswd via stdin (payload: "user:newpass\n")."""
    payload_str = f"{username}:{new_password}\n"
    payload = payload_str.encode("utf-8", "strict")
    code, out = run_cmd(["chpasswd"], input_bytes=payload)
    return code == 0, out

# ---------------------------
# GTK4 dialog helper (since .run() is gone)
# ---------------------------

def run_dialog(dialog: Gtk.Dialog) -> int:
    """Present a GTK4 dialog and block with a nested main loop until response.
    Returns the Gtk.ResponseType integer. Destroys the dialog after closing.
    """
    resp_holder: Dict[str, Optional[int]] = {"resp": None}
    loop = GLib.MainLoop()

    def _on_response(dlg: Gtk.Dialog, response_id: int):
        resp_holder["resp"] = response_id
        if loop.is_running():
            loop.quit()

    dialog.connect("response", _on_response)
    dialog.present()
    loop.run()
    dialog.destroy()
    return resp_holder["resp"] if resp_holder["resp"] is not None else Gtk.ResponseType.CANCEL


def add_user(username: str, password: str) -> Tuple[bool, str]:
    """Create a new user (non-interactive), then set the password."""
    code, out = run_cmd(["adduser", "--disabled-password", "--gecos", "", username])
    if code != 0:
        return False, out
    ok, out2 = set_password(username, password)

    default_groups = ["adm", "cdrom", "dip", "lpadmin", "plugdev", "sambashare"]
    group_msgs: List[str] = []
    for g in default_groups:
        ok_g, out_g = modify_group_membership(username, g, True)
        if not ok_g and out_g:
            group_msgs.append(f"[warn] could not add to {g}: {out_g.strip()}")

    extra = out2 or ""
    if group_msgs:
        extra = (extra + ("\n" if extra else "")) + "\n".join(group_msgs)

    return ok, out + (("\n" + extra) if extra else "")


def delete_user(username: str) -> Tuple[bool, str]:
    code, out = run_cmd(["userdel", "-r", username])
    return code == 0, out


def modify_group_membership(username: str, group: str, should_be_member: bool) -> Tuple[bool, str]:
    if should_be_member:
        code, out = run_cmd(["gpasswd", "-a", username, group])
    else:
        code, out = run_cmd(["gpasswd", "-d", username, group])
    return code == 0, out


# -------------------------------------------------------------------------------------------------
# UI Widgets
# -------------------------------------------------------------------------------------------------

class NewUserDialog(Gtk.Dialog):
    def __init__(self, parent: Gtk.Window):
        super().__init__(title="Add New User", transient_for=parent, modal=True)
        self.set_default_size(420, 160)

        box = self.get_content_area()
        grid = Gtk.Grid(column_spacing=8, row_spacing=8, margin_top=12, margin_bottom=12, margin_start=12, margin_end=12)
        box.append(grid)

        self.entry_user = Gtk.Entry(placeholder_text="username")
        self.entry_pass = Gtk.PasswordEntry(placeholder_text="password")
        self.entry_pass_confirm = Gtk.PasswordEntry(placeholder_text="confirm password")

        grid.attach(Gtk.Label(label="Username"), 0, 0, 1, 1)
        grid.attach(self.entry_user, 1, 0, 1, 1)
        grid.attach(Gtk.Label(label="Password"), 0, 1, 1, 1)
        grid.attach(self.entry_pass, 1, 1, 1, 1)
        grid.attach(Gtk.Label(label="Confirm"), 0, 2, 1, 1)
        grid.attach(self.entry_pass_confirm, 1, 2, 1, 1)

        self.add_buttons(
            "Cancel", Gtk.ResponseType.CANCEL,
            "Add User", Gtk.ResponseType.OK,
        )
        self.set_default_response(Gtk.ResponseType.OK)

    def get_values(self) -> Optional[Tuple[str, str]]:
        if run_dialog(self) == Gtk.ResponseType.OK:
            u = (self.entry_user.get_text() or "").strip()
            p1 = self.entry_pass.get_text()
            p2 = self.entry_pass_confirm.get_text()
            self.destroy()
            if not u:
                return None
            if p1 != p2:
                return None
            return u, p1
        self.destroy()
        return None


class ChangePasswordDialog(Gtk.Dialog):
    def __init__(self, parent: Gtk.Window, username: str):
        super().__init__(title=f"Change Password — {username}", transient_for=parent, modal=True)
        self.set_default_size(420, 140)
        self.username = username

        box = self.get_content_area()
        grid = Gtk.Grid(column_spacing=8, row_spacing=8, margin_top=12, margin_bottom=12, margin_start=12, margin_end=12)
        box.append(grid)

        self.entry_pass = Gtk.PasswordEntry(placeholder_text="new password")
        self.entry_pass_confirm = Gtk.PasswordEntry(placeholder_text="confirm password")

        grid.attach(Gtk.Label(label="New Password"), 0, 0, 1, 1)
        grid.attach(self.entry_pass, 1, 0, 1, 1)
        grid.attach(Gtk.Label(label="Confirm"), 0, 1, 1, 1)
        grid.attach(self.entry_pass_confirm, 1, 1, 1, 1)

        self.add_buttons(
            "Cancel", Gtk.ResponseType.CANCEL,
            "Change", Gtk.ResponseType.OK,
        )
        self.set_default_response(Gtk.ResponseType.OK)

    def get_value(self) -> Optional[str]:
        if run_dialog(self) == Gtk.ResponseType.OK:
            p1 = self.entry_pass.get_text()
            p2 = self.entry_pass_confirm.get_text()
            self.destroy()
            if p1 and p1 == p2:
                return p1
            return None
        self.destroy()
        return None


class GroupChecklist(Gtk.ScrolledWindow):
    def __init__(self):
        super().__init__()
        self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        self.set_min_content_height(260)
        self.set_min_content_width(420)

        self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, margin_top=6, margin_bottom=6, margin_start=6, margin_end=6)
        self.set_child(self.vbox)

        header = Gtk.Label(label="Groups", css_classes=["heading"])
        header.set_xalign(0)
        self.vbox.append(header)

        self.checks: Dict[str, Gtk.CheckButton] = {}
        self.groups = list_all_groups()
        for g in self.groups:
            cb = Gtk.CheckButton(label=g.gr_name)
            self.vbox.append(cb)
            self.checks[g.gr_name] = cb

    def set_memberships(self, username: str):
        current = set(groups_for_user(username))
        for name, cb in self.checks.items():
            # Primary group membership is informational; disable toggling it
            try:
                primary_gid = pwd.getpwnam(username).pw_gid
                primary_group = grp.getgrgid(primary_gid).gr_name
            except Exception:
                primary_group = None
            if name == primary_group:
                cb.set_active(True)
                cb.set_sensitive(False)
            else:
                cb.set_sensitive(True)
                cb.set_active(name in current)

    def desired_memberships(self) -> Dict[str, bool]:
        return {name: cb.get_active() for name, cb in self.checks.items() if cb.get_sensitive()}


class UsersPage(Gtk.Box):
    def __init__(self, app: "UserManagerApp"):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18)
        self.set_margin_top(12)
        self.set_margin_bottom(12)
        self.set_margin_start(12)
        self.set_margin_end(12)
        self.app = app

        self.left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)

        # User selector (GTK4-native)
        self.user_store = Gtk.StringList()
        self.user_drop = Gtk.DropDown(model=self.user_store)
        self.user_drop.connect("notify::selected", self.on_user_changed)
        self.left.append(Gtk.Label(label="Select User", css_classes=["heading"]))
        self.left.append(self.user_drop)

        # Action buttons
        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self.btn_new = Gtk.Button(label="New User")
        self.btn_del = Gtk.Button(label="Delete User…")
        self.btn_pass = Gtk.Button(label="Change Password…")
        self.btn_refresh = Gtk.Button(label="Refresh")
        for b in (self.btn_new, self.btn_del, self.btn_pass, self.btn_refresh):
            btn_box.append(b)
        self.left.append(btn_box)

        # Group checklist on the right
        self.group_list = GroupChecklist()
        self.group_list.set_hexpand(True)
        self.group_list.set_vexpand(True)

        # Apply groups button
        self.btn_apply_groups = Gtk.Button(label="Apply Group Changes")
        self.left.append(self.btn_apply_groups)

        # Use a draggable paned split so columns can be resized
        self.paned = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
        self.paned.set_start_child(self.left)
        self.paned.set_end_child(self.group_list)
        self.append(self.paned)

        # Set left pane to 70%, right pane to 30% (GTK4, no deprecated APIs)
        self._paned_ratio_set = False

        def _apply_paned_ratio_once(_widget):
            if self._paned_ratio_set:
                return

            def _idle_try_set():
                width = self.paned.get_width()
                if width > 0:
                    self.paned.set_position(int(width * 0.7))
                    self._paned_ratio_set = True
                    return False
                return True

            GLib.idle_add(_idle_try_set)

        self.paned.connect("map", _apply_paned_ratio_once)

        # Populate users now that UI pieces exist
        self.refresh_users()

        # Connect signals
        self.btn_new.connect("clicked", self.on_new_user)
        self.btn_del.connect("clicked", self.on_delete_user)
        self.btn_pass.connect("clicked", self.on_change_password)
        self.btn_refresh.connect("clicked", lambda *_: self.refresh_users())
        self.btn_apply_groups.connect("clicked", self.on_apply_groups)

        # Initialize selection
        if self.user_drop.get_selected() < 0 and self.user_store.get_n_items() > 0:
            self.user_drop.set_selected(0)
        self._refresh_side_state()

    # ---------------------------------------------------------------------------------------------
    # Helpers & signals
    # ---------------------------------------------------------------------------------------------
    def current_username(self) -> Optional[str]:
        idx = self.user_drop.get_selected()
        if idx < 0:
            return None
        model = self.user_drop.get_model()
        try:
            return model.get_string(idx)
        except Exception:
            return None

    def refresh_users(self):
        active_before = self.current_username()
        # rebuild model
        new_store = Gtk.StringList()
        for u in list_real_users():
            new_store.append(u.pw_name)
        self.user_store = new_store
        self.user_drop.set_model(self.user_store)

        # restore selection if possible
        selected_index = -1
        if active_before is not None:
            for i in range(self.user_store.get_n_items()):
                if self.user_store.get_string(i) == active_before:
                    selected_index = i
                    break
        if selected_index >= 0:
            self.user_drop.set_selected(selected_index)
        elif self.user_store.get_n_items() > 0:
            self.user_drop.set_selected(0)

        self.on_user_changed()

    def on_user_changed(self, *args):
        u = self.current_username()
        if not u:
            return
        self.group_list.set_memberships(u)
        self._refresh_side_state()

    def _refresh_side_state(self):
        u = self.current_username() or ""
        is_current = (u == getpass.getuser())
        # Prevent deleting the logged-in user to avoid foot-guns
        self.btn_del.set_sensitive(bool(u) and not is_current)
        self.btn_pass.set_sensitive(bool(u))
        self.btn_apply_groups.set_sensitive(bool(u))

    def on_new_user(self, *_):
        dlg = NewUserDialog(self.get_root())
        res = dlg.get_values()
        if not res:
            self.app.toast("Cancelled or invalid details.")
            return
        username, password = res
        ok, out = add_user(username, password)
        if ok:
            self.app.toast(f"User '{username}' added.")
            self.refresh_users()
            self._select_username(username)
        else:
            self.app.error(f"Failed to add user '{username}'.\n\n{out}")

    def _select_username(self, username: str):
        for i in range(self.user_store.get_n_items()):
            if self.user_store.get_string(i) == username:
                self.user_drop.set_selected(i)
                break

    def on_delete_user(self, *_):
        u = self.current_username()
        if not u:
            return
        dialog = Gtk.MessageDialog(transient_for=self.get_root(), modal=True,
                                   buttons=Gtk.ButtonsType.NONE,
                                   message_type=Gtk.MessageType.QUESTION,
                                   text=f"Remove user '{u}' and their home directory?")
        dialog.add_buttons("Cancel", Gtk.ResponseType.CANCEL, "Delete", Gtk.ResponseType.OK)
        resp = run_dialog(dialog)
        dialog.destroy()
        if resp != Gtk.ResponseType.OK:
            return
        ok, out = delete_user(u)
        if ok:
            self.app.toast(f"User '{u}' removed.")
            self.refresh_users()
        else:
            self.app.error(f"Failed to remove '{u}'.{out}")

    def on_change_password(self, *_):
        u = self.current_username()
        if not u:
            return
        dlg = ChangePasswordDialog(self.get_root(), u)
        newp = dlg.get_value()
        if not newp:
            self.app.toast("Password change cancelled or mismatch.")
            return
        ok, out = set_password(u, newp)
        if ok:
            self.app.toast(f"Password updated for '{u}'.")
        else:
            self.app.error(f"Failed to change password for '{u}'.{out}")

    def on_apply_groups(self, *_):
        u = self.current_username()
        if not u:
            return
        desired = self.group_list.desired_memberships()
        current = set(groups_for_user(u))
        # Apply differences
        errors: List[str] = []
        changes: List[str] = []
        for g, should in desired.items():
            if should and g not in current:
                ok, out = modify_group_membership(u, g, True)
                if ok:
                    changes.append(f"Added {u} to {g}")
                else:
                    errors.append(f"Add {u} to {g}: {out}")
            elif (not should) and g in current:
                ok, out = modify_group_membership(u, g, False)
                if ok:
                    changes.append(f"Removed {u} from {g}")
                else:
                    errors.append(f"Remove {u} from {g}: {out}")
        # Feedback popups
        if changes:
            self.app.toast("\n".join(changes))
        if errors:
            self.app.error("Some changes failed:\n\n" + "\n".join(errors))
        # Refresh memberships view
        self.group_list.set_memberships(u)


class HelpPage(Gtk.ScrolledWindow):
    def __init__(self):
        super().__init__()
        self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        self.set_min_content_height(280)
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, margin_top=12, margin_bottom=12, margin_start=12, margin_end=12)
        self.set_child(box)
        label = Gtk.Label(xalign=0)
        label.set_wrap(True)
        label.set_use_markup(True)
        label.set_markup(
            """
<b>Lite User Manager</b>


• Add or remove users (home directory deleted on removal).

• Change user passwords.

• Add or remove users from common groups.
            """
        )
        box.append(label)


class AboutPage(Gtk.Box):
    def __init__(self):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.set_margin_top(16)
        self.set_margin_bottom(16)
        self.set_margin_start(16)
        self.set_margin_end(16)
        title = Gtk.Label(label=f"{APP_NAME}")
        title.add_css_class("title-1")
        subtitle = Gtk.Label(label="A simple front-end for user and group management")
        subtitle.add_css_class("subtitle-1")
        credit = Gtk.Label()
        credit.set_use_markup(True)
        credit.set_markup("© 2013–2025 Linux Lite Dev Team")

        for w in (title, subtitle, credit):
            w.set_xalign(0)
            self.append(w)


class MainWindow(Gtk.ApplicationWindow):
    def __init__(self, app: "UserManagerApp"):
        super().__init__(application=app)
        self.set_title(APP_NAME)
        self.set_default_size(820, 480)

        # Modern GTK4 HeaderBar (client-side decorations)
        hb = Gtk.HeaderBar()
        hb.set_title_widget(Gtk.Label(label=APP_NAME))
        self.set_titlebar(hb)

        # Top-level container
        root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.set_child(root)

        # Notebook
        self.notebook = Gtk.Notebook()
        root.append(self.notebook)

        self.users_page = UsersPage(app)
        self.notebook.append_page(self.users_page, Gtk.Label(label="Users"))
        self.notebook.append_page(HelpPage(), Gtk.Label(label="Help"))
        self.notebook.append_page(AboutPage(), Gtk.Label(label="About"))


class UserManagerApp(Gtk.Application):
    def __init__(self):
        super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE)
        self._prime_icons()

    def _prime_icons(self):
        """Match the Lite Series Upgrade app behavior:
        1) Register icon resource/search paths so the theme can resolve ICON_NAME.
        2) Set default icon name for all app windows (taskbar/dock depends on .desktop Icon=... but this helps).
        3) Provide on-disk fallbacks so non-resource installs still pick up the icon.
        """
        display = Gdk.Display.get_default()
        if not display:
            return
        theme = Gtk.IconTheme.get_for_display(display)

        # 1) Register resource paths (when icons are shipped via GResource)
        for rpath in ICON_RESOURCE_PATHS:
            try:
                theme.add_resource_path(rpath)
            except Exception:
                pass

        # Also register common on-disk search paths to mirror your other apps
        for dpath in [
            "/usr/share/icons/hicolor/256x256/apps",
            "/usr/share/icons/hicolor/128x128/apps",
            "/usr/share/icons/hicolor/64x64/apps",
            "/usr/share/pixmaps",
        ]:
            try:
                theme.add_search_path(dpath)
            except Exception:
                pass

        # 2) Set default icon name (window/taskbar icon where respected)
        try:
            Gtk.Window.set_default_icon_name(ICON_NAME)
        except Exception:
            pass

        # 3) If theme can't find ICON_NAME, try an explicit file-based fallback
        if not theme.has_icon(ICON_NAME):
            for p in FALLBACK_ICON_PATHS:
                if os.path.exists(p):
                    # GTK4 removed per-window set_icon() API; many shells use .desktop Icon.
                    # We still nudge the theme to include our path (above) so lookups succeed.
                    # Nothing else to do here.
                    break

    def do_activate(self, *args):
        # Single window
        if not self.get_active_window():
            win = MainWindow(self)
            win.present()
        else:
            self.get_active_window().present()

    # Convenience: simple toast via a transient dialog
    def toast(self, message: str):
        win = self.get_active_window()
        if not win:
            return
        dialog = Gtk.MessageDialog(transient_for=win, modal=True,
                                   buttons=Gtk.ButtonsType.OK,
                                   message_type=Gtk.MessageType.INFO,
                                   text=message)
        run_dialog(dialog)
        dialog.destroy()

    def error(self, message: str):
        win = self.get_active_window()
        if not win:
            return
        dialog = Gtk.MessageDialog(transient_for=win, modal=True,
                                   buttons=Gtk.ButtonsType.CLOSE,
                                   message_type=Gtk.MessageType.ERROR,
                                   text=message)
        run_dialog(dialog)
        dialog.destroy()


def main(argv: Optional[List[str]] = None) -> int:
    if argv is None:
        argv = sys.argv

    # Nudge users to run as root; still works if launched without, commands fall back to pkexec
    if not is_root():
        pass

    # Ensure default icon name is set ASAP (mirrors other Lite apps)
    try:
        Gtk.Window.set_default_icon_name(ICON_NAME)
    except Exception:
        pass

    app = UserManagerApp()
    return app.run(argv)


if __name__ == "__main__":
    raise SystemExit(main())
