#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# paystation-rotate — Outil graphique de rotation écran + tactile pour le kiosque
# PayStation (Debian 12 / XFCE / X11, dalle tactile iLiTek).
#
# CE QU'IL FAIT
#   * Détecte tout seul l'écran branché (LVDS/eDP/HDMI/DP) et le(s) périphérique(s)
#     tactile(s) — aucun nom matériel codé en dur.
#   * Applique une orientation EN DIRECT (xrandr) et recalcule immédiatement la
#     matrice de transformation tactile, EXACTEMENT comme paystation-touch-map.sh,
#     pour que le tactile suive l'écran sans décalage.
#   * Garde-fou : après chaque test, un compte à rebours de 15 s ramène l'ancienne
#     orientation si vous ne confirmez pas (utile si l'écran devient illisible).
#   * Enregistre le choix DE FAÇON PERMANENTE dans /etc/paystation/display.conf
#     (clé ROTATE=) — c'est le point d'override que paystation-display.sh relit à
#     CHAQUE démarrage (pré-session LightDM + autostart de session). Au prochain
#     boot, l'écran ET le tactile reprennent donc la bonne orientation tout seuls.
#     Ce fichier vit dans /etc, il est donc capturé par un re-ghost de la flotte.
#
# POURQUOI PASSER PAR display.conf PLUTÔT QU'UN SYSTÈME À PART
#   paystation-display.sh ré-impose --rotate <ROTATE> à chaque boot. Tout réglage
#   posé ailleurs serait écrasé. Écrire ROTATE= dans display.conf est donc la
#   SEULE persistance qui « tient » sur ce matériel — et elle réutilise le chemin
#   d'application déjà éprouvé (écran + tactile couplé).
#
# ROTATION TACTILE INDÉPENDANTE (tous les montages)
#   Le tactile suit l'écran automatiquement. En plus, le digitaliseur ILITEK peut
#   être monté tourné par rapport à la dalle (90°, 180°, 270°). On compose alors
#   une rotation supplémentaire sur la SEULE matrice tactile, persistée via
#   TOUCH_ROTATE=normal|left|right|inverted (TOUCH_FLIP=1 reste accepté = inverted).
#   paystation-touch-map.sh la relit nativement à chaque démarrage.
#
# LUMINOSITÉ
#   5–100 % pilotent le VRAI rétroéclairage (/sys/class/backlight, qualité
#   parfaite, sans perte). Le matériel étant déjà au max, 100–150 % ajoutent un
#   boost gamma logiciel (xrandr --brightness) qui éclaircit en délavant l'image
#   — dernier recours plein soleil. Persisté via BRIGHTNESS=<pct> dans
#   display.conf et ré-appliqué au boot par paystation-display.sh.
#
# USAGE
#   paystation-rotate                       -> ouvre l'interface graphique
#   paystation-rotate --status              -> affiche l'état courant (terminal)
#   paystation-rotate --apply <ORIENT>      -> tourne l'écran (scriptable)
#                                              ORIENT = normal|left|right|inverted
#       options : --touch-rotate <ORIENT>  (rotation tactile supplémentaire)
#                 --touch-flip             (alias de --touch-rotate inverted)
#                 --save                   (enregistre dans display.conf, permanent)
#   paystation-rotate --brightness <5..150> [--save]  -> règle la luminosité
#
import os
import sys
import shutil
import subprocess

DISPLAY_CONF = "/etc/paystation/display.conf"
TOUCH_CONF = "/etc/paystation/touch.conf"
TOUCH_MAP_SCRIPT = "/home/paystation/bin/paystation-touch-map.sh"
BACKLIGHT_DIR = "/sys/class/backlight"

ORIENTATIONS = ["normal", "left", "right", "inverted"]

# Luminosité. 5–100 % = rétroéclairage matériel réel ; 100–150 % = matériel au
# max + boost gamma logiciel (xrandr --brightness) qui délave l'image.
BRIGHTNESS_MIN = 5
BRIGHTNESS_HW_MAX = 100
BRIGHTNESS_MAX = 150
TOUCH_LABELS = {
    "normal": "Tactile aligné sur l'écran (par défaut)",
    "left": "Tactile tourné 90° anti-horaire vs l'écran",
    "right": "Tactile tourné 90° horaire vs l'écran",
    "inverted": "Tactile inversé 180° vs l'écran (digitaliseur monté à l'envers)",
}
LABELS = {
    "normal": "Normale (0°) — paysage",
    "left": "Gauche 90° — portrait",
    "right": "Droite 90° — portrait (inverse de « gauche »)",
    "inverted": "Inversée 180° — paysage retourné",
}
# Aide visuelle : où se trouve le « haut » de l'image pour chaque choix.
HINTS = {
    "normal": "Le haut de l'image est en haut de la dalle (montage standard).",
    "left": "Image pivotée 90° anti-horaire (portrait).",
    "right": "Image pivotée 90° horaire (portrait) — c'est le portrait « à l'envers » de gauche.",
    "inverted": "Image retournée tête-en-bas (dalle montée à 180°).",
}

# Le compte à rebours doit laisser le temps de cliquer sans être interminable.
COUNTDOWN_SECONDS = 15

