#!/usr/bin/env bash
#
# localectl - control the system locale and keyboard layout
# Slackware implementation - all reads/writes use org.freedesktop.locale1 D-Bus.
#
# Copyright 2026, GPLv2+

KNOWN_LOCALE_VARS=(
  LANG LANGUAGE
  LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
  LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION
)

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

die() {
  echo "localectl: $*" >&2
  exit 1
}

locale_var_is_known() {
  local key="$1"
  local v
  for v in "${KNOWN_LOCALE_VARS[@]}"; do
    [ "$v" = "$key" ] && return 0
  done
  return 1
}

locale_value_is_safe() {
  # Allow alnum plus _ . - @ : ,
  [[ "$1" =~ ^[A-Za-z0-9_.@:,-]+$ ]]
}

# ---------------------------------------------------------------------------
# Pager support
# ---------------------------------------------------------------------------

# Pipe stdout through less unless --no-pager was given or stdout is not a
# terminal.  Usage: some_command | _run_pager
# $PAGER is intentionally ignored: honouring it allows arbitrary command
# execution when localectl is run via sudo without env_reset.  Use
# --no-pager and pipe to your preferred pager explicitly if needed.
_run_pager() {
  if [ "$NO_PAGER" -eq 1 ] || [ ! -t 1 ]; then
    cat
  else
    less -FRX
  fi
}

# list-x11-keymap-models / layouts / variants / options
#
# All four parse /usr/share/X11/xkb/rules/base.lst which is shipped by the
# xkeyboard-config package.  The file has four sections introduced by a
# "! section" header line; entries are "  code   description" pairs.
#
# list-x11-keymap-variants accepts an optional LAYOUT argument: when given,
# only variants whose description starts with "LAYOUT:" are printed, matching
# upstream systemd localectl behaviour.
# ---------------------------------------------------------------------------

XKB_BASE_LST="/usr/share/X11/xkb/rules/base.lst"

# Internal helper: extract a named section from base.lst.
# Usage: _xkb_section SECTIONNAME [FILTER]
# Prints the code (first field) of every entry in the section, optionally
# filtered case-insensitively using grep -iF (fixed-string, not ERE) so a
# user-supplied filter string cannot cause awk to abort on malformed regex
# metacharacters.  Matches list-keymaps and list-locales which already use
# grep -iF for the same reason.
_xkb_section() {
  local section="$1" filter="${2:-}"

  if [ ! -f "$XKB_BASE_LST" ]; then
    die "$XKB_BASE_LST not found (is xkeyboard-config installed?)"
  fi

  awk -v section="$section" '
    /^! /      { in_section = ($2 == section); next }
    /^[[:space:]]*$/ { next }
    in_section {
      code = $1
      if (code != "") print code
    }
  ' "$XKB_BASE_LST" \
    | sort -u \
    | if [ -n "$filter" ]; then grep -iF "$filter"; else cat; fi
}

cmd_list_x11_keymap_models() {
  _xkb_section "model" "${1:-}" | _run_pager
}

cmd_list_x11_keymap_layouts() {
  _xkb_section "layout" "${1:-}" | _run_pager
}

cmd_list_x11_keymap_variants() {
  local layout="${1:-}" filter="${2:-}"

  if [ ! -f "$XKB_BASE_LST" ]; then
    die "$XKB_BASE_LST not found (is xkeyboard-config installed?)"
  fi

  # When a layout is given, restrict to variants whose description begins
  # with "LAYOUT:" (the format used in base.lst).  The filter arg (if any)
  # is applied as a fixed-string grep -iF post-filter, matching the pattern
  # used by _xkb_section, list-keymaps, and list-locales.
  awk -v layout="$layout" '
    /^! /      { in_section = ($2 == "variant"); next }
    /^[[:space:]]*$/ { next }
    in_section {
      code = $1
      # desc is everything after the code and its trailing whitespace
      desc = substr($0, index($0, code) + length(code))
      sub(/^[[:space:]]+/, "", desc)
      if (code == "") next
      if (layout != "" && index(desc, layout ":") != 1) next
      print code
    }
  ' "$XKB_BASE_LST" \
    | sort -u \
    | if [ -n "$filter" ]; then grep -iF "$filter"; else cat; fi \
    | _run_pager
}

