#!/usr/bin/env python3
#--------------------------------------------------------------------------------------------------------
# Name: Linux Lite - Lite Share Folder
# Architecture: amd64
# Author: Jerry Bezencon
# Website: https://www.linuxliteos.com
# Language: Python/GTK4
# Licence: GPLv2
#--------------------------------------------------------------------------------------------------------
# Per-folder Samba sharing helper, invoked from Thunar via Custom Action.
# Wraps `net usershare` so the user does not need root once they are in
# the sambashare group. First-run setup is performed by
# /usr/bin/lite-share-folder-setup via pkexec — it adds the user to
# sambashare, ensures smb.conf has the usershare stanza, enables smbd,
# opens firewalld, and (optionally) sets a Samba password for the user
# so remote machines can authenticate.

import os
import sys
import pwd
import grp
import subprocess
import threading
import re
import logging
import logging.handlers
from pathlib import Path

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

import gettext as _gt, locale as _loc
TEXTDOMAIN = "lite-share-folder"
try:
    _loc.setlocale(_loc.LC_ALL, "")
except _loc.Error:
    pass
_gt.bindtextdomain(TEXTDOMAIN, "/usr/share/locale")
_gt.textdomain(TEXTDOMAIN)
_ = _gt.translation(TEXTDOMAIN, "/usr/share/locale", fallback=True).gettext


# ----------------------------------------------------------------------
# logging — per-user, rotating, never records passwords. Lets field reports
# ("Share Folder did nothing") be self-diagnosing: errors here would otherwise
# only flash as a toast and the traceback go to a stderr nobody sees (Thunar
# launches us with no terminal).
# ----------------------------------------------------------------------
def _setup_log():
    lg = logging.getLogger("lite-share-folder")
    lg.setLevel(logging.INFO)
    lg.propagate = False
    try:
        state = os.environ.get("XDG_STATE_HOME") or os.path.join(
            os.path.expanduser("~"), ".local", "state")
        os.makedirs(state, exist_ok=True)
        h = logging.handlers.RotatingFileHandler(
            os.path.join(state, "lite-share-folder.log"),
            maxBytes=262144, backupCount=1, encoding="utf-8")
        h.setFormatter(logging.Formatter(
            "%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S"))
        lg.addHandler(h)
    except Exception:
        lg.addHandler(logging.NullHandler())
    return lg


log = _setup_log()


def _excepthook(et, ev, tb):
    try:
        log.error("uncaught exception", exc_info=(et, ev, tb))
    except Exception:
        pass
    sys.__excepthook__(et, ev, tb)


sys.excepthook = _excepthook


def _short(s):
    """One-line, truncated stderr for logging."""
    return (s or "").strip().replace("\n", " ")[:300]


APP_ID = "com.linuxliteos.LiteShareFolder"
SETUP_HELPER = "/usr/bin/lite-share-folder-setup"

# Absolute-path prefixes the user may not share. A folder is denied if it
# equals one of these or is a descendant.
DENY_ABSOLUTE = [
    "/etc", "/root", "/boot", "/proc", "/sys", "/dev", "/run",
    "/var/log", "/var/cache", "/var/lib",
    "/usr", "/snap",
]

# Home-relative folders treated as credential stores.
DENY_HOME_REL = [
    ".ssh", ".gnupg", ".config", ".local/share/keyrings",
    ".mozilla", ".thunderbird", ".pki", ".password-store",
    ".aws", ".kube", ".docker",
]

INVALID_SHARENAME_RE = re.compile(r"[^A-Za-z0-9_\-]")


# ----------------------------------------------------------------------
# helpers
# ----------------------------------------------------------------------

def run(cmd, **kw):
    p = subprocess.run(cmd, capture_output=True, text=True, **kw)
    return p.returncode, p.stdout, p.stderr


def run_with_stdin(cmd, stdin_text):
    """Run cmd, feeding stdin_text to its stdin. Returns (rc, stdout, stderr)."""
    p = subprocess.Popen(cmd, stdin=subprocess.PIPE,
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         text=True)
    out, err = p.communicate(input=stdin_text)
    return p.returncode, out, err