# Toujours forcer la locale C : la matrice est calculée en virgule flottante et
# xinput exige le point décimal, pas la virgule fr_FR (« 0,5 » casserait set-prop).
ENV = dict(os.environ)
ENV["LC_ALL"] = "C"
ENV.setdefault("DISPLAY", ":0")


# --------------------------------------------------------------------------- #
#  Couche bas niveau : xrandr / xinput                                         #
# --------------------------------------------------------------------------- #
def run(cmd):
    """Exécute une commande, renvoie (code_retour, stdout)."""
    try:
        p = subprocess.run(cmd, env=ENV, stdout=subprocess.PIPE,
                           stderr=subprocess.STDOUT, text=True)
        return p.returncode, p.stdout
    except FileNotFoundError:
        return 127, ""


def xrandr_query():
    rc, out = run(["xrandr", "--query"])
    return out if rc == 0 else ""


def detect_output(forced=""):
    """Choisit l'écran client. MÊME logique que paystation-display.sh :
    on préfère la sortie connectée ayant un vrai EDID (taille physique non nulle),
    pour ne jamais tomber sur un LVDS « fantôme » 0mm. Override possible."""
    if forced:
        return forced
    q = xrandr_query()
    connected = [l.split()[0] for l in q.splitlines() if " connected" in l]
    if not connected:
        return ""
    if len(connected) == 1:
        return connected[0]
    for l in q.splitlines():
        if " connected" in l and "mm x" in l:
            # vrai EDID = au moins un champ « <n>mm » non nul
            import re
            if re.search(r"\b[1-9][0-9]*mm\b", l):
                return l.split()[0]
    for l in q.splitlines():
        if " connected primary" in l:
            return l.split()[0]
    return connected[0]


def output_line(output):
    for l in xrandr_query().splitlines():
        if l.startswith(output + " ") and "connected" in l:
            return l
    return ""


def current_rotation(output):
    """Lit l'orientation live depuis xrandr (mot-clé après la géométrie)."""
    line = output_line(output)
    for r in ("left", "right", "inverted"):
        if (" %s " % r) in line or line.rstrip().endswith(" %s" % r):
            # éviter de confondre « inverted » dans la liste des capacités
            import re
            if re.search(r"\+\d+ %s\b" % r, line):
                return r
    return "normal"


def native_mode(output):
    """Renvoie la résolution native (mode marqué '+' préféré) pour info."""
    q = xrandr_query()
    grab = False
    import re
    for l in q.splitlines():
        if l.startswith(output + " "):
            grab = True
            continue
        if grab:
            if l and not l[0].isspace():
                break
            m = re.search(r"(\d+x\d+)", l)
            if m and "+" in l:
                return m.group(1)
            if m:
                return m.group(1)
    return "?"


def list_touch_ids():
    """IDs des périphériques tactiles : un tactile expose la propriété
    'libinput Calibration Matrix' ; une souris/clavier non. Identique au filtre
    de paystation-touch-map.sh, donc la souris Logitech n'est jamais happée."""
    rc, out = run(["xinput", "list", "--id-only"])
    ids = []
    if rc != 0:
        return ids
    for did in out.split():
        rc2, props = run(["xinput", "list-props", did])
        if rc2 == 0 and "libinput Calibration Matrix" in props:
            ids.append(did)
    return ids


def touch_name(did):
    rc, out = run(["xinput", "list", "--name-only", did])
    return out.strip() if rc == 0 else ("id=" + did)


# --------------------------------------------------------------------------- #
#  Calcul de matrice — RÉPLIQUE EXACTE de compute_matrix() du script tactile   #
#  Final = S x R, où S = rectangle de la sortie normalisé à l'écran virtuel,   #
#  R = rotation de la dalle. Pour un kiosque mono-écran plein cadre, S = I.    #
# --------------------------------------------------------------------------- #
def compute_matrix(output):
    q = xrandr_query()
    import re
    mscr = re.search(r"current\s+(\d+)\s*x\s*(\d+)", q)
    if not mscr:
        return None
    W, H = float(mscr.group(1)), float(mscr.group(2))
    line = output_line(output)
    mgeo = re.search(r"(\d+)x(\d+)\+(\d+)\+(\d+)", line)
    if not mgeo:
        return None
    ow, oh, ox, oy = (float(mgeo.group(i)) for i in range(1, 5))
    rot = "normal"
    for r in ("left", "right", "inverted"):
        if re.search(r"\+%d\+%d %s\b" % (int(ox), int(oy), r), line):
            rot = r
            break
    sa, sd, se, sf = ow / W, oh / H, ox / W, oy / H
    if rot == "left":
        m = [0, -sa, sa + se, sd, 0, sf, 0, 0, 1]
    elif rot == "right":
        m = [0, sa, se, -sd, 0, sd + sf, 0, 0, 1]
    elif rot == "inverted":
        m = [-sa, 0, sa + se, 0, -sd, sd + sf, 0, 0, 1]
    else:
        m = [sa, 0, se, 0, sd, sf, 0, 0, 1]
    return m