cmd_list_x11_keymap_options() {
  local filter="${1:-}"

  if [ ! -f "$XKB_BASE_LST" ]; then
    die "$XKB_BASE_LST not found (is xkeyboard-config installed?)"
  fi

  # Options are "group:name" tokens; print the full token (both the group
  # header line and the individual options), matching upstream localectl.
  # Filter is applied as grep -iF (fixed-string) after extraction so a
  # malformed user-supplied string cannot cause awk to abort on a bad ERE.
  awk '
    /^! /      { in_section = ($2 == "option"); next }
    /^[[:space:]]*$/ { next }
    in_section {
      code = $1
      if (code != "") print code
    }
  ' "$XKB_BASE_LST" \
    | sort -u \
    | if [ -n "$filter" ]; then grep -iF "$filter"; else cat; fi
}

# ---------------------------------------------------------------------------
# list-keymaps
# ---------------------------------------------------------------------------

cmd_list_keymaps() {
  local filter="${1:-}"
  local kbddir="/usr/share/kbd/keymaps"

  if [ ! -d "$kbddir" ]; then
    die "keymap directory $kbddir not found (is kbd installed?)"
  fi

  # Strip path and extensions (.map.gz, .map) to get bare keymap names,
  # sort, deduplicate, then optionally filter.
  find "$kbddir" -type f \( -name "*.map.gz" -o -name "*.map" \) \
    | xargs -I{} basename {} \
    | sed -e 's/\.map\.gz$//' -e 's/\.map$//' \
    | sort -u \
    | if [ -n "$filter" ]; then grep -iF "$filter"; else cat; fi \
    | _run_pager
}

# ---------------------------------------------------------------------------
# list-locales
# ---------------------------------------------------------------------------

cmd_list_locales() {
  local filter="${1:-}"

  # locale(1) is part of glibc and always present on Slackware.
  # "locale -a" lists every locale the C library can use without generation.
  if ! command -v locale >/dev/null 2>&1; then
    die "locale(1) not found - cannot list available locales"
  fi

  if [ -n "$filter" ]; then
    locale -a 2>/dev/null | grep -iF "$filter"
  else
    locale -a 2>/dev/null
  fi | _run_pager
}

# ---------------------------------------------------------------------------
# usage
# ---------------------------------------------------------------------------

usage() {
  cat << EOF
Usage: localectl [OPTIONS] COMMAND [ARGUMENT...]

Commands:
  status                          Show current locale and keyboard settings
  list-keymaps [FILTER]           List available console keymaps (optional grep filter)
  list-locales [FILTER]           List available locales (optional grep filter)
  set-locale KEY=value [...]      Set system locale variables through D-Bus
  set-keymap [--no-convert] KEYMAP [TOGGLE]
                                  Set the virtual console keymap through D-Bus
  set-x11-keymap [--no-convert] LAYOUT [MODEL [VARIANT [OPTIONS]]]
                                  Set the X11 keyboard layout through D-Bus.
                                  Omitted args keep their current value;
                                  pass "" to explicitly clear a field.
  list-x11-keymap-models [FILTER]    List available X11 keyboard models
  list-x11-keymap-layouts [FILTER]   List available X11 keyboard layouts
  list-x11-keymap-variants [LAYOUT] [FILTER]
                                     List available X11 keyboard variants
  list-x11-keymap-options [FILTER]   List available X11 keyboard options

Options:
  --no-pager                      Do not pipe list output through a pager
  -h, --help                      Show this help and exit

Known locale variables:
  LANG LANGUAGE LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY
  LC_MESSAGES LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT
  LC_IDENTIFICATION

Examples:
  localectl list-keymaps
  localectl list-keymaps de
  localectl list-locales
  localectl list-locales UTF-8
  localectl list-locales en_US
  localectl set-locale LANG=en_US.UTF-8
  localectl set-locale LANG=en_US.UTF-8 LC_TIME=en_GB.UTF-8
  localectl set-keymap us
  localectl set-x11-keymap cz,us pc104 ,dvorak grp:win_space_toggle
  localectl set-x11-keymap cz,us pc104      # keeps existing variant and options
  localectl set-x11-keymap cz,us "" "" grp:alt_shift_toggle  # clear model/variant, set options
  localectl set-x11-keymap de pc105 nodeadkeys
  localectl set-x11-keymap us pc105 "" compose:ralt
  localectl list-x11-keymap-models
  localectl list-x11-keymap-layouts
  localectl list-x11-keymap-variants us
  localectl list-x11-keymap-options compose
EOF
}

