#!/usr/bin/env python3
"""
groovix-screen-lock - Custom screen locker for the Groovix kiosk platform.

Displays a fullscreen GTK3 window with a password field and a status
line at the bottom showing elapsed lock time and time remaining until
the session is forcibly closed.  The username is pre-set to the user
running the process and authentication goes through PAM.

After --timeout minutes (default 10), the program invokes
'groovix-session-forced-exit' to end the session.
"""

import argparse
import configparser
import os
import pwd
import subprocess
import sys
import time

import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gtk, Gdk, GLib


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
INFO_TEXT       = "Enter your password/pin and hit enter to unlock the screen."
USER_LABEL      = "User: "
PASSWORD_LABEL  = "Password/Pin: "
INVALID_MESSAGE = "Invalid password/pin"

STATUS_REFRESH_MS        = 1000    # recompute elapsed/remaining message

KEEP_ON_TOP_INTERVAL_MS  = 1000    # how often to re-raise the lock window

SESSION_FORCED_EXIT_CMD  = ['groovix-session-forced-exit',
                            'temporary screen lock timed out']

# PAM service for authentication.  Change this and create
# /etc/pam.d/<name> if you want a dedicated stack for the lock screen.
PAM_SERVICE              = 'login'

CONFIG_FILE              = '/etc/groovix/screen-lock.conf'
ERROR_DISPLAY_SECONDS    = 5     # auto-clear error message after this long


def _plural(n):
    return "" if n == 1 else "s"


