#!/usr/bin/env python3
"""
timer.py - Horloge + reveil pour PiSugar Whisplay HAT (ecran 240x280).

- Affiche uniquement l'heure en gros (HH:MM facon 7 segments,
  pivotee de 90 degres), rouge la nuit.
- Reveil a heure fixe (ALARM_HOUR:ALARM_MINUTE, 06:15 par defaut).
- Bouton :
    * pendant que l'alarme sonne  -> arrete l'alarme
    * sinon                        -> active / desactive le reveil
- Icone cloche en haut a droite quand le reveil est actif.
- L'etat actif/inactif est memorise dans ~/.timer_alarm.json.

Lancement :  python3 timer.py      (Ctrl+C pour quitter)
"""

import json
import math
import os
import struct
import subprocess
import sys
import tempfile
import threading
import time
import wave
from datetime import datetime

from PIL import Image, ImageChops, ImageDraw

# ============================ CONFIGURATION ============================
ALARM_HOUR = 6
ALARM_MINUTE = 15
ALARM_MAX_DURATION_SEC = 5         # l'alarme s'arrete seule apres 5 sec
BRIGHTNESS_DAY = 80                # retroeclairage 0-100 en journee
BRIGHTNESS_NIGHT = 10              # retroeclairage 0-100 la nuit
DAY_START_HOUR = 9                 # journee : de 9h00...
DAY_END_HOUR = 20                  # ...a 19h59 ; nuit de 20h00 a 8h59
# Aspect des chiffres (style radio-reveil 7 segments)
SEG_THICKNESS = 0.25               # epaisseur des segments (part de la largeur)
SEG_SLANT = 0                      # inclinaison (0 = droit 1 = incliné)
GHOST_LEVEL = 0                    # segments eteints visibles en fond (0 = invisibles 1 visibles)
LEADING_ZERO = True                # False : affiche " 6" au lieu de "06"
COLON_BLINK = False                # True : les ":" clignotent chaque seconde
ROTATION = 270                     # 90 ou 270 selon le sens voulu
AUDIO_DEVICE = "plughw:whisplaysound,0"
STATE_FILE = os.path.expanduser("~/.timer_alarm.json")

# Economie de CPU (le driver Whisplay fait du PWM logiciel en Python)
BACKLIGHT_PWM_HZ = 200             # driver : 1000 Hz par defaut (tres gourmand)
LED_PWM_HZ = 20                    # LED RGB : 100 Hz par defaut
BUTTON_POLL_SEC = 0.03             # lecture du bouton : 5 ms par defaut
NICE_LEVEL = 10                    # priorite basse : le systeme/reseau passe avant

# Dossier du driver Whisplay (git clone https://github.com/PiSugar/Whisplay)
WHISPLAY_CANDIDATES = [
    os.environ.get("WHISPLAY_DIR", ""),
    os.path.expanduser("~/Whisplay"),
    "/home/pi/Whisplay",
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "Whisplay"),
]
# =======================================================================

for _base in WHISPLAY_CANDIDATES:
    if _base and os.path.isfile(os.path.join(_base, "runtime", "whisplay.py")):
        sys.path.insert(0, os.path.join(_base, "runtime"))
        break
else:
    sys.exit("Driver Whisplay introuvable : definissez WHISPLAY_DIR=/chemin/vers/Whisplay")

from whisplay_client import create_whisplay_hardware  # noqa: E402
try:
    import whisplay as _whisplay  # noqa: E402
    _whisplay.WhisplayBoard.BUTTON_POLL_INTERVAL_SEC = BUTTON_POLL_SEC
except Exception:
    pass

W, H = 240, 280

# Couleurs
BG = (0, 0, 0)
FG = (255, 255, 255)
NIGHT_FG = (220, 0, 0)          # heure en rouge la nuit
ACCENT = (255, 170, 40)
ALARM_BG = (200, 20, 20)


# ----------------------------------------------------------------- utils
def to_rgb565(img):
    """Image PIL RGB -> octets RGB565 big-endian attendus par l'ecran."""
    r, g, b = img.convert("RGB").split()
    # octet haut : RRRRRGGG   octet bas : GGGBBBBB  (bits disjoints -> addition sans debordement)
    hi = ImageChops.add(r.point(lambda v: v & 0xF8), g.point(lambda v: v >> 5))
    lo = ImageChops.add(g.point(lambda v: (v & 0x1C) << 3), b.point(lambda v: v >> 3))
    return Image.merge("LA", (hi, lo)).tobytes()