def current_user():
    return pwd.getpwuid(os.getuid()).pw_name


def user_in_sambashare_on_disk():
    """Is the user listed in /etc/group's sambashare entry? True
    immediately after gpasswd -a, even though the running process won't
    pick up the new GID until the next login."""
    user = current_user()
    try:
        return user in grp.getgrnam("sambashare").gr_mem
    except KeyError:
        return False


def process_can_usershare():
    """Does THIS process have sambashare in its kernel supplementary
    groups? Without this, `net usershare add` fails with a permission
    error even when /etc/group says the user is in the group."""
    try:
        gid = grp.getgrnam("sambashare").gr_gid
        return gid in os.getgroups()
    except KeyError:
        return False


def smbd_running():
    rc, _, _ = run(["systemctl", "is-active", "--quiet", "smbd"])
    return rc == 0


def folder_is_denied(path):
    try:
        p = Path(path).resolve()
    except (OSError, RuntimeError):
        return True
    s = str(p)

    if not p.is_dir():
        return True

    for prefix in DENY_ABSOLUTE:
        if s == prefix or s.startswith(prefix + "/"):
            return True

    home = str(Path.home())
    if s.startswith(home + "/"):
        rel = s[len(home) + 1:]
        for sub in DENY_HOME_REL:
            if rel == sub or rel.startswith(sub + "/"):
                return True

    if s == "/home":
        return True
    if s.startswith("/home/") and not s.startswith(home + "/") and s != home:
        return True

    return False


def sanitize_sharename(suggestion):
    s = INVALID_SHARENAME_RE.sub("_", suggestion).strip("_")
    if not s:
        s = "share"
    return s[:80]


def folder_to_default_name(path):
    base = os.path.basename(os.path.normpath(path)) or "share"
    return sanitize_sharename(base)


# ----------------------------------------------------------------------
# usershare backend
# ----------------------------------------------------------------------

def parse_usershare_info(text):
    info = {"name": None, "path": None, "comment": "", "acl": "", "guest": False}
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("[") and line.endswith("]"):
            info["name"] = line[1:-1]
        elif line.startswith("path="):
            info["path"] = line[5:]
        elif line.startswith("comment="):
            info["comment"] = line[8:]
        elif line.startswith("usershare_acl="):
            info["acl"] = line[len("usershare_acl="):]
        elif line.startswith("guest_ok="):
            info["guest"] = line.split("=", 1)[1].strip().lower() in ("y", "yes", "true", "1")
    return info


def list_usershares():
    out = {}
    rc, names, _ = run(["net", "usershare", "list"])
    if rc != 0:
        return out
    for name in names.splitlines():
        name = name.strip()
        if not name:
            continue
        rc, info, _ = run(["net", "usershare", "info", "--long", name])
        if rc != 0:
            continue
        parsed = parse_usershare_info(info)
        if parsed["name"]:
            out[parsed["name"]] = parsed
    return out


def find_share_for_path(target_path):
    target = str(Path(target_path).resolve())
    for name, info in list_usershares().items():
        if info.get("path") == target:
            return name, info
    return None, None


def acl_is_writable(acl):
    return ":F" in (acl or "").upper()


def usershare_add(name, path, comment, writable, guest):
    everyone = "S-1-1-0"
    acl = f"{everyone}:{'F' if writable else 'R'}"
    guest_arg = "guest_ok=y" if guest else "guest_ok=n"
    rc, out, err = run([
        "net", "usershare", "add",
        name, str(path), comment or "", acl, guest_arg,
    ])
    log.info("usershare add name=%r path=%r writable=%s guest=%s -> rc=%s%s",
             name, str(path), writable, guest, rc,
             "" if rc == 0 else " err=" + _short(err or out))
    return rc, out, err


def usershare_delete(name):
    rc, out, err = run(["net", "usershare", "delete", name])
    log.info("usershare delete name=%r -> rc=%s%s", name, rc,
             "" if rc == 0 else " err=" + _short(err or out))
    return rc, out, err