# Rotations supplémentaires appliquées à la SEULE matrice tactile, dans l'espace
# écran normalisé [0,1]². Servent quand le digitaliseur ILITEK est monté tourné
# par rapport à la dalle, à N'IMPORTE QUEL angle (pas seulement 180°). On compose
# R_touch × M, M étant la matrice qui fait déjà suivre l'écran au tactile.
#   normal   = identité
#   left  90°: (x,y)->(y,1-x)   right 90°: (x,y)->(1-y,x)   inverted 180°: (x,y)->(1-x,1-y)
TOUCH_ROT = {
    "normal":   [1, 0, 0, 0, 1, 0, 0, 0, 1],
    "left":     [0, 1, 0, -1, 0, 1, 0, 0, 1],
    "right":    [0, -1, 1, 1, 0, 0, 0, 0, 1],
    "inverted": [-1, 0, 1, 0, -1, 1, 0, 0, 1],
}


def mat3_mul(r, m):
    """Produit de deux matrices 3×3 stockées row-major : P = R × M."""
    p = [0.0] * 9
    for i in range(3):
        for j in range(3):
            p[3 * i + j] = (r[3 * i] * m[j]
                            + r[3 * i + 1] * m[3 + j]
                            + r[3 * i + 2] * m[6 + j])
    return p


def compose_touch_rotation(m, touch_rotate):
    """Compose la rotation tactile supplémentaire sur la matrice déjà calculée.
    'normal' (ou vide) = no-op -> le tactile suit simplement l'écran."""
    r = TOUCH_ROT.get(touch_rotate or "normal")
    if r is None or touch_rotate in (None, "", "normal"):
        return m
    return mat3_mul(r, m)


def norm_touch_rotate(touch_rotate=None, touch_flip=False):
    """Normalise les entrées héritées : --touch-flip == 'inverted'."""
    if touch_rotate in ORIENTATIONS:
        return touch_rotate
    return "inverted" if touch_flip else "normal"


def fmt_matrix(m):
    return ["%.6f" % v for v in m]


# --------------------------------------------------------------------------- #
#  Application live                                                            #
# --------------------------------------------------------------------------- #
def apply_orientation(output, rot, touch_rotate="normal"):
    """Applique l'orientation à l'écran ET au(x) tactile(s). 'touch_rotate' est la
    rotation tactile SUPPLÉMENTAIRE (montage du digitaliseur). Renvoie (ok, msg)."""
    touch_rotate = touch_rotate or "normal"
    rc, out = run(["xrandr", "--output", output, "--auto",
                   "--rotate", rot, "--primary"])
    if rc != 0:
        return False, "Échec xrandr : " + out.strip()
    m = compute_matrix(output)
    if m is None:
        return False, "Rotation appliquée mais matrice tactile incalculable."
    m = compose_touch_rotation(m, touch_rotate)
    vals = fmt_matrix(m)
    ids = list_touch_ids()
    if not ids:
        return True, "Écran tourné (%s). Aucun tactile détecté." % rot
    failed = []
    for did in ids:
        rc2, _ = run(["xinput", "set-prop", did,
                      "Coordinate Transformation Matrix"] + vals)
        if rc2 != 0:
            failed.append(touch_name(did))
    if failed:
        return False, "Tactile non mappé : " + ", ".join(failed)
    extra = "" if touch_rotate == "normal" else " (tactile « %s »)" % touch_rotate
    return True, "Écran + tactile en « %s »%s." % (rot, extra)


# --------------------------------------------------------------------------- #
#  Luminosité — rétroéclairage matériel (5–100 %) + boost gamma (100–150 %)    #
# --------------------------------------------------------------------------- #
def detect_backlight():
    """Renvoie le chemin du contrôleur de rétroéclairage, ou None."""
    try:
        devs = sorted(os.listdir(BACKLIGHT_DIR))
    except OSError:
        return None
    if not devs:
        return None
    for pref in ("intel_backlight", "amdgpu_bl0", "amdgpu_bl1", "acpi_video0"):
        if pref in devs:
            return os.path.join(BACKLIGHT_DIR, pref)
    return os.path.join(BACKLIGHT_DIR, devs[0])


def read_backlight():
    """(path, cur_raw, max_raw) ou (None, 0, 0) si pas de rétroéclairage."""
    p = detect_backlight()
    if not p:
        return None, 0, 0
    try:
        with open(os.path.join(p, "brightness")) as fh:
            cur = int(fh.read().strip())
        with open(os.path.join(p, "max_brightness")) as fh:
            mx = int(fh.read().strip())
        return p, cur, mx
    except (OSError, ValueError):
        return None, 0, 0


def write_backlight(path, raw):
    """Écrit la valeur brute. /sys est inscriptible par le groupe 'video' grâce à
    la règle udev 90-paystation-backlight.rules ; repli pkexec si jamais root."""
    bf = os.path.join(path, "brightness")
    try:
        with open(bf, "w") as fh:
            fh.write("%d\n" % raw)
        return True
    except OSError:
        try:
            p = subprocess.run(["pkexec", "tee", bf], input="%d\n" % raw,
                               text=True, stdout=subprocess.DEVNULL)
            return p.returncode == 0
        except FileNotFoundError:
            return False


