#!/usr/bin/env bash

# System locale, via localectl.
#
#   panama-locale list                 -> [{value, label, detail}]
#   panama-locale get                  -> the current LANG, e.g. en_US.UTF-8
#   panama-locale set <locale>
#   panama-locale categories           -> the category names, one per line
#   panama-locale overrides            -> {LC_TIME: "...", ...}, "" for none
#   panama-locale get <category>       -> the override, or "" for "match language"
#   panama-locale set <category> <locale|"">
#
# The categories are the five that a person actually chooses independently of
# their language: LC_TIME, LC_NUMERIC, LC_MONETARY, LC_MEASUREMENT, LC_PAPER.
# Someone reading in English while writing dates and currency the way their
# country does is the ordinary case, not an exotic one.
#
# An empty value means "match language", which is the ABSENCE of an override
# rather than a value equal to LANG -- the two behave the same today and
# diverge the moment the language changes. localectl replaces /etc/locale.conf
# with exactly the assignments it is given, so unsetting one category means
# re-issuing all the others. That is why cmd_set_category reads the current
# file first: passing only the survivors is the only way to remove one.
#
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
# already know, which defeats the point of a picker, so codes are resolved
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
# The code stays visible as the row's detail, because it is what actually gets
# written and someone choosing between two Spanish variants needs to see it.
#
# The join happens in a single jq pass. Doing it per locale meant 327 jq
# invocations, which took long enough to be visible when opening the page.
#
# Setting the locale is a privileged operation: localectl goes through polkit,
# which prompts. It also only takes effect for programs started afterwards, so
# the caller is responsible for saying a sign-out is needed -- this script does
# not pretend the running session changed.

set -uo pipefail

readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
readonly LOCALE_CONF=/etc/locale.conf
readonly CATEGORIES=(LC_TIME LC_NUMERIC LC_MONETARY LC_MEASUREMENT LC_PAPER)

is_category() {
    local candidate="$1" name
    for name in "${CATEGORIES[@]}"; do
        [[ "$name" == "$candidate" ]] && return 0
    done
    return 1
}

# LANG plus every category override, as VAR=value lines. /etc/locale.conf is
# what localectl writes and is world-readable, so it is read directly rather
# than scraped out of `localectl status`, whose multi-variable output wraps
# across continuation lines and has no stable machine form.
current_assignments() {
    [[ -r "$LOCALE_CONF" ]] || return 0
    while IFS= read -r line; do
        line="${line%%#*}"
        [[ "$line" =~ ^[[:space:]]*(LANG|LANGUAGE|LC_[A-Z_]+)=(.*)$ ]] || continue
        local name="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}"
        # locale.conf quotes values; localectl takes them bare.
        value="${value%\"}"; value="${value#\"}"
        value="${value%\'}"; value="${value#\'}"
        [[ -n "$value" ]] && printf '%s=%s\n' "$name" "$value"
    done <"$LOCALE_CONF"
}

cmd_get() {
    local name="${1:-LANG}"
    if [[ "$name" != "LANG" ]] && ! is_category "$name"; then
        printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
        return 2
    fi
    local value
    value="$(current_assignments | awk -F= -v want="$name" '$1 == want { print $2; exit }')"
    # A machine with no /etc/locale.conf still has a LANG, and localectl is the
    # one that knows it. Categories have no such fallback: absent there means
    # absent, which is exactly "match language".
    if [[ -z "$value" && "$name" == "LANG" ]]; then
        value="$(localectl status 2>/dev/null \
            | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
            | tr -d '[:space:]')"
    fi
    printf '%s\n' "$value"
}

# Every category override in one answer: {"LC_TIME": "de_DE.UTF-8", ...} with
# "" for the ones that match the language. Five separate `get` calls would be
# five processes for one screenful of state.
cmd_overrides() {
    local assignments name
    assignments="$(current_assignments)"
    { for name in "${CATEGORIES[@]}"; do
        printf '%s\t%s\n' "$name" \
            "$(awk -F= -v want="$name" '$1 == want { print $2; exit }' <<<"$assignments")"
    done; } | jq -Rn '[inputs | split("\t") | {key: .[0], value: (.[1] // "")}] | from_entries'
}

cmd_list() {
    local locales
    locales="$(localectl list-locales 2>/dev/null)" || locales=""
    if [[ -z "$locales" ]]; then
        printf '[]\n'
        return 0
    fi

    # Without iso-codes installed the codes are still perfectly usable; they
    # just do not get friendly names. That is a degraded list, not a failure.
    if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
        jq -Rn --rawfile raw /dev/stdin \
            '[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
            <<<"$locales"
        return 0
    fi

    jq -Rn \
        --slurpfile languages "$ISO_LANG" \
        --slurpfile countries "$ISO_COUNTRY" \
        --rawfile raw /dev/stdin '
        # alpha_2 -> name, for both databases. Languages without a two-letter
        # code cannot appear in a locale name, so they are simply absent.
        ($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
        | ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
        | [ $raw
            | split("\n")[]
            | select(length > 0)
            | . as $value
            # en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
            # not part of the human name.
            | ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
            | ($lang[$parts[0]] // $parts[0]) as $language
            | (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
            | {
                value: $value,
                label: (if $region then "\($language) (\($region))" else $language end),
                detail: $value
              }
          ]
        | sort_by(.label)
    ' <<<"$locales"
}

# A locale name that is both well formed and actually installed. Everything
# reaching localectl goes through this: it is a privileged command, and the set
# of legal locale names is narrow and well known.
installed_locale() {
    local locale="$1"
    [[ "$locale" =~ ^[a-zA-Z0-9_@.-]+$ ]] || {
        printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
        return 2
    }
    localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
        printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
        return 2
    }
}

cmd_set() {
    local locale="${1:-}"
    installed_locale "$locale" || return 2
    # LANG is set on its own rather than through the rewrite path: changing the
    # language must not quietly drop category overrides somebody chose, and
    # localectl merges a lone LANG= assignment into the existing file.
    localectl set-locale "LANG=$locale"
}

cmd_set_category() {
    local name="${1:-}" locale="${2:-}"
    is_category "$name" || {
        printf 'panama-locale: %s is not a category this manages\n' "$name" >&2
        return 2
    }

    local assignments=() line
    while IFS= read -r line; do
        [[ "${line%%=*}" == "$name" ]] && continue
        assignments+=("$line")
    done < <(current_assignments)

    if [[ -n "$locale" ]]; then
        installed_locale "$locale" || return 2
        assignments+=("$name=$locale")
    fi

    # An empty file would leave the system with no LANG at all. Nothing here
    # should be able to produce that, but refusing is cheaper than explaining.
    (( ${#assignments[@]} > 0 )) || {
        printf 'panama-locale: refusing to clear every locale setting\n' >&2
        return 2
    }
    localectl set-locale "${assignments[@]}"
}

case "${1:-list}" in
    list) cmd_list ;;
    get)  shift; cmd_get "${1:-LANG}" ;;
    set)
        shift
        if is_category "${1:-}"; then
            cmd_set_category "${1:-}" "${2:-}"
        else
            cmd_set "${1:-}"
        fi
        ;;
    categories) printf '%s\n' "${CATEGORIES[@]}" ;;
    overrides)  cmd_overrides ;;
    *)
        printf 'usage: panama-locale [list|categories|overrides|get [category]|set [category] <locale>]\n' >&2
        exit 2
        ;;
esac