# ---------------------------------------------------------------------------
# D-Bus client implementation (0.2.0)
#
# Configuration writes must go through org.freedesktop.locale1 so that the
# daemon owns validation, persistence, property updates, and Polkit policy.
# Keep list-* commands local: they only enumerate installed data.
# ---------------------------------------------------------------------------

DBUS_DEST="org.freedesktop.locale1"
DBUS_PATH="/org/freedesktop/locale1"
DBUS_IFACE="org.freedesktop.locale1"

require_gdbus() {
  command -v gdbus >/dev/null 2>&1 || die "gdbus not found (install GLib's gdbus utility)"
}

_gdbus_call() {
  gdbus call --system --dest "$DBUS_DEST" --object-path "$DBUS_PATH" \
    --method "$DBUS_IFACE.$1" "${@:2}"
}

_gdbus_get() {
  gdbus call --system --dest "$DBUS_DEST" --object-path "$DBUS_PATH" \
    --method org.freedesktop.DBus.Properties.Get "$DBUS_IFACE" "$1" 2>/dev/null
}

# Unwrap the GVariant value returned by Properties.Get. Locale values are
# restricted to a safe character set, and keyboard identifiers use an
# allowlist, so the simple quote removal below cannot expose shell syntax.
_gdbus_value() {
  # gdbus wraps property values in GVariant punctuation.
  printf '%s\n' "$1" | tr -d "\\\"'<>[](),"
}

_status_line() {
  printf "%16s: %s\n" "$1" "$2"
}

cmd_status() {
  require_gdbus
  local locale keymap toggle layout model variant options raw
  raw=$(_gdbus_get Locale) || die "cannot read locale1 properties; is localed running?"
  locale=$(_gdbus_value "$raw")
  _status_line "System Locale" "${locale:-n/a}"

  raw=$(_gdbus_get VConsoleKeymap) || die "cannot read VConsoleKeymap"
  keymap=$(_gdbus_value "$raw")
  raw=$(_gdbus_get VConsoleKeymapToggle) || die "cannot read VConsoleKeymapToggle"
  toggle=$(_gdbus_value "$raw")
  _status_line "VC Keymap" "${keymap:-(unset)}"
  [ -n "$toggle" ] && _status_line "VC Keymap Tgl" "$toggle"

  raw=$(_gdbus_get X11Layout) || die "cannot read X11Layout"
  layout=$(_gdbus_value "$raw")
  raw=$(_gdbus_get X11Model) || die "cannot read X11Model"
  model=$(_gdbus_value "$raw")
  raw=$(_gdbus_get X11Variant) || die "cannot read X11Variant"
  variant=$(_gdbus_value "$raw")
  raw=$(_gdbus_get X11Options) || die "cannot read X11Options"
  options=$(_gdbus_value "$raw")
  _status_line "X11 Layout" "${layout:-(unset)}"
  if [ -n "$layout" ]; then
    _status_line "X11 Model" "${model:--}"
    _status_line "X11 Variant" "${variant:--}"
    _status_line "X11 Options" "${options:--}"
  fi
}

cmd_set_locale() {
  require_gdbus
  [ "$#" -gt 0 ] || die "set-locale requires at least one KEY=value argument"
  local pair key value
  for pair in "$@"; do
    [[ "$pair" == *=* ]] || die "expected KEY=value, got '$pair'"
    key="${pair%%=*}"
    value="${pair#*=}"
    locale_var_is_known "$key" || die "unknown locale variable '$key'"
    if [ -n "$value" ] && ! locale_value_is_safe "$value"; then
      die "invalid value for $key: '$value'"
    fi
  done
  # The trailing true permits a Polkit authentication agent to prompt.
  _gdbus_call SetLocale "[$(printf "'%s', " "$@" | sed 's/, $//')]" true >/dev/null \
    || die "SetLocale failed (authorization may have been denied)"
}