def current_gamma(output):
    """Lit le multiplicateur Brightness (gamma) xrandr de la sortie."""
    if not output:
        return 1.0
    rc, out = run(["xrandr", "--verbose"])
    if rc != 0:
        return 1.0
    grab = False
    for l in out.splitlines():
        if l.startswith(output + " "):
            grab = True
            continue
        if grab:
            if l and not l[0].isspace():
                break
            s = l.strip()
            if s.startswith("Brightness:"):
                try:
                    return float(s.split(":", 1)[1])
                except ValueError:
                    return 1.0
    return 1.0


def brightness_inverted():
    """True si le rétroéclairage de CETTE dalle est inversé (valeur basse = clair).
    Particularité matérielle calibrée PAR UNITÉ dans display.conf
    (BRIGHTNESS_INVERT=1). Défaut: 0 (normal) -> ne casse aucune autre borne."""
    return read_conf(DISPLAY_CONF).get("BRIGHTNESS_INVERT", "0") == "1"


def pct_to_raw(hw_pct, mx, invert):
    """% de luminosité PERÇUE (100 = le plus lumineux) -> valeur brute sysfs.
    Si 'invert', 100 % perçu correspond à la valeur basse (1)."""
    f = max(0.0, min(1.0, hw_pct / 100.0))
    raw = (1.0 - f) * (mx - 1) + 1 if invert else f * mx
    return max(1, min(mx, int(round(raw))))


def raw_to_pct(raw, mx, invert):
    """Valeur brute sysfs -> % de luminosité PERÇUE (100 = le plus lumineux)."""
    if not mx:
        return BRIGHTNESS_HW_MAX
    if invert:
        return int(round((1.0 - (raw - 1) / float(mx - 1)) * 100)) if mx > 1 else 100
    return int(round(100.0 * raw / mx))


def current_brightness_pct(output=None):
    """Pourcentage PERÇU courant : gamma>1 prime (boost actif), sinon le matériel
    (en tenant compte d'un éventuel rétroéclairage inversé)."""
    if output is None:
        output = detect_output()
    g = current_gamma(output)
    if g > 1.001:
        return max(BRIGHTNESS_HW_MAX, min(BRIGHTNESS_MAX, int(round(100 * g))))
    _, cur, mx = read_backlight()
    return raw_to_pct(cur, mx, brightness_inverted()) if mx else BRIGHTNESS_HW_MAX


def apply_brightness(pct, output=None):
    """Applique la luminosité. 5–100 % = rétroéclairage matériel ; 100–150 % =
    matériel au max + boost gamma. Renvoie (ok, msg)."""
    try:
        pct = int(round(float(pct)))
    except (TypeError, ValueError):
        return False, "Valeur de luminosité invalide : %r" % (pct,)
    pct = max(BRIGHTNESS_MIN, min(BRIGHTNESS_MAX, pct))
    if output is None:
        output = detect_output()
    invert = brightness_inverted()
    parts = []
    # 1) part matérielle (toujours portée au plafond demandé, max 100 %)
    hw_pct = min(pct, BRIGHTNESS_HW_MAX)
    p, _, mx = read_backlight()
    if p and mx:
        raw = pct_to_raw(hw_pct, mx, invert)
        if not write_backlight(p, raw):
            return False, ("Rétroéclairage non inscriptible (%s). La règle udev "
                           "backlight est-elle installée ?" % p)
        parts.append("rétroéclairage %d%%%s" % (hw_pct, " [inversé]" if invert else ""))
    else:
        parts.append("pas de rétroéclairage matériel")
    # 2) boost gamma au-delà de 100 % (sinon gamma neutre 1.0)
    gamma = pct / 100.0 if pct > BRIGHTNESS_HW_MAX else 1.0
    if output:
        run(["xrandr", "--output", output, "--brightness", "%.3f" % gamma])
        if gamma > 1.0:
            parts.append("boost gamma ×%.2f (image délavée)" % gamma)
    return True, "Luminosité %d%% — %s." % (pct, ", ".join(parts))


# --------------------------------------------------------------------------- #
#  Persistance                                                                 #
# --------------------------------------------------------------------------- #
def read_conf(path):
    """Lit un fichier KEY=value en un dict (ignore commentaires)."""
    d = {}
    if os.path.exists(path):
        try:
            with open(path) as fh:
                for ln in fh:
                    ln = ln.strip()
                    if not ln or ln.startswith("#") or "=" not in ln:
                        continue
                    k, v = ln.split("=", 1)
                    d[k.strip()] = v.strip().strip('"').strip("'")
        except OSError:
            pass
    return d


def write_conf_atomic(path, lines):
    """Écrit le fichier de façon atomique. /etc/paystation est détenu par
    'paystation' (drwxrwxr-x) -> écriture sans root. Repli pkexec si jamais
    les droits changeaient un jour."""
    content = "".join(lines)
    tmp = path + ".tmp.%d" % os.getpid()
    try:
        with open(tmp, "w") as fh:
            fh.write(content)
        os.replace(tmp, path)
        return True, ""
    except OSError as e:
        try:
            os.remove(tmp)
        except OSError:
            pass
        # Repli : élévation graphique via pkexec.
        sh = "cat > %s" % shell_quote(path)
        try:
            p = subprocess.run(["pkexec", "sh", "-c", sh],
                              input=content, text=True)
            if p.returncode == 0:
                return True, ""
        except FileNotFoundError:
            pass
        return False, str(e)