def hostname():
    rc, out, _ = run(["hostname"])
    return out.strip() if rc == 0 else os.uname().nodename


# ----------------------------------------------------------------------
# UI
# ----------------------------------------------------------------------

class FolderHeader(Gtk.Box):
    """Folder-icon + path block sitting at the top of every state."""

    def __init__(self, path):
        super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
        self.add_css_class("card")
        self.set_margin_top(0)
        self.set_margin_bottom(16)

        icon = Gtk.Image.new_from_icon_name("folder")
        icon.set_pixel_size(40)
        icon.set_margin_start(12)
        icon.set_margin_top(10)
        icon.set_margin_bottom(10)
        self.append(icon)

        text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
        text.set_valign(Gtk.Align.CENTER)
        text.set_margin_top(10)
        text.set_margin_bottom(10)
        text.set_margin_end(12)
        text.set_hexpand(True)

        base = os.path.basename(os.path.normpath(path)) or path
        name = Gtk.Label(label=base)
        name.set_xalign(0)
        name.add_css_class("heading")
        name.set_ellipsize(2)
        text.append(name)

        full = Gtk.Label(label=path)
        full.set_xalign(0)
        full.add_css_class("dim-label")
        full.add_css_class("caption")
        full.set_ellipsize(2)
        text.append(full)

        self.append(text)