def load_state():
    try:
        with open(STATE_FILE, "r", encoding="utf-8") as f:
            return bool(json.load(f).get("enabled", True))
    except Exception:
        return True


def save_state(enabled):
    try:
        with open(STATE_FILE, "w", encoding="utf-8") as f:
            json.dump({"enabled": enabled}, f)
    except Exception as e:
        print("Impossible d'enregistrer l'etat :", e)


def make_alarm_wav(path):
    """Genere un bip-bip-bip (2 s) en WAV mono 16 bits."""
    rate = 22050
    frames = bytearray()

    def tone(freq, dur):
        n = int(rate * dur)
        for i in range(n):
            env = min(1.0, i / 200, (n - i) / 200)  # petite rampe anti-clic
            v = int(26000 * env * math.sin(2 * math.pi * freq * i / rate))
            frames.extend(struct.pack("<h", v))

    def silence(dur):
        frames.extend(b"\x00\x00" * int(rate * dur))

    for _ in range(3):
        tone(1760, 0.12)
        silence(0.08)
    silence(1.4)

    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(rate)
        w.writeframes(bytes(frames))


# ------------------------------------------------------------- dessin UI
def is_night(now):
    return not (DAY_START_HOUR <= now.hour < DAY_END_HOUR)


def blend(c1, c2, t):
    return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2))


# segments allumes pour chaque chiffre (a=haut, b=haut droite, c=bas droite,
# d=bas, e=bas gauche, f=haut gauche, g=milieu)
SEGMENTS = {
    "0": "abcdef", "1": "bc", "2": "abdeg", "3": "abcdg", "4": "bcfg",
    "5": "acdfg", "6": "acdefg", "7": "abc", "8": "abcdefg", "9": "abcdfg",
    " ": "",
}