def shell_quote(s):
    return "'" + s.replace("'", "'\\''") + "'"


DISPLAY_HEADER = (
    "# /etc/paystation/display.conf — généré par paystation-rotate\n"
    "# Override relu par paystation-display.sh à CHAQUE démarrage (pré-session\n"
    "# LightDM + autostart). Le tactile suit via paystation-touch-map.sh.\n"
    "# Capturé par un re-ghost (/etc).\n"
    "#   ROTATE       : normal | left | right | inverted  (orientation de l'écran)\n"
    "#   KIOSK_OUTPUT : forcer une sortie ; vide = autodétection (préféré)\n"
    "#   TOUCH_ROTATE : normal | left | right | inverted  (rotation tactile EN PLUS)\n"
    "#   TOUCH_FLIP   : compat héritée ; 1 == TOUCH_ROTATE=inverted\n"
    "#   BRIGHTNESS   : 5..150  (100 = le plus lumineux ; 100–150 = boost gamma)\n"
    "#   BRIGHTNESS_INVERT : 1 si le rétroéclairage est inversé (valeur basse=clair),\n"
    "#                       calibration PAR UNITÉ. Défaut 0 (normal).\n"
)
DISPLAY_KEYS = ["ROTATE", "KIOSK_OUTPUT", "TOUCH_ROTATE", "TOUCH_FLIP",
                "BRIGHTNESS", "BRIGHTNESS_INVERT"]


def update_display_conf(updates):
    """Fusionne 'updates' (dict) dans display.conf SANS écraser les autres clés —
    indispensable car la luminosité et la rotation s'enregistrent séparément."""
    cur = read_conf(DISPLAY_CONF)
    for k, v in updates.items():
        if v is not None:
            cur[k] = v
    lines = [DISPLAY_HEADER]
    for k in DISPLAY_KEYS:
        if k in cur:
            lines.append("%s=%s\n" % (k, cur[k]))
    for k, v in cur.items():            # préserve d'éventuelles clés inconnues
        if k not in DISPLAY_KEYS:
            lines.append("%s=%s\n" % (k, v))
    return write_conf_atomic(DISPLAY_CONF, lines)


def write_touch_conf(touch_rotate):
    """Reflète TOUCH_ROTATE (+ compat TOUCH_FLIP) dans touch.conf, lu par
    paystation-touch-map.sh. Préserve un TOUCH_OUTPUT éventuel."""
    tlines = [
        "# /etc/paystation/touch.conf — géré par paystation-rotate\n",
        "TOUCH_ROTATE=%s\n" % touch_rotate,
        "TOUCH_FLIP=%s\n" % ("1" if touch_rotate == "inverted" else "0"),
    ]
    prev = read_conf(TOUCH_CONF)
    if prev.get("TOUCH_OUTPUT"):
        tlines.append("TOUCH_OUTPUT=%s\n" % prev["TOUCH_OUTPUT"])
    return write_conf_atomic(TOUCH_CONF, tlines)


def persist(output, rot, touch_rotate, forced_output=""):
    """Enregistre l'orientation écran + la rotation tactile, de façon permanente."""
    touch_rotate = touch_rotate or "normal"
    ok, err = update_display_conf({
        "ROTATE": rot,
        "KIOSK_OUTPUT": forced_output or "",
        "TOUCH_ROTATE": touch_rotate,
        "TOUCH_FLIP": "1" if touch_rotate == "inverted" else "0",
    })
    if not ok:
        return False, "Impossible d'écrire %s : %s" % (DISPLAY_CONF, err)
    ok2, err2 = write_touch_conf(touch_rotate)
    extra = "" if ok2 else " (avertissement : touch.conf non écrit : %s)" % err2
    return True, "Enregistré (ROTATE=%s, TOUCH_ROTATE=%s).%s" % (rot, touch_rotate, extra)


def persist_brightness(pct, invert=None):
    """Enregistre BRIGHTNESS (+ éventuellement BRIGHTNESS_INVERT) dans display.conf,
    indépendamment de la rotation."""
    try:
        pct = max(BRIGHTNESS_MIN, min(BRIGHTNESS_MAX, int(round(float(pct)))))
    except (TypeError, ValueError):
        return False, "Valeur de luminosité invalide."
    updates = {"BRIGHTNESS": str(pct)}
    if invert is not None:
        updates["BRIGHTNESS_INVERT"] = "1" if invert else "0"
    ok, err = update_display_conf(updates)
    if not ok:
        return False, "Impossible d'écrire %s : %s" % (DISPLAY_CONF, err)
    inv = "" if invert is None else (" [inversé]" if invert else " [normal]")
    return True, "Luminosité %d%%%s enregistrée (ré-appliquée au démarrage)." % (pct, inv)


def persisted_touch_rotate():
    """Lit la rotation tactile persistée (TOUCH_ROTATE, ou compat TOUCH_FLIP=1)."""
    c = read_conf(DISPLAY_CONF)
    return c.get("TOUCH_ROTATE") or ("inverted" if c.get("TOUCH_FLIP") == "1" else "normal")