class MainWindow(Adw.ApplicationWindow):
    def __init__(self, app, folder_path):
        super().__init__(application=app)
        self.set_title(_("Share Folder"))
        self.set_default_size(540, 1)
        self.set_resizable(False)
        self.set_icon_name("folder-publicshare")

        self.folder_path = folder_path
        self.toast_overlay = Adw.ToastOverlay()
        self.set_content(self.toast_overlay)

        self.toolbar = Adw.ToolbarView()
        self.toast_overlay.set_child(self.toolbar)

        self.header = Adw.HeaderBar()
        self.toolbar.add_top_bar(self.header)

        self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.content_box.set_margin_start(20)
        self.content_box.set_margin_end(20)
        self.content_box.set_margin_top(16)
        self.content_box.set_margin_bottom(20)
        self.toolbar.set_content(self.content_box)

        # Set once the user has completed first-run successfully in this
        # session. Forces the relogin state on subsequent refreshes
        # because os.getgroups() won't reflect the new group until the
        # next login.
        self.setup_completed_this_session = False

        self._register_css()
        self.refresh()

    def _register_css(self):
        css = b"""
.lite-share-success-banner {
    background-color: #e8f5e9;
    border: 1px solid #a5d6a7;
    border-radius: 12px;
    padding: 14px 16px;
}
.lite-share-success-title {
    color: #1b5e20;
    font-weight: 600;
}
.lite-share-success-sub {
    color: #2e7d32;
    opacity: 1;
}
.lite-share-success-icon {
    color: #2e7d32;
}
"""
        provider = Gtk.CssProvider()
        try:
            provider.load_from_string(css.decode("utf-8"))
        except (AttributeError, TypeError):
            provider.load_from_data(css, -1)
        display = Gdk.Display.get_default()
        if display is not None:
            Gtk.StyleContext.add_provider_for_display(
                display,
                provider,
                Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
            )

    # ------------------------------------------------------------------
    # state routing
    # ------------------------------------------------------------------

    def refresh(self):
        child = self.content_box.get_first_child()
        while child is not None:
            self.content_box.remove(child)
            child = self.content_box.get_first_child()

        self.content_box.append(FolderHeader(self.folder_path))

        if folder_is_denied(self.folder_path):
            self.build_denied_state()
            return

        if not user_in_sambashare_on_disk():
            self.build_first_run_state()
            return

        # Process-level group check: even if the user is in sambashare on
        # disk, we still can't `net usershare add` until the kernel-level
        # group list for this process includes it. That only happens
        # after the user logs out and back in.
        if not process_can_usershare() or self.setup_completed_this_session:
            self.build_relogin_state()
            return

        existing_name, existing_info = find_share_for_path(self.folder_path)
        if existing_name:
            self.build_edit_state(existing_name, existing_info)
        else:
            self.build_new_state()

    # ------------------------------------------------------------------
    # state: denied
    # ------------------------------------------------------------------

    def build_denied_state(self):
        banner = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title(_("This folder can't be shared."))
        row.set_subtitle(
            _("Hidden folders that may contain credentials (.ssh, .gnupg, "
            ".config, …) and system folders are blocked for safety.")
        )
        icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        banner.add(row)
        self.content_box.append(banner)

        self.append_button_row(
            primary_label="OK",
            on_primary=lambda *_: self.close(),
            cancel_label=None,
            destructive=None,
        )

    # ------------------------------------------------------------------
    # state: first run — Samba never configured + user not in group
    # ------------------------------------------------------------------

    def build_first_run_state(self):
        # Explanation card
        group = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title(_("One-time setup needed."))
        row.set_subtitle(
            _("Linux Lite will turn on Samba, add you to the <tt>sambashare</tt> "
            "group, and create a network password. Other computers will use "
            "this password when they connect to folders you share from this "
            "machine. It can be different from your login password.")
        )
        row.set_subtitle_lines(8)
        icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        group.add(row)
        self.content_box.append(group)

        # Password fields
        gap = Gtk.Box(); gap.set_size_request(-1, 12)
        self.content_box.append(gap)

        pw_group = Adw.PreferencesGroup()
        pw_group.set_title(_("Network password"))

        self.entry_pw1 = Adw.PasswordEntryRow()
        self.entry_pw1.set_title(_("Password"))
        self.entry_pw1.connect("changed", self._on_setup_pw_changed)
        pw_group.add(self.entry_pw1)

        self.entry_pw2 = Adw.PasswordEntryRow()
        self.entry_pw2.set_title(_("Confirm password"))
        self.entry_pw2.connect("changed", self._on_setup_pw_changed)
        pw_group.add(self.entry_pw2)

        self.content_box.append(pw_group)

        # Inline status text (mismatch / too short)
        self.pw_status = Gtk.Label(label="")
        self.pw_status.set_xalign(0)
        self.pw_status.add_css_class("caption")
        self.pw_status.add_css_class("error")
        self.pw_status.set_margin_top(6)
        self.pw_status.set_margin_start(4)
        self.pw_status.set_visible(False)
        self.content_box.append(self.pw_status)

        # Buttons (keep ref to primary so we can en/disable it)
        spacer = Gtk.Box(); spacer.set_size_request(-1, 18)
        self.content_box.append(spacer)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)
        gap2 = Gtk.Box(); gap2.set_hexpand(True)
        bar.append(gap2)

        cancel = Gtk.Button(label=_("Cancel"))
        cancel.connect("clicked", lambda *_: self.close())
        bar.append(cancel)

        self.setup_primary_btn = Gtk.Button(label=_("Set Up Sharing"))
        self.setup_primary_btn.add_css_class("suggested-action")
        self.setup_primary_btn.set_sensitive(False)
        self.setup_primary_btn.connect("clicked", self._on_setup_clicked)
        bar.append(self.setup_primary_btn)

        self.content_box.append(bar)

    def _on_setup_pw_changed(self, _entry):
        pw1 = self.entry_pw1.get_text()
        pw2 = self.entry_pw2.get_text()
        if not pw1 and not pw2:
            self.pw_status.set_visible(False)
            self.setup_primary_btn.set_sensitive(False)
            return
        if len(pw1) < 4:
            self.pw_status.set_text(_("Password must be at least 4 characters."))
            self.pw_status.set_visible(True)
            self.setup_primary_btn.set_sensitive(False)
            return
        if pw1 != pw2:
            self.pw_status.set_text(_("Passwords don't match."))
            self.pw_status.set_visible(True)
            self.setup_primary_btn.set_sensitive(False)
            return
        self.pw_status.set_visible(False)
        self.setup_primary_btn.set_sensitive(True)

    def _on_setup_clicked(self, *_):
        password = self.entry_pw1.get_text()
        user = current_user()
        # Run the pkexec helper OFF the GTK main thread — it enables/starts
        # smbd+nmbd and runs smbpasswd, which can take several seconds on a
        # fresh install. Doing it inline froze the whole window after the
        # polkit prompt. Re-enable + report from the main loop via idle_add.
        self.set_sensitive(False)
        self.setup_primary_btn.set_label(_("Setting up…"))

        def worker():
            rc, out, err = run_with_stdin(
                ["pkexec", SETUP_HELPER, user],
                password + "\n",
            )
            GLib.idle_add(self._on_setup_finished, rc, out, err)

        threading.Thread(target=worker, daemon=True).start()

    def _on_setup_finished(self, rc, out, err):
        self.set_sensitive(True)
        self.setup_primary_btn.set_label(_("Set Up Sharing"))
        log.info("samba enrolment via pkexec for %s -> rc=%s%s",
                 current_user(), rc, "" if rc == 0 else " err=" + _short(err or out))

        if rc == 0:
            self.setup_completed_this_session = True
            self.show_toast(_("Sharing is set up."))
            self.refresh()  # will land on the relogin state
            return False

        msg = (err or out or "").strip().splitlines()
        tail = msg[-1] if msg else _("Setup was cancelled or failed.")
        if "Not authorized" in (err or "") or rc == 126 or rc == 127:
            tail = _("Setup was cancelled (no administrator password entered).")
        self.show_toast(_("Setup didn't complete: {tail}").format(tail=tail))
        return False

    # ------------------------------------------------------------------
    # state: post-setup, awaiting re-login
    # ------------------------------------------------------------------

    def build_relogin_state(self):
        group = Adw.PreferencesGroup()
        row = Adw.ActionRow()
        row.set_title(_("Almost there — log out and back in."))
        row.set_subtitle(
            _("Samba is set up and you're in the <tt>sambashare</tt> group. The "
            "change takes effect after you log out and back in. Then "
            "right-click this folder again to share it.")
        )
        row.set_subtitle_lines(5)
        icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic")
        icon.set_pixel_size(32)
        row.add_prefix(icon)
        group.add(row)
        self.content_box.append(group)

        self.append_button_row(
            primary_label="Close",
            on_primary=lambda *_: self.close(),
            cancel_label=None,
            destructive=None,
        )

    # ------------------------------------------------------------------
    # state: new share
    # ------------------------------------------------------------------

    def build_new_state(self):
        self.build_share_form(
            initial_name=folder_to_default_name(self.folder_path),
            initial_comment="",
            initial_writable=False,
            initial_guest=False,
            existing_name=None,
            success_banner=None,
        )

    # ------------------------------------------------------------------
    # state: edit existing share
    # ------------------------------------------------------------------

    def build_edit_state(self, existing_name, info):
        host = hostname()
        success_banner = (
            _("This folder is shared as {unc}.").format(unc=f"\\\\{host}\\{existing_name}"),
            _("Reachable from other Linux Lite, Windows and macOS machines on "
            "this network."),
        )
        self.build_share_form(
            initial_name=existing_name,
            initial_comment=info.get("comment", ""),
            initial_writable=acl_is_writable(info.get("acl", "")),
            initial_guest=info.get("guest", False),
            existing_name=existing_name,
            success_banner=success_banner,
        )

    # ------------------------------------------------------------------
    # shared form
    # ------------------------------------------------------------------

    def build_share_form(self, initial_name, initial_comment, initial_writable,
                         initial_guest, existing_name, success_banner):
        if success_banner:
            banner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
            banner.add_css_class("lite-share-success-banner")
            banner.set_margin_bottom(12)

            icon = Gtk.Image.new_from_icon_name("emblem-ok-symbolic")
            icon.set_pixel_size(24)
            icon.set_valign(Gtk.Align.START)
            icon.add_css_class("lite-share-success-icon")
            banner.append(icon)

            text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
            text_box.set_hexpand(True)

            title = Gtk.Label(label=success_banner[0])
            title.set_xalign(0)
            title.set_wrap(True)
            title.add_css_class("lite-share-success-title")
            text_box.append(title)

            sub = Gtk.Label(label=success_banner[1])
            sub.set_xalign(0)
            sub.set_wrap(True)
            sub.add_css_class("lite-share-success-sub")
            sub.add_css_class("caption")
            text_box.append(sub)

            banner.append(text_box)
            self.content_box.append(banner)

        group = Adw.PreferencesGroup()

        self.entry_name = Adw.EntryRow()
        self.entry_name.set_title(_("Share name"))
        self.entry_name.set_text(initial_name)
        self.entry_name.connect("changed", self._on_name_changed)
        group.add(self.entry_name)

        self.entry_comment = Adw.EntryRow()
        self.entry_comment.set_title(_("Comment (optional)"))
        self.entry_comment.set_text(initial_comment)
        group.add(self.entry_comment)

        self.switch_write = Adw.SwitchRow()
        self.switch_write.set_title(_("Allow writing"))
        self.switch_write.set_subtitle(_("Others can modify and delete files."))
        self.switch_write.set_active(initial_writable)
        group.add(self.switch_write)

        self.switch_guest = Adw.SwitchRow()
        self.switch_guest.set_title(_("Guest access"))
        self.switch_guest.set_subtitle(_("Allow anyone on the network, no password."))
        self.switch_guest.set_active(initial_guest)
        group.add(self.switch_guest)

        self.content_box.append(group)

        # Network-password row, in its own group below the share form
        gap = Gtk.Box(); gap.set_size_request(-1, 12)
        self.content_box.append(gap)

        pw_group = Adw.PreferencesGroup()
        pw_row = Adw.ActionRow()
        pw_row.set_title(_("Network password"))
        pw_row.set_subtitle(
            _("What other computers enter when they connect to your shares.")
        )
        change_btn = Gtk.Button(label=_("Change…"))
        change_btn.set_valign(Gtk.Align.CENTER)
        change_btn.connect("clicked", lambda *_: self._open_change_password())
        pw_row.add_suffix(change_btn)
        pw_group.add(pw_row)
        self.content_box.append(pw_group)

        # Buttons
        if existing_name:
            self.append_button_row(
                primary_label=_("Save Changes"),
                on_primary=lambda *_: self._do_save(existing_name),
                cancel_label=_("Cancel"),
                on_cancel=lambda *_: self.close(),
                destructive=(_("Stop Sharing"),
                             lambda *_: self._do_stop(existing_name)),
            )
        else:
            self.append_button_row(
                primary_label=_("Share"),
                on_primary=lambda *_: self._do_save(None),
                cancel_label=_("Cancel"),
                on_cancel=lambda *_: self.close(),
                destructive=None,
            )

    def _on_name_changed(self, entry):
        raw = entry.get_text()
        cleaned = sanitize_sharename(raw)
        if cleaned != raw:
            entry.set_text(cleaned)

    def _do_save(self, existing_name):
        name = sanitize_sharename(self.entry_name.get_text())
        comment = self.entry_comment.get_text().strip()
        writable = self.switch_write.get_active()
        guest = self.switch_guest.get_active()

        if not name:
            self.show_toast(_("Share name can't be empty."))
            return

        if existing_name and existing_name != name:
            rc, _out, err = usershare_delete(existing_name)
            if rc != 0:
                self.show_toast(_("Couldn't rename share: {err}").format(err=err.strip()))
                return

        existing = list_usershares()
        if name in existing and existing[name]["path"] != str(
                Path(self.folder_path).resolve()):
            self.show_toast(
                _("The name '{name}' is already used for another folder.").format(name=name)
            )
            return

        rc, out, err = usershare_add(
            name, str(Path(self.folder_path).resolve()),
            comment, writable, guest,
        )
        if rc != 0:
            msg = (err or out or "").strip().splitlines()
            tail = msg[-1] if msg else _("Couldn't save the share.")
            tail_l = tail.lower()
            if "max share count" in tail_l:
                tail = (_("You've reached the maximum number of shares "
                        "allowed on this computer."))
            elif "you are not in the" in tail_l or "permission denied" in tail_l:
                tail = (_("Permission denied. Try logging out and back in, "
                        "then share the folder again."))
            self.show_toast(tail)
            return

        self.refresh()
        self.show_toast(
            _("Sharing as {unc}.").format(unc=f"\\\\{hostname()}\\{name}") if not existing_name
            else _("Share updated.")
        )

    def _do_stop(self, name):
        dlg = Adw.AlertDialog.new(
            _("Stop sharing '{name}'?").format(name=name),
            _("Other computers on the network won't be able to reach this "
            "folder anymore. The files themselves stay where they are.")
        )
        dlg.add_response("cancel", _("Cancel"))
        dlg.add_response("stop", _("Stop Sharing"))
        dlg.set_response_appearance("stop", Adw.ResponseAppearance.DESTRUCTIVE)
        dlg.set_default_response("cancel")
        dlg.set_close_response("cancel")
        dlg.connect("response", self._on_stop_confirmed, name)
        dlg.present(self)

    def _on_stop_confirmed(self, _dlg, response, name):
        if response != "stop":
            return
        rc, out, err = usershare_delete(name)
        if rc != 0:
            self.show_toast((err or out).strip().splitlines()[-1])
            return
        self.refresh()
        self.show_toast(_("Folder is no longer shared."))

    # ------------------------------------------------------------------
    # change-password dialog
    # ------------------------------------------------------------------

    def _open_change_password(self):
        # Adw.AlertDialog's set_extra_child is unreliable, so build a
        # small Adw.Window the way other LL apps do.
        win = Adw.Window()
        win.set_title(_("Change network password"))
        win.set_default_size(420, 1)
        win.set_resizable(False)
        win.set_modal(True)
        win.set_transient_for(self)

        toolbar = Adw.ToolbarView()
        toolbar.add_top_bar(Adw.HeaderBar())
        win.set_content(toolbar)

        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        box.set_margin_start(20); box.set_margin_end(20)
        box.set_margin_top(8); box.set_margin_bottom(20)
        toolbar.set_content(box)

        info = Gtk.Label(label=(
            _("Set a new password for connecting to your shares from other "
            "computers. The username stays the same as your login name.")
        ))
        info.set_wrap(True)
        info.set_xalign(0)
        info.add_css_class("dim-label")
        info.set_margin_bottom(12)
        box.append(info)

        pw_group = Adw.PreferencesGroup()
        e1 = Adw.PasswordEntryRow(); e1.set_title(_("New password"))
        e2 = Adw.PasswordEntryRow(); e2.set_title(_("Confirm new password"))
        pw_group.add(e1); pw_group.add(e2)
        box.append(pw_group)

        status = Gtk.Label(label="")
        status.set_xalign(0)
        status.add_css_class("caption")
        status.add_css_class("error")
        status.set_margin_top(6)
        status.set_visible(False)
        box.append(status)

        gap = Gtk.Box(); gap.set_size_request(-1, 18)
        box.append(gap)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)
        spacer = Gtk.Box(); spacer.set_hexpand(True)
        bar.append(spacer)

        cancel = Gtk.Button(label=_("Cancel"))
        cancel.connect("clicked", lambda *_: win.close())
        bar.append(cancel)

        save = Gtk.Button(label=_("Save"))
        save.add_css_class("suggested-action")
        save.set_sensitive(False)
        bar.append(save)
        box.append(bar)

        def validate(*_):
            p1 = e1.get_text()
            p2 = e2.get_text()
            if not p1 and not p2:
                status.set_visible(False)
                save.set_sensitive(False)
                return
            if len(p1) < 4:
                status.set_text(_("Password must be at least 4 characters."))
                status.set_visible(True)
                save.set_sensitive(False)
                return
            if p1 != p2:
                status.set_text(_("Passwords don't match."))
                status.set_visible(True)
                save.set_sensitive(False)
                return
            status.set_visible(False)
            save.set_sensitive(True)

        e1.connect("changed", validate)
        e2.connect("changed", validate)

        def do_save(*_):
            pw = e1.get_text()
            win.set_sensitive(False)

            def finished(rc, out, err):
                log.info("network password change for %s -> rc=%s%s",
                         current_user(), rc,
                         "" if rc == 0 else " err=" + _short(err or out))
                if rc == 0:
                    # Close the dialog first, then confirm on the main window
                    # so the toast is shown on the now-visible, focused window.
                    win.close()
                    self.show_toast(_("Network password updated."))
                else:
                    win.set_sensitive(True)
                    msg = (err or out or "").strip().splitlines()
                    self.show_toast(msg[-1] if msg
                                    else _("Couldn't update the password."))
                return False

            def worker():
                # Off the main thread — see _on_setup_clicked; pkexec+smbpasswd
                # would otherwise freeze the dialog after the polkit prompt.
                rc, out, err = run_with_stdin(
                    ["pkexec", SETUP_HELPER, "--password-only", current_user()],
                    pw + "\n",
                )
                GLib.idle_add(finished, rc, out, err)

            threading.Thread(target=worker, daemon=True).start()
        save.connect("clicked", do_save)

        win.present()

    # ------------------------------------------------------------------
    # button row utility
    # ------------------------------------------------------------------

    def append_button_row(self, primary_label, on_primary,
                          cancel_label=None, on_cancel=None,
                          destructive=None):
        spacer = Gtk.Box(); spacer.set_size_request(-1, 18)
        self.content_box.append(spacer)

        bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        bar.set_halign(Gtk.Align.FILL)

        if destructive:
            label, cb = destructive
            btn = Gtk.Button(label=label)
            btn.add_css_class("destructive-action")
            btn.connect("clicked", cb)
            bar.append(btn)

        gap = Gtk.Box(); gap.set_hexpand(True)
        bar.append(gap)

        if cancel_label:
            cancel = Gtk.Button(label=cancel_label)
            cancel.connect("clicked", on_cancel or (lambda *_: self.close()))
            bar.append(cancel)

        primary = Gtk.Button(label=primary_label)
        primary.add_css_class("suggested-action")
        primary.connect("clicked", on_primary)
        bar.append(primary)

        self.content_box.append(bar)

    def show_toast(self, text):
        self.toast_overlay.add_toast(Adw.Toast.new(text))