cmd_set_keymap() {
  require_gdbus
  local no_convert=false
  if [ "${1:-}" = "--no-convert" ]; then no_convert=true; shift; fi
  [ "$#" -ge 1 ] && [ "$#" -le 2 ] || die "usage: localectl set-keymap [--no-convert] KEYMAP [TOGGLE]"
  local keymap="$1" toggle="${2:-}"
  keyboard_value_is_safe "$keymap" || die "invalid keymap '$keymap'"
  [ -z "$toggle" ] || keyboard_value_is_safe "$toggle" || die "invalid keymap toggle '$toggle'"
  _gdbus_call SetVConsoleKeyboard "$keymap" "$toggle" "$([ "$no_convert" = false ] && echo true || echo false)" true >/dev/null \
    || die "SetVConsoleKeyboard failed (authorization may have been denied)"
}

cmd_set_x11_keymap() {
  require_gdbus
  local no_convert=false
  if [ "${1:-}" = "--no-convert" ]; then no_convert=true; shift; fi
  [ "$#" -ge 1 ] && [ "$#" -le 4 ] || die "usage: localectl set-x11-keymap [--no-convert] LAYOUT [MODEL [VARIANT [OPTIONS]]]"
  local layout="$1" model="" variant="" options=""
  # Preserve omitted fields by reading the current D-Bus properties. Explicit
  # empty arguments intentionally clear a field.
  if [ "$#" -ge 2 ]; then model="$2"; else model=$(_gdbus_get X11Model | sed -E 's/^\(<//; s/>?,\)$//; s/^"//; s/"$//; s/^\x27//; s/\x27$//') || die "cannot read X11Model"; fi
  if [ "$#" -ge 3 ]; then variant="$3"; else variant=$(_gdbus_get X11Variant | sed -E 's/^\(<//; s/>?,\)$//; s/^"//; s/"$//; s/^\x27//; s/\x27$//') || die "cannot read X11Variant"; fi
  if [ "$#" -ge 4 ]; then options="$4"; else options=$(_gdbus_get X11Options | sed -E 's/^\(<//; s/>?,\)$//; s/^"//; s/"$//; s/^\x27//; s/\x27$//') || die "cannot read X11Options"; fi
  local field
  for field in "$layout" "$model" "$variant" "$options"; do
    [ -z "$field" ] || keyboard_value_is_safe "$field" || die "invalid X11 keyboard value '$field'"
  done
  _gdbus_call SetX11Keyboard "$layout" "$model" "$variant" "$options" "$([ "$no_convert" = false ] && echo true || echo false)" true >/dev/null \
    || die "SetX11Keyboard failed (authorization may have been denied)"
}

# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------

# Global flags (consumed before the command)
NO_PAGER=0
while true; do
  case "${1:-}" in
    --no-pager) NO_PAGER=1; shift ;;
    *)          break ;;
  esac
done

case "${1:-status}" in
  status)                 shift; cmd_status "$@" ;;
  list-keymaps)           shift; cmd_list_keymaps "$@" ;;
  list-locales)           shift; cmd_list_locales "$@" ;;
  set-locale)             shift; cmd_set_locale "$@" ;;
  set-keymap)             shift; cmd_set_keymap "$@" ;;
  set-x11-keymap)              shift; cmd_set_x11_keymap "$@" ;;
  list-x11-keymap-models)      shift; cmd_list_x11_keymap_models "$@" ;;
  list-x11-keymap-layouts)     shift; cmd_list_x11_keymap_layouts "$@" ;;
  list-x11-keymap-variants)    shift; cmd_list_x11_keymap_variants "$@" ;;
  list-x11-keymap-options)     shift; cmd_list_x11_keymap_options "$@" ;;
  -h|--help)              usage ;;
  *)
    echo "localectl: unknown command '$1'" >&2
    usage >&2
    exit 1
    ;;
esac