# --------------------------------------------------------------------------- #
#  Mode terminal (sans interface)                                             #
# --------------------------------------------------------------------------- #
def cli_status():
    out = detect_output()
    print("Écran détecté     : %s" % (out or "(aucun)"))
    if out:
        print("  résolution native: %s" % native_mode(out))
        print("  orientation live : %s" % current_rotation(out))
    ids = list_touch_ids()
    if ids:
        for did in ids:
            print("Tactile           : %s (id=%s)" % (touch_name(did), did))
    else:
        print("Tactile           : (aucun détecté)")
    print("Luminosité live   : %d%%" % current_brightness_pct(out))
    bp, _, bmx = read_backlight()
    print("  rétroéclairage   : %s" % (bp or "(aucun détecté)"))
    conf = read_conf(DISPLAY_CONF)
    tr = conf.get("TOUCH_ROTATE") or ("inverted" if conf.get("TOUCH_FLIP") == "1" else "normal")
    print("Persistant (display.conf):")
    print("  ROTATE      = %s" % conf.get("ROTATE", "(non défini -> défaut 'left')"))
    print("  KIOSK_OUTPUT= %s" % conf.get("KIOSK_OUTPUT", "(auto)"))
    print("  TOUCH_ROTATE= %s" % tr)
    print("  BRIGHTNESS  = %s" % conf.get("BRIGHTNESS", "(non défini -> 100)"))
    print("  BRIGHTNESS_INVERT= %s" % conf.get("BRIGHTNESS_INVERT", "0"))


def cli_apply(rot, touch_rotate, save):
    if rot not in ORIENTATIONS:
        print("Orientation invalide : %s (attendu : %s)" %
              (rot, "|".join(ORIENTATIONS)))
        return 2
    if touch_rotate not in ORIENTATIONS:
        print("Rotation tactile invalide : %s (attendu : %s)" %
              (touch_rotate, "|".join(ORIENTATIONS)))
        return 2
    out = detect_output()
    if not out:
        print("Aucun écran connecté détecté.")
        return 1
    ok, msg = apply_orientation(out, rot, touch_rotate)
    print(msg)
    if not ok:
        return 1
    if save:
        ok2, msg2 = persist(out, rot, touch_rotate)
        print(msg2)
        return 0 if ok2 else 1
    return 0


def cli_brightness(pct, save, invert=None):
    # Si on (dé)active l'inversion, l'enregistrer AVANT d'appliquer pour que
    # apply_brightness lise le bon sens depuis display.conf.
    if invert is not None:
        update_display_conf({"BRIGHTNESS_INVERT": "1" if invert else "0"})
    ok, msg = apply_brightness(pct)
    print(msg)
    if not ok:
        return 1
    if save:
        ok2, msg2 = persist_brightness(pct, invert)
        print(msg2)
        return 0 if ok2 else 1
    return 0