class App(Adw.Application):
    def __init__(self, folder_path):
        super().__init__(application_id=APP_ID,
                         flags=Gio.ApplicationFlags.NON_UNIQUE)
        self.folder_path = folder_path
        self.connect("activate", self._on_activate)

    def _on_activate(self, _app):
        win = MainWindow(self, self.folder_path)
        win.present()


def main():
    if len(sys.argv) < 2:
        print("usage: lite-share-folder <folder-path>", file=sys.stderr)
        return 2

    folder_path = sys.argv[1]
    log.info("launch: folder=%r user=%s", folder_path, current_user())
    if not os.path.isdir(folder_path):
        app = Adw.Application(application_id=APP_ID,
                              flags=Gio.ApplicationFlags.NON_UNIQUE)

        def _bad(app):
            win = Adw.ApplicationWindow(application=app, title=_("Share Folder"))
            win.set_default_size(420, 1)
            tb = Adw.ToolbarView()
            tb.add_top_bar(Adw.HeaderBar())
            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            box.set_margin_start(20); box.set_margin_end(20)
            box.set_margin_top(20); box.set_margin_bottom(20)
            lbl = Gtk.Label(label=_("Folder not found:\n{path}").format(path=folder_path))
            lbl.set_wrap(True); lbl.set_xalign(0)
            box.append(lbl)
            ok = Gtk.Button(label="OK"); ok.add_css_class("suggested-action")
            ok.set_halign(Gtk.Align.END)
            ok.connect("clicked", lambda *_: win.close())
            box.append(ok)
            tb.set_content(box)
            win.set_content(tb)
            win.present()
        app.connect("activate", _bad)
        return app.run([])

    return App(folder_path).run([])


if __name__ == "__main__":
    sys.exit(main())