class Renderer:
    """Dessine en paysage (280x240) facon afficheur 7 segments,
    puis pivote l'image vers l'ecran 240x280."""

    LW, LH = H, W    # canevas paysage : 280 x 240
    SS = 2           # sur-echantillonnage pour des bords lisses

    MARGIN = 10      # marge gauche / droite
    GAP_DIGITS = 10  # espace entre deux chiffres
    BELL_ZONE = 52   # hauteur reservee en haut pour la cloche

    def __init__(self):
        aw = self.LW - 2 * self.MARGIN
        ah = self.LH - 2 * self.BELL_ZONE           # garde l'heure bien centree
        # largeur totale = 4 chiffres + 2 espaces + bloc ":" (0.6 chiffre) + inclinaison
        dw = (aw - 2 * self.GAP_DIGITS) / (4.6 + SEG_SLANT * 1.75)
        self.dw = dw
        self.dh = min(dw * 1.75, ah)                # hauteur chiffre
        self.colon_w = dw * 0.6
        self.t = self.dw * SEG_THICKNESS            # epaisseur segment

    # -------- un segment (hexagone allonge)
    def _seg(self, d, x0, y0, x1, y1, t, color, base_y):
        if y0 == y1:   # horizontal
            pts = [(x0, y0), (x0 + t / 2, y0 - t / 2), (x1 - t / 2, y0 - t / 2),
                   (x1, y0), (x1 - t / 2, y0 + t / 2), (x0 + t / 2, y0 + t / 2)]
        else:          # vertical
            pts = [(x0, y0), (x0 + t / 2, y0 + t / 2), (x0 + t / 2, y1 - t / 2),
                   (x0, y1), (x0 - t / 2, y1 - t / 2), (x0 - t / 2, y0 + t / 2)]
        k = self.SS
        d.polygon([((px + SEG_SLANT * (base_y - py)) * k, py * k) for px, py in pts],
                  fill=color)

    def draw_digit(self, d, ch, x, y, on, off):
        w, h, t = self.dw, self.dh, self.t
        g = t * 0.14                       # petit jour entre segments
        xl, xr = x + t / 2, x + w - t / 2  # axes verticaux
        yt, ym, yb = y + t / 2, y + h / 2, y + h - t / 2
        segs = {
            "a": (xl + g, yt, xr - g, yt),
            "g": (xl + g, ym, xr - g, ym),
            "d": (xl + g, yb, xr - g, yb),
            "f": (xl, yt + g, xl, ym - g),
            "b": (xr, yt + g, xr, ym - g),
            "e": (xl, ym + g, xl, yb - g),
            "c": (xr, ym + g, xr, yb - g),
        }
        lit = SEGMENTS.get(ch, "")
        for name, (x0, y0, x1, y1) in segs.items():
            color = on if name in lit else off
            if color is not None:
                self._seg(d, x0, y0, x1, y1, t, color, y + h)

    def draw_bell(self, d, cx, cy, color):
        """Petite cloche vectorielle centree en (cx, cy)."""
        s = self.SS
        cx, cy = cx * s, cy * s
        d.pieslice([cx - 11 * s, cy - 12 * s, cx + 11 * s, cy + 10 * s], 180, 360, fill=color)
        d.rectangle([cx - 11 * s, cy - 1 * s, cx + 11 * s, cy + 6 * s], fill=color)
        d.polygon([(cx - 11 * s, cy + 6 * s), (cx + 11 * s, cy + 6 * s),
                   (cx + 15 * s, cy + 10 * s), (cx - 15 * s, cy + 10 * s)], fill=color)
        d.ellipse([cx - 4 * s, cy + 10 * s, cx + 4 * s, cy + 16 * s], fill=color)
        d.ellipse([cx - 2 * s, cy - 16 * s, cx + 2 * s, cy - 11 * s], fill=color)

    def render(self, now, enabled, ringing, flash, night):
        if ringing and flash:
            bg, fg, bell = ALARM_BG, FG, FG
        elif night:
            bg, fg, bell = BG, NIGHT_FG, NIGHT_FG
        else:
            bg, fg, bell = BG, FG, ACCENT
        ghost = blend(bg, fg, GHOST_LEVEL) if GHOST_LEVEL > 0 else None

        img = Image.new("RGB", (self.LW * self.SS, self.LH * self.SS), bg)
        d = ImageDraw.Draw(img)

        hours = f"{now.hour:02d}" if LEADING_ZERO else f"{now.hour:2d}"
        minutes = f"{now.minute:02d}"
        colon_on = (not COLON_BLINK) or now.second % 2 == 0

        dw, cw, gap = self.dw, self.colon_w, self.GAP_DIGITS
        row_w = 4 * dw + 2 * gap + cw
        x = (self.LW - row_w - SEG_SLANT * self.dh) / 2
        y = (self.LH - self.dh) / 2
        base = y + self.dh

        self.draw_digit(d, hours[0], x, y, fg, ghost)
        self.draw_digit(d, hours[1], x + dw + gap, y, fg, ghost)
        # deux points (petits losanges inclines comme les segments)
        cx = x + 2 * dw + gap + cw / 2
        r = self.t * 0.8
        for cy in (y + self.dh * 0.32, y + self.dh * 0.68):
            pts = [(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)]
            col = fg if colon_on else ghost
            if col is not None:
                k = self.SS
                d.polygon([((px + SEG_SLANT * (base - py)) * k, py * k) for px, py in pts],
                          fill=col)
        self.draw_digit(d, minutes[0], x + 2 * dw + gap + cw, y, fg, ghost)
        self.draw_digit(d, minutes[1], x + 3 * dw + 2 * gap + cw, y, fg, ghost)

        # Cloche en haut a droite si le reveil est actif
        if enabled or ringing:
            self.draw_bell(d, self.LW - 30, 32, bell)

        img = img.resize((self.LW, self.LH), Image.LANCZOS)
        return img.rotate(ROTATION, expand=True)