# --------------------------------------------------------------------------- #
#  Interface graphique (GTK3)                                                  #
# --------------------------------------------------------------------------- #
def launch_gui():
    import gi
    gi.require_version("Gtk", "3.0")
    from gi.repository import Gtk, GLib

    output = detect_output()

    class RotateWindow(Gtk.Window):
        def __init__(self):
            super().__init__(title="PayStation — Rotation écran & tactile")
            self.set_border_width(16)
            self.set_default_size(520, 0)
            self.set_position(Gtk.WindowPosition.CENTER)
            try:
                self.set_icon_name("preferences-desktop-display")
            except Exception:
                pass

            self.output = output
            self.applied = (current_rotation(output) if output else "normal",
                            persisted_touch_rotate())
            self._revert_timer = None
            self._bright_timer = None

            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            self.add(box)

            title = Gtk.Label()
            title.set_markup("<b>Orientation de l'écran et du tactile</b>")
            title.set_xalign(0)
            box.pack_start(title, False, False, 0)

            # Bandeau d'info matériel détecté.
            ids = list_touch_ids()
            tlist = ", ".join(touch_name(i) for i in ids) or "aucun"
            info = Gtk.Label()
            info.set_markup(
                "<small>Écran : <b>%s</b> (dalle %s)\nTactile : %s</small>" %
                (GLib.markup_escape_text(output or "aucun"),
                 GLib.markup_escape_text(native_mode(output) if output else "?"),
                 GLib.markup_escape_text(tlist)))
            info.set_xalign(0)
            box.pack_start(info, False, False, 0)
            box.pack_start(Gtk.Separator(), False, False, 0)

            # Choix d'orientation (radios).
            self.radios = {}
            grp = None
            for rot in ORIENTATIONS:
                rb = Gtk.RadioButton.new_with_label_from_widget(grp, LABELS[rot])
                if grp is None:
                    grp = rb
                rb.set_tooltip_text(HINTS[rot])
                if rot == self.applied[0]:
                    rb.set_active(True)
                self.radios[rot] = rb
                box.pack_start(rb, False, False, 0)

            # Rotation tactile INDÉPENDANTE (montage du digitaliseur ILITEK).
            exp = Gtk.Expander(label="Rotation du tactile (montage ILITEK)")
            adv = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
            adv.set_margin_top(6)
            adv.set_margin_start(12)
            note = Gtk.Label()
            note.set_markup("<small>Le tactile suit l'écran automatiquement. Si le "
                            "digitaliseur est monté tourné par rapport à la dalle,\n"
                            "appliquez ici une rotation tactile EN PLUS de l'écran "
                            "(gère tous les montages : 90°, 180°, 270°).</small>")
            note.set_xalign(0)
            adv.add(note)
            self.touch_combo = Gtk.ComboBoxText()
            for val in ORIENTATIONS:
                self.touch_combo.append(val, TOUCH_LABELS[val])
            self.touch_combo.set_active_id(self.applied[1])
            adv.add(self.touch_combo)
            exp.add(adv)
            box.pack_start(exp, False, False, 0)

            box.pack_start(Gtk.Separator(), False, False, 0)

            # Luminosité — rétroéclairage matériel (5–100 %) + boost gamma (>100 %).
            blab = Gtk.Label()
            blab.set_markup("<b>Luminosité</b>")
            blab.set_xalign(0)
            box.pack_start(blab, False, False, 0)
            self.bright_scale = Gtk.Scale.new_with_range(
                Gtk.Orientation.HORIZONTAL, BRIGHTNESS_MIN, BRIGHTNESS_MAX, 1)
            self.bright_scale.set_value(current_brightness_pct(output))
            self.bright_scale.set_digits(0)
            self.bright_scale.set_hexpand(True)
            for mark in (BRIGHTNESS_MIN, BRIGHTNESS_HW_MAX, BRIGHTNESS_MAX):
                self.bright_scale.add_mark(mark, Gtk.PositionType.BOTTOM, "%d%%" % mark)
            self.bright_scale.connect("value-changed", self.on_bright_changed)
            box.pack_start(self.bright_scale, False, False, 0)
            bnote = Gtk.Label()
            bnote.set_markup("<small>5–100 % = vrai rétroéclairage (qualité parfaite). "
                             "Au-delà de 100 %, boost logiciel qui éclaircit en délavant\n"
                             "l'image (le matériel est déjà au maximum).</small>")
            bnote.set_xalign(0)
            box.pack_start(bnote, False, False, 0)
            bbtns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            save_b = Gtk.Button(label="Enregistrer la luminosité")
            save_b.connect("clicked", self.on_save_brightness)
            bbtns.pack_start(save_b, False, False, 0)
            box.pack_start(bbtns, False, False, 0)

            box.pack_start(Gtk.Separator(), False, False, 0)

            # Barre de boutons.
            btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
            test_btn = Gtk.Button(label="Tester (avec retour auto)")
            test_btn.connect("clicked", self.on_test)
            save_btn = Gtk.Button(label="Enregistrer définitivement")
            save_btn.get_style_context().add_class("suggested-action")
            save_btn.connect("clicked", self.on_save)
            quit_btn = Gtk.Button(label="Quitter")
            quit_btn.connect("clicked", lambda *_: self.close())
            btns.pack_start(test_btn, True, True, 0)
            btns.pack_start(save_btn, True, True, 0)
            btns.pack_start(quit_btn, False, False, 0)
            box.pack_start(btns, False, False, 0)

            # Barre d'état.
            self.status = Gtk.Label(xalign=0)
            self.status.set_line_wrap(True)
            self._set_status(self._persisted_summary())
            box.pack_start(self.status, False, False, 0)

            if not output:
                for w in (test_btn, save_btn):
                    w.set_sensitive(False)
                self._set_status("Aucun écran connecté détecté — branchez la dalle.")

        # --- helpers -----------------------------------------------------
        def _selected(self):
            rot = "normal"
            for r, rb in self.radios.items():
                if rb.get_active():
                    rot = r
                    break
            return rot, (self.touch_combo.get_active_id() or "normal")

        def _persisted_summary(self):
            c = read_conf(DISPLAY_CONF)
            if "ROTATE" in c:
                tr = c.get("TOUCH_ROTATE") or (
                    "inverted" if c.get("TOUCH_FLIP") == "1" else "normal")
                return ("Réglage permanent : ROTATE=%s, TOUCH_ROTATE=%s, "
                        "BRIGHTNESS=%s%%." %
                        (c.get("ROTATE"), tr, c.get("BRIGHTNESS", "100")))
            return "Aucun réglage permanent — défaut du système (ROTATE=left)."

        def _set_status(self, txt):
            self.status.set_markup("<small>%s</small>" % GLib.markup_escape_text(txt))

        def _apply(self, rot, touch_rotate):
            ok, msg = apply_orientation(self.output, rot, touch_rotate)
            self._set_status(msg)
            return ok

        # --- actions -----------------------------------------------------
        def on_test(self, _btn):
            rot, touch_rotate = self._selected()
            prev = self.applied
            if not self._apply(rot, touch_rotate):
                return
            self._confirm_dialog(rot, touch_rotate, prev, allow_save=True)

        def on_save(self, _btn):
            rot, touch_rotate = self._selected()
            # On applique d'abord en direct (sans retour auto : c'est un choix
            # assumé via ce bouton), puis on persiste.
            if not self._apply(rot, touch_rotate):
                return
            self.applied = (rot, touch_rotate)
            self._do_persist(rot, touch_rotate)

        def _do_persist(self, rot, touch_rotate):
            ok, msg = persist(self.output, rot, touch_rotate)
            self._set_status(msg)
            self._maybe_restart_panel()

        # --- luminosité --------------------------------------------------
        def on_bright_changed(self, scale):
            # Débounce : on n'applique qu'après une courte pause de glissement.
            pct = int(scale.get_value())
            if self._bright_timer:
                GLib.source_remove(self._bright_timer)
            self._bright_timer = GLib.timeout_add(120, self._apply_bright, pct)

        def _apply_bright(self, pct):
            self._bright_timer = None
            _, msg = apply_brightness(pct, self.output)
            self._set_status(msg)
            return False  # one-shot

        def on_save_brightness(self, _btn):
            pct = int(self.bright_scale.get_value())
            apply_brightness(pct, self.output)
            _, msg = persist_brightness(pct)
            self._set_status(msg)

        def _maybe_restart_panel(self):
            # Le panneau XFCE se replace en général seul sur 'monitors-changed' ;
            # un restart conditionnel garantit la bonne position de la barre.
            if ENV.get("DBUS_SESSION_BUS_ADDRESS"):
                run(["xfce4-panel", "--restart"])

        def _confirm_dialog(self, rot, touch_rotate, prev, allow_save):
            dlg = Gtk.Dialog(title="Garder cette orientation ?", transient_for=self,
                            modal=True)
            dlg.add_button("Annuler (revenir)", Gtk.ResponseType.CANCEL)
            keep = dlg.add_button("Garder", Gtk.ResponseType.OK)
            keep.get_style_context().add_class("suggested-action")
            area = dlg.get_content_area()
            area.set_spacing(10)
            area.set_border_width(14)
            lbl = Gtk.Label()
            area.add(lbl)
            save_chk = Gtk.CheckButton(
                label="Enregistrer définitivement (appliqué aussi au démarrage)")
            save_chk.set_active(True)
            if allow_save:
                area.add(save_chk)
            dlg.show_all()

            state = {"left": COUNTDOWN_SECONDS}

            def tick():
                state["left"] -= 1
                if state["left"] <= 0:
                    dlg.response(Gtk.ResponseType.CANCEL)
                    return False
                lbl.set_markup(
                    "L'écran est maintenant en <b>%s</b>.\n"
                    "Retour automatique à l'ancienne orientation dans <b>%d s</b>…"
                    % (GLib.markup_escape_text(rot), state["left"]))
                return True

            lbl.set_markup(
                "L'écran est maintenant en <b>%s</b>.\n"
                "Retour automatique à l'ancienne orientation dans <b>%d s</b>…"
                % (GLib.markup_escape_text(rot), state["left"]))
            tid = GLib.timeout_add_seconds(1, tick)
            resp = dlg.run()
            GLib.source_remove(tid)
            do_save = allow_save and save_chk.get_active()
            dlg.destroy()

            if resp == Gtk.ResponseType.OK:
                self.applied = (rot, touch_rotate)
                if do_save:
                    self._do_persist(rot, touch_rotate)
                else:
                    self._set_status("Orientation « %s » gardée (NON permanente)." % rot)
                    self._maybe_restart_panel()
            else:
                # Retour à l'orientation précédente.
                self._apply(prev[0], prev[1])
                self.applied = prev
                self.radios[prev[0]].set_active(True)
                self.touch_combo.set_active_id(prev[1])
                self._set_status("Annulé — retour à « %s »." % prev[0])

    win = RotateWindow()
    win.connect("destroy", Gtk.main_quit)
    win.show_all()
    Gtk.main()