class ScreenLocker:
    def __init__(self, timeout_minutes=10, username=None,
                 info_text=INFO_TEXT, user_label=USER_LABEL,
                 password_label=PASSWORD_LABEL,
                 invalid_message=INVALID_MESSAGE):
        self.timeout_minutes  = timeout_minutes
        self.fixed_username   = username   # None when the user must type it
        self.info_text        = info_text
        self.user_label       = user_label
        self.password_label   = password_label
        self.invalid_message  = invalid_message
        self.start_time       = time.monotonic()
        self._error_timeout_id = None

        self._build_window()
        self._apply_style()
        self._build_ui()
        self._update_status_text()

        self.window.show_all()
        if self.username_entry is not None:
            self.username_entry.grab_focus()
        else:
            self.password_entry.grab_focus()

        # Absolute lock timeout: invoke forced-exit when this fires.
        GLib.timeout_add_seconds(timeout_minutes * 60, self._on_session_timeout)
        # Periodic refresh of the status line text.
        GLib.timeout_add(STATUS_REFRESH_MS, self._update_status_text)
        # Keep the window above any other window that spawns over us.
        GLib.timeout_add(KEEP_ON_TOP_INTERVAL_MS, self._keep_on_top)

    # ----- window/style ----------------------------------------------------
    def _build_window(self):
        self.window = Gtk.Window()
        self.window.set_decorated(False)
        self.window.set_keep_above(True)
        self.window.set_skip_taskbar_hint(True)
        self.window.set_skip_pager_hint(True)
        self.window.fullscreen()

        # Block WM-driven close (Alt-F4 etc).  We exit only on successful
        # authentication or via the configured session timeout.  Note:
        # ctrl-alt-backspace and VT switching are deliberately *not*
        # blocked -- the X server handles those and we never grab the
        # keyboard.
        self.window.connect('delete-event',     lambda *_: True)
        self.window.connect('destroy',          Gtk.main_quit)
        self.window.connect('key-press-event',  self._on_key_press)

        # Re-assert fullscreen + on-top if anything tries to displace us.
        self.window.connect('window-state-event', self._on_window_state)
        self.window.connect('focus-out-event',    self._on_focus_out)

    def _apply_style(self):
        css = b"""
        window       { background-color: #000000; }
        label        { color: #ffffff; }
        label.info     { font-size: 14pt; }
        label.username { font-family: monospace; font-size: 16pt; }
        label.status   { font-family: monospace; font-size: 18pt; padding: 6px; }
        label.error-bar {
            color: #ffffff;
            font-size: 14pt;
            font-weight: bold;
            padding: 10px 16px;
            min-height: 18pt;
            background-color: transparent;
        }
        label.error-bar.active {
            background-color: #c0392b;
        }
        entry        {
            background-color: #111111;
            color: #ffffff;
            caret-color: #ffffff;
            font-family: monospace;
            font-size: 16pt;
            padding: 4px 8px;
        }
        """
        provider = Gtk.CssProvider()
        provider.load_from_data(css)
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
        )

    # ----- widgets ---------------------------------------------------------
    def _build_ui(self):
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        self.window.add(outer)

        outer.pack_start(Gtk.Box(), True, True, 0)   # top spacer

        self.input_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        outer.pack_start(self.input_box, False, False, 0)

        info = Gtk.Label(label=self.info_text)
        info.get_style_context().add_class('info')
        self.input_box.pack_start(info, False, False, 16)

        grid = Gtk.Grid()
        grid.set_halign(Gtk.Align.CENTER)
        grid.set_column_spacing(10)
        grid.set_row_spacing(10)

        user_lbl = Gtk.Label(label=self.user_label)
        user_lbl.set_halign(Gtk.Align.END)
        grid.attach(user_lbl, 0, 0, 1, 1)

        if self.fixed_username is not None:
            # Username is fixed: display as a label, no entry field.
            self.username_entry = None
            username_display = Gtk.Label(label=self.fixed_username)
            username_display.set_halign(Gtk.Align.START)
            username_display.set_xalign(0.0)
            username_display.get_style_context().add_class('username')
            grid.attach(username_display, 1, 0, 1, 1)
        else:
            self.username_entry = Gtk.Entry()
            self.username_entry.set_width_chars(24)
            grid.attach(self.username_entry, 1, 0, 1, 1)

        pw_lbl = Gtk.Label(label=self.password_label)
        pw_lbl.set_halign(Gtk.Align.END)
        self.password_entry = Gtk.Entry()
        self.password_entry.set_visibility(False)
        self.password_entry.set_invisible_char('\u2022')
        self.password_entry.set_width_chars(24)
        grid.attach(pw_lbl,              0, 1, 1, 1)
        grid.attach(self.password_entry, 1, 1, 1, 1)

        self.input_box.pack_start(grid, False, False, 0)

        self.error_label = Gtk.Label(label="")
        self.error_label.set_halign(Gtk.Align.FILL)
        self.error_label.set_hexpand(True)
        self.error_label.set_xalign(0.5)
        self.error_label.get_style_context().add_class('error-bar')
        self.input_box.pack_start(self.error_label, False, False, 12)

        outer.pack_start(Gtk.Box(), True, True, 0)   # middle spacer

        self.status_label = Gtk.Label(label="")
        self.status_label.set_halign(Gtk.Align.CENTER)
        self.status_label.get_style_context().add_class('status')
        outer.pack_start(self.status_label, False, False, 20)

        # Signals
        if self.username_entry is not None:
            self.username_entry.connect('activate', self._on_username_activate)
            self.username_entry.connect('changed',  self._clear_error)
        self.password_entry.connect('activate', self._on_submit)
        self.password_entry.connect('changed',  self._clear_error)

    # ----- status line -----------------------------------------------------
    def _update_status_text(self):
        elapsed   = int((time.monotonic() - self.start_time) // 60)
        remaining = max(0, self.timeout_minutes - elapsed)
        self.status_label.set_text(
            f"This screen has been locked for {elapsed} "
            f"minute{_plural(elapsed)}.  "
            f"In {remaining} minute{_plural(remaining)} "
            f"this session will be closed.")
        return True

    # ----- keep-on-top -----------------------------------------------------
    def _keep_on_top(self):
        gdk_window = self.window.get_window()
        if gdk_window is not None:
            gdk_window.raise_()                 # z-order only, no focus change
        # If we don't have focus, present() raises *and* grabs focus back.
        if not self.window.has_toplevel_focus():
            self.window.present()
        return True

    def _on_window_state(self, _widget, event):
        # Re-fullscreen if anything flips us out of fullscreen state.
        if not (event.new_window_state & Gdk.WindowState.FULLSCREEN):
            self.window.fullscreen()
        return False

    def _on_focus_out(self, _widget, _event):
        # Reclaim focus -- some other window grabbed it.
        self.window.present()
        return False

    # ----- input -----------------------------------------------------------
    def _on_username_activate(self, _entry):
        self.password_entry.grab_focus()

    def _show_error(self, message):
        # Show the error bar with this message.  Cancels any pending
        # timeout and schedules a fresh ERROR_DISPLAY_SECONDS countdown
        # so the bar auto-clears.
        if self._error_timeout_id is not None:
            GLib.source_remove(self._error_timeout_id)
        self.error_label.set_text(message)
        self.error_label.get_style_context().add_class('active')
        self._error_timeout_id = GLib.timeout_add_seconds(
            ERROR_DISPLAY_SECONDS, self._error_timed_out)

    def _error_timed_out(self):
        self.error_label.set_text("")
        self.error_label.get_style_context().remove_class('active')
        self._error_timeout_id = None
        return False

    def _clear_error(self, *_):
        # Connected to entry 'changed' signals: user is typing again, so
        # dismiss any visible error and cancel its auto-clear timeout.
        if self._error_timeout_id is not None:
            GLib.source_remove(self._error_timeout_id)
            self._error_timeout_id = None
        if self.error_label.get_text():
            self.error_label.set_text("")
            self.error_label.get_style_context().remove_class('active')

    def _on_key_press(self, _widget, event):
        if event.keyval == Gdk.KEY_Escape:
            if self.username_entry is not None:
                self.username_entry.set_text("")
            self.password_entry.set_text("")
            self.error_label.set_text("")
            if self.username_entry is not None:
                self.username_entry.grab_focus()
            else:
                self.password_entry.grab_focus()
            return True
        return False

    def _username(self):
        if self.fixed_username is not None:
            return self.fixed_username
        return self.username_entry.get_text().strip()

    def _pam_auth(self, username, password):
        # Lazy import: the Debian python3-pam package provides the `PAM`
        # (uppercase) C-extension binding with a conversation-callback API.
        import PAM

        def conv(_auth, query_list, _user_data=None):
            resp = []
            for query, qtype in query_list:
                if qtype == PAM.PAM_PROMPT_ECHO_OFF:        # password
                    resp.append((password, 0))
                elif qtype == PAM.PAM_PROMPT_ECHO_ON:       # username (already set)
                    resp.append((username, 0))
                elif qtype in (PAM.PAM_ERROR_MSG, PAM.PAM_TEXT_INFO):
                    resp.append(('', 0))
                else:
                    return None
            return resp

        auth = PAM.pam()
        auth.start(PAM_SERVICE)
        auth.set_item(PAM.PAM_USER, username)
        auth.set_item(PAM.PAM_CONV, conv)
        try:
            auth.authenticate()
            auth.acct_mgmt()
        except PAM.error as exc:
            msg = exc.args[0] if exc.args else "Authentication failed."
            return (1, str(msg))
        return (0, "Authentication successful.")

    def _on_submit(self, _entry):
        username = self._username()
        password = self.password_entry.get_text()

        if not username:
            self._show_error("Please enter your username.")
            return
        if not password:
            self._show_error("Please enter your password/pin.")
            return

        try:
            rc, _message = self._pam_auth(username, password)
        except Exception as exc:
            # Clear password first; otherwise the entry's 'changed'
            # handler fires *after* we set the error and wipes it out.
            self.password_entry.set_text("")
            self._show_error(f"Authentication error: {exc}")
            self.password_entry.grab_focus()
            return

        if rc == 0:
            Gtk.main_quit()
            return

        # Authentication completed but rejected the credentials.  Clear
        # the password entry first (its 'changed' handler then dismisses
        # any stale error) and surface the configured invalid message.
        self.password_entry.set_text("")
        self._show_error(self.invalid_message)
        self.password_entry.grab_focus()

    # ----- session timeout -------------------------------------------------
    def _on_session_timeout(self):
        try:
            subprocess.Popen(SESSION_FORCED_EXIT_CMD)
        except OSError as exc:
            sys.stderr.write(
                f"groovix-screen-lock: failed to invoke "
                f"{SESSION_FORCED_EXIT_CMD[0]}: {exc}\n")
        Gtk.main_quit()
        return False


def load_config(path):
    """Load defaults from an INI-style config file under [screen-lock].

    Returns a dict keyed by argparse attribute name (underscored).
    String values may be wrapped in double or single quotes to preserve
    leading/trailing whitespace (which configparser strips otherwise),
    e.g.  user-label = "User: "
    """
    if not os.path.exists(path):
        return {}

    cp = configparser.ConfigParser()
    try:
        cp.read(path)
    except configparser.Error as exc:
        sys.stderr.write(
            f"groovix-screen-lock: error reading {path}: {exc}\n")
        sys.exit(2)

    if 'screen-lock' not in cp:
        return {}

    section = cp['screen-lock']
    out = {}

    def _unquote(val):
        if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
            return val[1:-1]
        return val

    def _err(key, exc):
        sys.stderr.write(
            f"groovix-screen-lock: invalid '{key}' in {path}: {exc}\n")
        sys.exit(2)

    if 'timeout' in section:
        try:
            out['timeout'] = section.getint('timeout')
        except ValueError as exc:
            _err('timeout', exc)
    for cfg_key, attr in [
        ('info',            'info'),
        ('user-label',      'user_label'),
        ('password-label',  'password_label'),
        ('invalid-message', 'invalid_message'),
    ]:
        if cfg_key in section:
            out[attr] = _unquote(section.get(cfg_key))

    return out


def main():
    defaults = {
        'timeout':         10,
        'info':            INFO_TEXT,
        'user_label':      USER_LABEL,
        'password_label':  PASSWORD_LABEL,
        'invalid_message': INVALID_MESSAGE,
    }

    parser = argparse.ArgumentParser(
        description=f"Groovix kiosk screen locker.  Reads defaults from "
                    f"{CONFIG_FILE} (section [screen-lock]); command-line "
                    f"options take precedence.")
    # default=SUPPRESS so we can tell which options were actually passed
    # on the CLI.  Built-in defaults live in the `defaults` dict above.
    parser.add_argument(
        '-t', '--timeout', type=int, default=argparse.SUPPRESS,
        metavar='MINUTES',
        help=f"Minutes until the session is forcibly closed "
             f"(default: {defaults['timeout']}).")
    parser.add_argument(
        '--info', type=str, default=argparse.SUPPRESS, metavar='TEXT',
        help="Instruction text shown above the entry fields.")
    parser.add_argument(
        '--user-label', type=str, default=argparse.SUPPRESS, metavar='TEXT',
        help="Label text for the username field.")
    parser.add_argument(
        '--password-label', type=str, default=argparse.SUPPRESS, metavar='TEXT',
        help="Label text for the password field.")
    parser.add_argument(
        '--invalid-message', type=str, default=argparse.SUPPRESS, metavar='TEXT',
        help="Message shown when authentication is rejected.")

    args = parser.parse_args()
    cli_attrs = vars(args)                  # only contains CLI-set options

    # Layer: built-in defaults < config file < CLI.
    settings = defaults.copy()
    settings.update(load_config(CONFIG_FILE))
    settings.update(cli_attrs)

    if settings['timeout'] < 1:
        parser.error("--timeout must be at least 1 minute")

    try:
        import PAM  # noqa: F401  -- verify the package is installed
    except ImportError:
        parser.error("groovix-screen-lock requires the python3-pam package "
                     "(apt install python3-pam)")
    try:
        username = pwd.getpwuid(os.getuid()).pw_name
    except KeyError:
        parser.error("could not look up the current user (uid %d)"
                     % os.getuid())

    ScreenLocker(timeout_minutes=settings['timeout'],
                 username=username,
                 info_text=settings['info'],
                 user_label=settings['user_label'],
                 password_label=settings['password_label'],
                 invalid_message=settings['invalid_message'])
    Gtk.main()
    return 0


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