# --------------------------------------------------------- application
class AlarmClock:
    def __init__(self):
        self.board = create_whisplay_hardware(
            app_id="whisplay-timer", display_name="Reveil", icon="R",
            exit_gesture="quad_click", priority=10,
        )
        for name in ("red_pwm", "green_pwm", "blue_pwm"):
            pwm = getattr(self.board, name, None)
            if pwm is not None:
                pwm.frequency = LED_PWM_HZ
        self._led_idle()                 # le driver demarre la LED allumee : on l'eteint
        self.brightness = None
        self.apply_brightness(datetime.now())
        self.renderer = Renderer()
        self.lock = threading.Lock()
        self.running = True

        self.enabled = load_state()
        self.ringing = False
        self.ring_started = 0.0
        self.last_trigger_date = None
        self.last_press = 0.0
        self.dirty = True

        self.player = None
        self.player_thread = None
        self.wav_path = os.path.join(tempfile.gettempdir(), "whisplay_alarm.wav")
        make_alarm_wav(self.wav_path)

        self.board.on_button_press(self.on_button)
        if hasattr(self.board, "on_exit_request"):
            self.board.on_exit_request(self.stop)
        if hasattr(self.board, "on_focus_revoked"):
            self.board.on_focus_revoked(lambda *_: self.stop())

    # -------- bouton
    def on_button(self):
        now = time.monotonic()
        if now - self.last_press < 0.35:        # anti-rebond
            return
        self.last_press = now
        with self.lock:
            if self.ringing:
                self._stop_ringing()
                print("Alarme arretee")
            else:
                self.enabled = not self.enabled
                save_state(self.enabled)
                print("Reveil", "active" if self.enabled else "desactive")
                self._led_idle()
            self.dirty = True

    def stop(self, *_):
        self.running = False

    # -------- alarme
    def _start_ringing(self):
        self.ringing = True
        self.ring_started = time.monotonic()
        self.player_thread = threading.Thread(target=self._sound_loop, daemon=True)
        self.player_thread.start()
        print("ALARME !")

    def _stop_ringing(self):
        self.ringing = False
        p = self.player
        if p and p.poll() is None:
            p.terminate()
        self._led_idle()

    def _sound_loop(self):
        while self.ringing and self.running:
            try:
                self.player = subprocess.Popen(
                    ["aplay", "-q", "-D", AUDIO_DEVICE, self.wav_path],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                self.player.wait()
            except FileNotFoundError:
                print("aplay introuvable (sudo apt install alsa-utils)")
                return

    def _led_idle(self):
        try:
            self.board.set_rgb(0, 0, 0)
        except Exception:
            pass

    def check_alarm(self, now):
        today = now.date()
        if (self.enabled and not self.ringing
                and now.hour == ALARM_HOUR and now.minute == ALARM_MINUTE
                and self.last_trigger_date != today):
            self.last_trigger_date = today
            self._start_ringing()
            self.dirty = True
        if self.ringing and time.monotonic() - self.ring_started > ALARM_MAX_DURATION_SEC:
            self._stop_ringing()
            self.dirty = True

    # -------- luminosite jour / nuit
    def apply_brightness(self, now):
        target = BRIGHTNESS_NIGHT if is_night(now) else BRIGHTNESS_DAY
        if target != self.brightness:
            self.board.set_backlight(target)
            pwm = getattr(self.board, "backlight_pwm", None)
            if pwm is not None:
                pwm.frequency = BACKLIGHT_PWM_HZ
            self.brightness = target
            print(f"Luminosite : {target}")

    # -------- boucle principale
    def run(self):
        last_key = None
        try:
            while self.running:
                now = datetime.now()
                self.apply_brightness(now)
                with self.lock:
                    self.check_alarm(now)
                    ringing, enabled = self.ringing, self.enabled
                    flash = ringing and int(time.monotonic() * 2) % 2 == 0
                    night = is_night(now)
                    tick = now.second % 2 if COLON_BLINK else 0
                    key = (now.strftime("%Y%m%d%H%M"), tick, enabled, ringing, flash, night)
                    redraw = self.dirty or key != last_key
                    self.dirty = False

                if ringing:
                    try:
                        self.board.set_rgb(255, 0, 0) if flash else self.board.set_rgb(0, 0, 0)
                    except Exception:
                        pass

                if redraw:
                    img = self.renderer.render(now, enabled, ringing, flash, night)
                    self.board.draw_image(0, 0, W, H, to_rgb565(img))
                    last_key = key

                time.sleep(0.25)
        except KeyboardInterrupt:
            pass
        finally:
            self.ringing = False
            p = self.player
            if p and p.poll() is None:
                p.terminate()
            try:
                self.board.set_rgb(0, 0, 0)
                self.board.fill_screen(0)
                self.board.set_backlight(0)
            except Exception:
                pass
            self.board.cleanup()


if __name__ == "__main__":
    try:
        os.nice(NICE_LEVEL)
    except Exception:
        pass
    AlarmClock().run()