# --------------------------------------------------------------------------- #
#  main                                                                        #
# --------------------------------------------------------------------------- #
def main(argv):
    if "--status" in argv:
        cli_status()
        return 0
    if "--brightness" in argv:
        i = argv.index("--brightness")
        pct = argv[i + 1] if i + 1 < len(argv) else ""
        invert = None
        if "--brightness-invert" in argv:
            j = argv.index("--brightness-invert")
            invert = (argv[j + 1] if j + 1 < len(argv) else "1") == "1"
        return cli_brightness(pct, "--save" in argv, invert)
    if "--apply" in argv:
        i = argv.index("--apply")
        rot = argv[i + 1] if i + 1 < len(argv) else ""
        # rotation tactile : --touch-rotate <ORIENT>, ou --touch-flip (== inverted)
        touch_rotate = "normal"
        if "--touch-rotate" in argv:
            j = argv.index("--touch-rotate")
            touch_rotate = argv[j + 1] if j + 1 < len(argv) else "normal"
        elif "--touch-flip" in argv:
            touch_rotate = "inverted"
        return cli_apply(rot, touch_rotate, "--save" in argv)
    if "--help" in argv or "-h" in argv:
        print(__doc__)
        return 0
    try:
        launch_gui()
        return 0
    except Exception as e:
        sys.stderr.write("Interface graphique indisponible : %s\n" % e)
        sys.stderr.write("Essayez : paystation-rotate --status\n")
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
