// display_ui.cpp — ST7796 + LVGL status screen, Phase 2a.
//
// Panel / bus configuration faithfully replicates the original Freenove
// Arduino firmware (ui_freenove_allinone/src/drivers/display/display_hal_lgfx.cpp
// FreenoveLgfxDevice, lines ~132-208):
//
//   - Panel:  Panel_ST7796
//   - Bus:    Bus_SPI, SPI2_HOST (FSPI; FREENOVE_LCD_USE_HSPI == 0)
//   - Freq:   80 MHz write / 20 MHz read
//   - spi_mode 0, spi_3wire false, use_lock false, dma_channel 0 (CPU mode)
//   - Pins:   SCK=47, MOSI=21, MISO=-1, DC=45, CS=-1, RST=20, BL=2
//   - Panel:  memory/panel 320×480, offset_x/y 0, offset_rotation 0,
//             dummy_read_pixel 8, dummy_read_bits 1, readable false (no MISO),
//             invert true (TFT_INVERSION_ON), rgb_order true (not BGR),
//             dlen_16bit false, bus_shared true
//   - Rotation: 1 (landscape)
//
// LVGL (Phase 2a):
//   LVGL 8.4 (same line as the original: platformio.ini:35 lvgl/lvgl@8.4.0),
//   configured via the managed component's Kconfig (sdkconfig: depth 16,
//   swap 0, mem 160 KB, Montserrat 24/28 — mirroring the original lv_conf.h).
//   Flush callback is the canonical LovyanGFX↔LVGL8 glue (startWrite /
//   setAddrWindow / writePixels rgb565 / endWrite), matching the effective
//   behaviour of the original ui_manager_display.cpp glue (its DMA path is
//   disabled at runtime in the original HAL, so the synchronous path is the
//   faithful one). Draw buffer: width × 40 lines, single, INTERNAL ram —
//   exactly like the original (buffer_cfg_.draw_in_psram = false, lines = 40).
//   Tick: esp_timer periodic 2 ms → lv_tick_inc(2) (the original calls
//   lv_tick_inc(elapsed) from its UI task; LV_TICK_CUSTOM is off both sides).
//
// BACKLIGHT (Phase 2a):
//   LEDC PWM on LEDC_TIMER_1 / LEDC_CHANNEL_1 (low-speed), ~44.1 kHz like the
//   original Light_PWM. LEDC_TIMER_0 / LEDC_CHANNEL_0 stay reserved for the
//   camera XCLK (qr_puzzle). display_ui_set_brightness() exposes duty 0-100%.
//
// Thread model:
//   ALL LVGL and LovyanGFX calls run exclusively on the display_task.
//   display_ui_set_status() copies the status under a mutex and sets a dirty
//   flag; the task folds it into LVGL labels within ~50 ms. lv_tick_inc is
//   called from the esp_timer task (atomic counter increment — the standard
//   espressif pattern).

#include "display_ui.h"
#include "board_pins_mediakit.h"

#include <string.h>
#include <stdio.h>
#include <math.h>
#include <dirent.h>
#include <sys/stat.h>

#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"

#define LGFX_USE_V1
#include <LovyanGFX.hpp>
#include "lvgl.h"

// Original UI fonts (fonts/ — copied from ui_freenove_allinone/src/ui/fonts/).
LV_FONT_DECLARE(lv_font_orbitron_40);
LV_FONT_DECLARE(lv_font_ibmplexmono_18);

static const char *TAG = "display_ui";

// ─── LovyanGFX device (faithful port of FreenoveLgfxDevice) ──────────────────

class ZacusDisplay final : public lgfx::LGFX_Device {
 public:
    ZacusDisplay() {
        // ── SPI bus ──────────────────────────────────────────────────────────
        {
            auto cfg = _bus_instance.config();
            cfg.spi_host    = SPI2_HOST;   // FSPI (FREENOVE_LCD_USE_HSPI == 0)
            cfg.spi_mode    = 0;
            cfg.freq_write  = 80000000;    // 80 MHz
            cfg.freq_read   = 20000000;    // 20 MHz
            cfg.spi_3wire   = false;
            cfg.use_lock    = false;
            // CPU mode — DMA out-link init can be unavailable on this
            // board/runtime mix (same note as original firmware, line ~145).
            cfg.dma_channel = 0;
            cfg.pin_sclk    = TFT_PIN_SCK;
            cfg.pin_mosi    = TFT_PIN_MOSI;
            cfg.pin_miso    = TFT_PIN_MISO;  // -1 (not wired)
            cfg.pin_dc      = TFT_PIN_DC;
            _bus_instance.config(cfg);
            _panel_instance.setBus(&_bus_instance);
        }

        // ── Panel ─────────────────────────────────────────────────────────────
        {
            auto cfg = _panel_instance.config();
            cfg.pin_cs           = -1;             // CS not wired
            cfg.pin_rst          = TFT_PIN_RST;
            cfg.pin_busy         = -1;
            cfg.memory_width     = 320;
            cfg.memory_height    = 480;
            cfg.panel_width      = 320;
            cfg.panel_height     = 480;
            cfg.offset_x         = 0;
            cfg.offset_y         = 0;
            cfg.offset_rotation  = 0;
            cfg.dummy_read_pixel = 8;
            cfg.dummy_read_bits  = 1;
            cfg.readable         = false;  // MISO == -1
            cfg.invert           = true;   // TFT_INVERSION_ON in original
            cfg.rgb_order        = true;   // not BGR
            cfg.dlen_16bit       = false;
            cfg.bus_shared       = true;
            _panel_instance.config(cfg);
        }

        // Backlight handled externally (LEDC PWM, see file header).

        setPanel(&_panel_instance);
    }

 private:
    lgfx::Bus_SPI       _bus_instance;
    lgfx::Panel_ST7796  _panel_instance;
};

static ZacusDisplay s_lcd;

// ─── Status state ─────────────────────────────────────────────────────────────

static display_status_t  s_status;
static SemaphoreHandle_t s_mutex   = NULL;
static volatile bool     s_dirty   = false;
static bool              s_running = false;

// ─── Palette (Amiga Workbench-ish) ────────────────────────────────────────────
static const uint32_t COL_BG        = 0x0055AA;  // Workbench blue
static const uint32_t COL_TITLEBAR  = 0x0000AA;  // darker blue
static const uint32_t COL_TITLE_TXT = 0xFFFFFF;  // white
static const uint32_t COL_LABEL     = 0xAAAAAA;  // mid grey
static const uint32_t COL_VALUE     = 0xFFFFFF;  // white
static const uint32_t COL_CODE      = 0xFF8800;  // Workbench orange

// ─── LVGL plumbing ────────────────────────────────────────────────────────────

#define DUI_HOR_RES   480           // rotation 1 (landscape)
#define DUI_VER_RES   320
#define DUI_BUF_LINES 40            // like the original (buffer_cfg_.lines)

static lv_disp_draw_buf_t s_draw_buf;
static lv_color_t        *s_buf1;
static lv_disp_drv_t      s_disp_drv;
static esp_timer_handle_t s_tick_timer;

// Canonical LovyanGFX↔LVGL8 glue (synchronous — matches the original's
// effective path; its DMA branch is disabled at runtime).
static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area,
                          lv_color_t *color_p) {
    const uint32_t w = (uint32_t) (area->x2 - area->x1 + 1);
    const uint32_t h = (uint32_t) (area->y2 - area->y1 + 1);
    s_lcd.startWrite();
    s_lcd.setAddrWindow(area->x1, area->y1, w, h);
    s_lcd.writePixels((lgfx::rgb565_t *) &color_p->full, w * h);
    s_lcd.endWrite();
    lv_disp_flush_ready(drv);
}

static void lvgl_tick_cb(void *arg) {
    (void) arg;
    lv_tick_inc(2);
}

// ─── LVGL status screen objects (display task only) ───────────────────────────

typedef struct {
    lv_obj_t *value;                // value label of a row
} dui_row_t;

static lv_obj_t *s_scr_status;      // status screen (no active step)
static lv_obj_t *s_lbl_title;
static dui_row_t s_row_ip, s_row_wake, s_row_step, s_row_armed, s_row_solved;
static lv_obj_t *s_lbl_code;

// Scene view (active step) — structure mirrors the original ui_manager scene
// frame: title (Orbitron), subtitle (IBM Plex Mono), symbol slot (the
// assembled code, pulse effect = SceneEffect::kPulse default).
static lv_obj_t *s_scr_scene;
static lv_obj_t *s_scene_title;     // step id, Orbitron 40, top-center
static lv_obj_t *s_scene_subtitle;  // armed puzzle, IBM Plex 18, bottom
static lv_obj_t *s_scene_code;      // assembled code, Orbitron 40, center

// Camera viewfinder (QR aiming aid). The qr_puzzle scan task pushes
// grayscale QVGA frames via display_ui_camera_frame(); the display task
// converts to RGB565 into the canvas buffer at ~5 fps.
#define CAM_W 320
#define CAM_H 240
static lv_obj_t  *s_scene_cam;          // lv_canvas, PSRAM-backed
static lv_color_t *s_cam_canvas_buf;    // CAM_W*CAM_H rgb565 (PSRAM)
static uint8_t   *s_cam_gray;           // incoming copy (PSRAM)
static SemaphoreHandle_t s_cam_mutex;
static volatile bool     s_cam_fresh = false;
static bool              s_cam_visible = false;

// ─── Amiga Workbench shell (port of ui_amiga_shell, same grid metrics) ───────
#define SHELL_GRID_COLS    4    // = AmigaUIShell::GRID_COLS
#define SHELL_ICON_SIZE    64   // = ICON_SIZE
#define SHELL_ICON_SPACING 16   // = ICON_SPACING
#define SHELL_GRID_X       16   // = GRID_START_X
#define SHELL_GRID_Y       48   // GRID_START_Y(32) + our taller title bar
#define SHELL_BUILTINS     5
#define SHELL_MAX_TILES    12
static lv_obj_t *s_scr_shell;
static lv_obj_t *s_shell_tiles[SHELL_MAX_TILES];
// Written by the buttons task, applied by the display task.
static volatile uint8_t s_shell_sel  = 0;
static volatile bool    s_shell_open = false;
// Accent colors from the original shell palette (ui_amiga_shell.cpp:280-287).
static const struct { const char *name; uint32_t color; }
kShellBuiltins[SHELL_BUILTINS] = {
    {"Statut",   0x00FFFF},   // cyan
    {"Scene",    0xFF8800},   // orange
    {"Auto",     0x00FF88},   // green
    {"Lumiere",  0xFFFF00},   // yellow
    {"Fichiers", 0x88FF00},   // lime — opens the file browser
};
// Dynamic apps from /littlefs/apps/<id>/ (icon.png optional, step.txt = the
// step to arm on launch — handled by main via the pending-launch poll).
typedef struct { char id[32]; } shell_dyn_app_t;
static shell_dyn_app_t s_shell_dyn[SHELL_MAX_TILES - SHELL_BUILTINS];
static uint8_t s_shell_count = SHELL_BUILTINS;   // builtins + dynamic
static bool    s_shell_loaded = false;           // lazy (littlefs mounts late)

// Pending app launch (buttons task → main poll).
static char          s_pending_launch[32];
static volatile bool s_pending_launch_set = false;

// ─── File browser (original shell's drawer, master edition) ─────────────────
#define BROWSER_MAX_ENTRIES 24
static lv_obj_t *s_scr_browser;
static lv_obj_t *s_browser_list;     // plain container, rebuilt on reload
static lv_obj_t *s_browser_title;
static char     s_browser_path[96] = "/littlefs";
static char     s_browser_items[BROWSER_MAX_ENTRIES][40];
static bool     s_browser_isdir[BROWSER_MAX_ENTRIES];
static uint8_t  s_browser_count = 0;
static volatile uint8_t s_browser_sel = 0;
static volatile bool s_browser_open = false;
static volatile bool s_browser_reload = false;

// ─── Intro — faithful port of the original cracktro ──────────────────────────
// Phase A from ui_manager_intro.cpp: 3-layer parallax starfield in Q8.8
// fixed point (layer split 50/30/20%, speeds {54,116,198} px/s, sizes 1-3 px,
// opacities 30/60/100%, 1-in-8 twinkle), logo + drop shadow (offset 1,23),
// bottom sine-wave scrolltext, copper bars. Phases B/C/D are the original 3D
// FX (rotozoom, dot sphere, ray corridor) ported in intro_fx3d.cpp, rendered
// into a full-screen LVGL canvas behind the logo + scroller. If the canvas
// PSRAM allocation fails, the intro gracefully stays on phase A throughout.
#include "intro_fx3d.h"
#define INTRO_STARS     48
#define INTRO_BARS      4
#define INTRO_PHASE_A_MS  7000
#define INTRO_FX_MS       3000
#define INTRO_DURATION_MS (INTRO_PHASE_A_MS + 3 * INTRO_FX_MS)
typedef struct { int32_t x_q8, y_q8; uint8_t layer; int16_t speed; } intro_star_t;
static lv_obj_t *s_scr_intro;
static lv_obj_t *s_intro_logo, *s_intro_logo_shadow;
static lv_obj_t *s_intro_scroll;
static lv_obj_t *s_star_objs[INTRO_STARS];
static intro_star_t s_stars[INTRO_STARS];
static lv_obj_t *s_intro_bars[INTRO_BARS];
static int32_t  s_scroll_x_q8;          // scrolltext x in Q8.8
static uint32_t s_intro_t_ms;           // elapsed intro time
static volatile bool s_intro_done = false;
static lv_obj_t  *s_fx_canvas;          // phases B/C/D render target
static lv_color_t *s_fx_canvas_buf;     // 480x320 RGB565, PSRAM
static bool s_fx_tried, s_fx_ok;        // lazy one-shot setup at phase B
static fx3d_mode_t s_fx_order[3];       // 3 distinct FX drawn per boot
// Original kIntro defaults: scroll 90 px/s, sine amp 14 px, period 120 px.
#define INTRO_SCROLL_PXS  90
#define INTRO_SINE_AMP    14
#define INTRO_SINE_PERIOD 120.0f

static lv_obj_t *make_row(lv_obj_t *parent, int y, const char *label_txt,
                          dui_row_t *out) {
    lv_obj_t *lbl = lv_label_create(parent);
    lv_obj_set_style_text_color(lbl, lv_color_hex(COL_LABEL), 0);
    lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
    lv_label_set_text(lbl, label_txt);
    lv_obj_set_pos(lbl, 8, y);

    lv_obj_t *val = lv_label_create(parent);
    lv_obj_set_style_text_color(val, lv_color_hex(COL_VALUE), 0);
    lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0);
    lv_label_set_text(val, "...");
    lv_obj_set_pos(val, 160, y);
    lv_label_set_long_mode(val, LV_LABEL_LONG_DOT);
    lv_obj_set_width(val, DUI_HOR_RES - 168);

    out->value = val;
    return val;
}

// ─── Scene effects (ports of the original ui_manager SceneEffect set) ────────

// Pulse — SceneEffect::kPulse equivalent (opacity breathe on the symbol/code).
static void pulse_anim_cb(void *obj, int32_t v) {
    lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) v, 0);
}

// Glitch — jitter + flicker on the title (text-artifact evocation of the
// original glitch overlay; the full pixel-shred shader is a later phase).
static lv_timer_t *s_glitch_timer;
static void glitch_timer_cb(lv_timer_t *t) {
    (void) t;
    const int dx = (int) (esp_random() % 7) - 3;
    lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, dx, 24);
    lv_obj_set_style_opa(s_scene_title,
                         (esp_random() % 8) ? LV_OPA_COVER : LV_OPA_60, 0);
}

// Gyrophare — rotating beacon ring around the scene.
static lv_obj_t *s_gyro_arc;
static void gyro_anim_cb(void *obj, int32_t v) {
    lv_arc_set_rotation((lv_obj_t *) obj, (uint16_t) v);
}

static uint8_t s_fx_applied = 0xFF;   // force first apply

static void start_pulse(void) {
    lv_anim_t a;
    lv_anim_init(&a);
    lv_anim_set_var(&a, s_scene_code);
    lv_anim_set_exec_cb(&a, pulse_anim_cb);
    lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
    lv_anim_set_time(&a, 600);
    lv_anim_set_playback_time(&a, 600);
    lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
    lv_anim_start(&a);
}

static void apply_effect(uint8_t fx) {
    if (fx == s_fx_applied) return;
    s_fx_applied = fx;

    // Stop everything, restore neutral state.
    lv_anim_del(s_scene_code, pulse_anim_cb);
    lv_obj_set_style_opa(s_scene_code, LV_OPA_COVER, 0);
    if (s_glitch_timer) lv_timer_pause(s_glitch_timer);
    lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
    lv_obj_set_style_opa(s_scene_title, LV_OPA_COVER, 0);
    if (s_gyro_arc) {
        lv_anim_del(s_gyro_arc, gyro_anim_cb);
        lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
    }

    switch (fx) {
    case 0:  // pulse (default)
        start_pulse();
        break;
    case 1:  // glitch
        if (s_glitch_timer) lv_timer_resume(s_glitch_timer);
        break;
    case 2:  // gyro
        if (s_gyro_arc) {
            lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
            lv_anim_t a;
            lv_anim_init(&a);
            lv_anim_set_var(&a, s_gyro_arc);
            lv_anim_set_exec_cb(&a, gyro_anim_cb);
            lv_anim_set_values(&a, 0, 360);
            lv_anim_set_time(&a, 1200);
            lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
            lv_anim_start(&a);
        }
        break;
    default: // none
        break;
    }
}

static void build_scene_screen(void) {
    s_scr_scene = lv_obj_create(NULL);
    lv_obj_set_style_bg_color(s_scr_scene, lv_color_hex(0x000000), 0);
    lv_obj_set_style_bg_opa(s_scr_scene, LV_OPA_COVER, 0);

    // Viewfinder canvas (hidden unless the QR puzzle is armed).
    s_cam_canvas_buf = (lv_color_t *) heap_caps_malloc(
        (size_t) CAM_W * CAM_H * sizeof(lv_color_t), MALLOC_CAP_SPIRAM);
    s_cam_gray = (uint8_t *) heap_caps_malloc(
        (size_t) CAM_W * CAM_H, MALLOC_CAP_SPIRAM);
    if (s_cam_canvas_buf && s_cam_gray) {
        s_scene_cam = lv_canvas_create(s_scr_scene);
        lv_canvas_set_buffer(s_scene_cam, s_cam_canvas_buf,
                             CAM_W, CAM_H, LV_IMG_CF_TRUE_COLOR);
        lv_canvas_fill_bg(s_scene_cam, lv_color_hex(0x101010), LV_OPA_COVER);
        lv_obj_align(s_scene_cam, LV_ALIGN_CENTER, 0, 8);
        lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
    } else {
        ESP_LOGW(TAG, "viewfinder buffers alloc failed — preview disabled");
    }

    s_scene_title = lv_label_create(s_scr_scene);
    lv_obj_set_style_text_color(s_scene_title, lv_color_hex(COL_VALUE), 0);
    lv_obj_set_style_text_font(s_scene_title, &lv_font_orbitron_40, 0);
    lv_label_set_text(s_scene_title, "");
    lv_label_set_long_mode(s_scene_title, LV_LABEL_LONG_SCROLL_CIRCULAR);
    lv_obj_set_width(s_scene_title, DUI_HOR_RES - 24);
    lv_obj_set_style_text_align(s_scene_title, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);

    s_scene_code = lv_label_create(s_scr_scene);
    lv_obj_set_style_text_color(s_scene_code, lv_color_hex(COL_CODE), 0);
    lv_obj_set_style_text_font(s_scene_code, &lv_font_orbitron_40, 0);
    lv_label_set_text(s_scene_code, "");
    lv_obj_align(s_scene_code, LV_ALIGN_CENTER, 0, 16);

    s_scene_subtitle = lv_label_create(s_scr_scene);
    lv_obj_set_style_text_color(s_scene_subtitle, lv_color_hex(COL_LABEL), 0);
    lv_obj_set_style_text_font(s_scene_subtitle, &lv_font_ibmplexmono_18, 0);
    lv_label_set_text(s_scene_subtitle, "");
    lv_obj_align(s_scene_subtitle, LV_ALIGN_BOTTOM_LEFT, 8, -10);

    // Effect plumbing: glitch timer (paused until selected) + gyro ring
    // (hidden until selected). Pulse starts via apply_effect on first status.
    s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
    lv_timer_pause(s_glitch_timer);

    s_gyro_arc = lv_arc_create(s_scr_scene);
    lv_obj_set_size(s_gyro_arc, 300, 300);
    lv_obj_center(s_gyro_arc);
    lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
    lv_arc_set_angles(s_gyro_arc, 0, 60);          // 60° beacon segment
    lv_obj_remove_style(s_gyro_arc, NULL, LV_PART_KNOB);
    lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
    lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(0xFF3300),
                               LV_PART_INDICATOR);
    lv_obj_set_style_arc_width(s_gyro_arc, 8, LV_PART_INDICATOR);
    lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
}

static void build_shell_screen(void) {
    s_scr_shell = lv_obj_create(NULL);
    lv_obj_set_style_bg_color(s_scr_shell, lv_color_hex(COL_BG), 0);
    lv_obj_set_style_bg_opa(s_scr_shell, LV_OPA_COVER, 0);

    lv_obj_t *bar = lv_obj_create(s_scr_shell);
    lv_obj_remove_style_all(bar);
    lv_obj_set_size(bar, DUI_HOR_RES, 36);
    lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
    lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
    lv_obj_t *t = lv_label_create(bar);
    lv_obj_set_style_text_color(t, lv_color_hex(COL_TITLE_TXT), 0);
    lv_obj_set_style_text_font(t, &lv_font_montserrat_24, 0);
    lv_label_set_text(t, "Zacus Workbench");
    lv_obj_align(t, LV_ALIGN_LEFT_MID, 8, 0);
    // Tiles are created lazily by shell_populate() — littlefs mounts after
    // display_ui_init, so dynamic apps can't be scanned here.
}

static lv_obj_t *shell_make_tile(int index, const char *name, uint32_t color,
                                 const char *icon_path) {
    const int col = index % SHELL_GRID_COLS;
    const int row = index / SHELL_GRID_COLS;
    lv_obj_t *tile = lv_obj_create(s_scr_shell);
    lv_obj_remove_style_all(tile);
    lv_obj_set_size(tile, SHELL_ICON_SIZE, SHELL_ICON_SIZE);
    lv_obj_set_pos(tile,
                   SHELL_GRID_X + col * (SHELL_ICON_SIZE + SHELL_ICON_SPACING),
                   SHELL_GRID_Y + row * (SHELL_ICON_SIZE + SHELL_ICON_SPACING));
    lv_obj_set_style_bg_color(tile, lv_color_hex(color), 0);
    lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(tile, 4, 0);              // like the original
    lv_obj_set_style_border_color(tile, lv_color_white(), 0);
    lv_obj_set_style_border_width(tile, 1, 0);

    if (icon_path) {
        // PNG icon via LVGL FS drive 'L' (→ /littlefs), like the original's
        // "L:" + icon.png path.
        lv_obj_t *img = lv_img_create(tile);
        lv_img_set_src(img, icon_path);
        lv_obj_align(img, LV_ALIGN_TOP_MID, 0, 2);
    }

    lv_obj_t *lbl = lv_label_create(tile);
    lv_obj_set_style_text_color(lbl, lv_color_hex(0x000000), 0);
    lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
    lv_label_set_text(lbl, name);
    lv_obj_align(lbl, LV_ALIGN_BOTTOM_MID, 0, -4);
    return tile;
}

// Lazy tile creation: builtins + /littlefs/apps/<id>/ (display task only).
static void shell_populate(void) {
    if (s_shell_loaded) return;
    s_shell_loaded = true;

    for (int i = 0; i < SHELL_BUILTINS; i++) {
        s_shell_tiles[i] = shell_make_tile(i, kShellBuiltins[i].name,
                                           kShellBuiltins[i].color, NULL);
    }
    s_shell_count = SHELL_BUILTINS;

    DIR *dir = opendir("/littlefs/apps");
    if (!dir) return;                       // no apps dir — builtins only
    struct dirent *ent;
    while ((ent = readdir(dir)) != NULL &&
           s_shell_count < SHELL_MAX_TILES) {
        if (ent->d_type != DT_DIR || ent->d_name[0] == '.') continue;
        shell_dyn_app_t *app = &s_shell_dyn[s_shell_count - SHELL_BUILTINS];
        strncpy(app->id, ent->d_name, sizeof(app->id) - 1);
        app->id[sizeof(app->id) - 1] = '\0';

        // Optional PNG icon (LVGL path with the L: drive).
        char fs_path[96], lv_path[96];
        snprintf(fs_path, sizeof(fs_path), "/littlefs/apps/%s/icon.png", app->id);
        struct stat st;
        const bool has_icon = (stat(fs_path, &st) == 0);
        snprintf(lv_path, sizeof(lv_path), "L:apps/%s/icon.png", app->id);

        s_shell_tiles[s_shell_count] = shell_make_tile(
            s_shell_count, app->id, 0x335577,
            has_icon ? lv_path : NULL);
        s_shell_count++;
    }
    closedir(dir);
    ESP_LOGI(TAG, "shell: %d tiles (%d apps from /littlefs/apps)",
             s_shell_count, s_shell_count - SHELL_BUILTINS);
}

// Highlight the selected tile (display task only).
static void apply_shell_selection(void) {
    for (int i = 0; i < s_shell_count; i++) {
        if (s_shell_tiles[i])
            lv_obj_set_style_border_width(s_shell_tiles[i],
                                          (i == s_shell_sel) ? 4 : 1, 0);
    }
}

// ─── File browser (display task only for LVGL parts) ────────────────────────

static void build_browser_screen(void) {
    s_scr_browser = lv_obj_create(NULL);
    lv_obj_set_style_bg_color(s_scr_browser, lv_color_hex(0x000000), 0);
    lv_obj_set_style_bg_opa(s_scr_browser, LV_OPA_COVER, 0);

    lv_obj_t *bar = lv_obj_create(s_scr_browser);
    lv_obj_remove_style_all(bar);
    lv_obj_set_size(bar, DUI_HOR_RES, 36);
    lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
    lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
    s_browser_title = lv_label_create(bar);
    lv_obj_set_style_text_color(s_browser_title, lv_color_hex(COL_TITLE_TXT), 0);
    lv_obj_set_style_text_font(s_browser_title, &lv_font_ibmplexmono_18, 0);
    lv_label_set_text(s_browser_title, s_browser_path);
    lv_obj_align(s_browser_title, LV_ALIGN_LEFT_MID, 8, 0);

    s_browser_list = lv_obj_create(s_scr_browser);
    lv_obj_remove_style_all(s_browser_list);
    lv_obj_set_size(s_browser_list, DUI_HOR_RES, DUI_VER_RES - 36);
    lv_obj_set_pos(s_browser_list, 0, 36);
}

static void browser_reload(void) {
    s_browser_count = 0;
    DIR *dir = opendir(s_browser_path);
    if (dir) {
        struct dirent *ent;
        while ((ent = readdir(dir)) != NULL &&
               s_browser_count < BROWSER_MAX_ENTRIES) {
            strncpy(s_browser_items[s_browser_count], ent->d_name,
                    sizeof(s_browser_items[0]) - 1);
            s_browser_items[s_browser_count][sizeof(s_browser_items[0]) - 1] = '\0';
            s_browser_isdir[s_browser_count] = (ent->d_type == DT_DIR);
            s_browser_count++;
        }
        closedir(dir);
    }

    lv_obj_clean(s_browser_list);
    lv_label_set_text(s_browser_title, s_browser_path);
    for (int i = 0; i < s_browser_count; i++) {
        lv_obj_t *row = lv_label_create(s_browser_list);
        lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0);
        lv_obj_set_pos(row, 12, 6 + i * 22);
        char line[56];
        snprintf(line, sizeof(line), "%s%s",
                 s_browser_isdir[i] ? "[D] " : "    ", s_browser_items[i]);
        lv_label_set_text(row, line);
        lv_obj_set_style_text_color(row,
            (i == s_browser_sel) ? lv_color_hex(COL_CODE) : lv_color_white(), 0);
    }
    if (s_browser_count == 0) {
        lv_obj_t *row = lv_label_create(s_browser_list);
        lv_obj_set_style_text_font(row, &lv_font_ibmplexmono_18, 0);
        lv_obj_set_pos(row, 12, 6);
        lv_obj_set_style_text_color(row, lv_color_hex(COL_LABEL), 0);
        lv_label_set_text(row, "(vide)");
    }
}

static void build_intro_screen(void) {
    s_scr_intro = lv_obj_create(NULL);
    lv_obj_set_style_bg_color(s_scr_intro, lv_color_hex(0x000000), 0);
    lv_obj_set_style_bg_opa(s_scr_intro, LV_OPA_COVER, 0);

    // Starfield — original layer split: 50% layer0, 30% layer1, 20% layer2.
    static const int16_t kSpeeds[3] = {54, 116, 198};   // speeds_fast
    const int l0_end = (INTRO_STARS * 50) / 100;
    const int l1_end = (INTRO_STARS * 80) / 100;
    for (int i = 0; i < INTRO_STARS; i++) {
        const uint8_t layer = (i < l0_end) ? 0 : (i < l1_end) ? 1 : 2;
        intro_star_t *st = &s_stars[i];
        st->layer = layer;
        st->speed = kSpeeds[layer];
        st->x_q8  = (int32_t) (esp_random() % DUI_HOR_RES) << 8;
        st->y_q8  = (int32_t) (esp_random() % DUI_VER_RES) << 8;

        lv_obj_t *star = lv_obj_create(s_scr_intro);
        lv_obj_remove_style_all(star);
        lv_obj_set_size(star, 1 + layer, 1 + layer);
        lv_obj_set_style_radius(star, LV_RADIUS_CIRCLE, 0);
        lv_obj_set_style_bg_color(star,
            (layer == 2) ? lv_color_hex(0x00FFFF) : lv_color_white(), 0);
        lv_obj_set_style_bg_opa(star,
            (layer == 0) ? LV_OPA_30 : (layer == 1) ? LV_OPA_60 : LV_OPA_COVER, 0);
        lv_obj_set_pos(star, st->x_q8 >> 8, st->y_q8 >> 8);
        s_star_objs[i] = star;
    }

    // Copper bars (4 horizontal gradient bars bobbing on sines).
    static const uint32_t kBarCols[INTRO_BARS] =
        {0xFF0088, 0x0088FF, 0x00FF88, 0xFFFF00};
    for (int i = 0; i < INTRO_BARS; i++) {
        lv_obj_t *bar = lv_obj_create(s_scr_intro);
        lv_obj_remove_style_all(bar);
        lv_obj_set_size(bar, DUI_HOR_RES, 6);
        lv_obj_set_style_bg_color(bar, lv_color_hex(kBarCols[i]), 0);
        lv_obj_set_style_bg_opa(bar, LV_OPA_70, 0);
        lv_obj_set_pos(bar, 0, 60 + i * 14);
        s_intro_bars[i] = bar;
    }

    // Logo + drop shadow (original offsets: shadow at TOP_MID +1,+23;
    // logo at TOP_MID 0,22 — shadow drawn first, behind).
    s_intro_logo_shadow = lv_label_create(s_scr_intro);
    lv_obj_set_style_text_color(s_intro_logo_shadow, lv_color_hex(0x222222), 0);
    lv_obj_set_style_text_font(s_intro_logo_shadow, &lv_font_orbitron_40, 0);
    lv_label_set_text(s_intro_logo_shadow, "Professeur ZACUS");
    lv_obj_align(s_intro_logo_shadow, LV_ALIGN_TOP_MID, 1, 23);

    s_intro_logo = lv_label_create(s_scr_intro);
    lv_obj_set_style_text_color(s_intro_logo, lv_color_hex(COL_CODE), 0);
    lv_obj_set_style_text_font(s_intro_logo, &lv_font_orbitron_40, 0);
    lv_label_set_text(s_intro_logo, "Professeur ZACUS");
    lv_obj_align(s_intro_logo, LV_ALIGN_TOP_MID, 0, 22);

    // Bottom sine scroller.
    s_intro_scroll = lv_label_create(s_scr_intro);
    lv_obj_set_style_text_color(s_intro_scroll, lv_color_hex(0x00FFFF), 0);
    lv_obj_set_style_text_font(s_intro_scroll, &lv_font_ibmplexmono_18, 0);
    lv_label_set_text(s_intro_scroll,
        "*** ZACUS MASTER CRACKTRO *** LE MYSTERE CONTINUE ... "
        "GREETINGS TO THE GAME MASTER ... ");
    s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8;
    lv_obj_set_pos(s_intro_scroll, DUI_HOR_RES, DUI_VER_RES - 60);
}

// Phase B entry: allocate the FX module + a full-screen canvas behind the
// logo/scroller, hide the phase-A starfield + bars. One-shot; on failure the
// intro simply keeps the starfield (s_fx_ok stays false). Display task only.
static bool intro_fx_setup(void) {
    if (!fx3d_init()) return false;
    // Fisher-Yates over the 6-FX pool, keep the first 3: each boot shows a
    // different random selection/order without lengthening the intro.
    fx3d_mode_t pool[FX3D_MODE_COUNT];
    for (int i = 0; i < FX3D_MODE_COUNT; i++) pool[i] = (fx3d_mode_t) i;
    for (int i = FX3D_MODE_COUNT - 1; i > 0; i--) {
        const int j = (int) (esp_random() % (uint32_t) (i + 1));
        const fx3d_mode_t tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
    }
    for (int i = 0; i < 3; i++) s_fx_order[i] = pool[i];
    ESP_LOGI(TAG, "intro fx: order=%d,%d,%d (0=roto 1=sphere 2=corridor "
             "3=star3d 4=voxel 5=cube)",
             (int) s_fx_order[0], (int) s_fx_order[1], (int) s_fx_order[2]);
    s_fx_canvas_buf = (lv_color_t *) heap_caps_malloc(
        (size_t) DUI_HOR_RES * DUI_VER_RES * sizeof(lv_color_t),
        MALLOC_CAP_SPIRAM);
    if (!s_fx_canvas_buf) return false;
    s_fx_canvas = lv_canvas_create(s_scr_intro);
    lv_canvas_set_buffer(s_fx_canvas, s_fx_canvas_buf,
                         DUI_HOR_RES, DUI_VER_RES, LV_IMG_CF_TRUE_COLOR);
    lv_obj_set_pos(s_fx_canvas, 0, 0);
    lv_obj_move_background(s_fx_canvas);
    for (int i = 0; i < INTRO_STARS; i++)
        lv_obj_add_flag(s_star_objs[i], LV_OBJ_FLAG_HIDDEN);
    for (int i = 0; i < INTRO_BARS; i++)
        lv_obj_add_flag(s_intro_bars[i], LV_OBJ_FLAG_HIDDEN);
    return true;
}

// Per-tick intro animation (display task only; dt in ms). Faithful Q8.8 math.
static void update_intro(uint32_t dt_ms) {
    s_intro_t_ms += dt_ms;

    // Phase sequencing: A starfield, then B/C/D = rotozoom, dot sphere,
    // ray corridor (original fx_mode order) on the FX canvas.
    if (s_intro_t_ms >= INTRO_PHASE_A_MS && !s_fx_tried) {
        s_fx_tried = true;
        s_fx_ok = intro_fx_setup();
    }
    if (s_fx_ok && s_intro_t_ms >= INTRO_PHASE_A_MS) {
        int fx = (int) ((s_intro_t_ms - INTRO_PHASE_A_MS) / INTRO_FX_MS);
        if (fx > 2) fx = 2;
        // Render every other tick (20 ms): the SPI flush of a full-screen
        // canvas dominates anyway, no point racing it.
        static bool skip;
        skip = !skip;
        if (!skip) {
            fx3d_render(s_fx_order[fx], s_intro_t_ms,
                        (uint16_t *) s_fx_canvas_buf, DUI_HOR_RES, DUI_VER_RES);
            lv_obj_invalidate(s_fx_canvas);
        }
        goto scroller;   // stars/bars hidden — skip their updates
    }

    // Starfield: fall + wrap + twinkle (original update loop).
    for (int i = 0; i < INTRO_STARS; i++) {
        intro_star_t *st = &s_stars[i];
        st->y_q8 += (int32_t) (((uint32_t) st->speed * dt_ms * 256u) / 1000u);
        if (st->y_q8 > ((int32_t) (DUI_VER_RES + 4) << 8)) {
            st->y_q8 = -(int32_t) ((esp_random() % 36u) << 8);
            st->x_q8 = (int32_t) (esp_random() % DUI_HOR_RES) << 8;
        }
        if ((esp_random() & 0x7u) == 0u) {
            lv_obj_set_style_bg_opa(s_star_objs[i],
                (lv_opa_t) (96u + (esp_random() % 160u)), 0);
        }
        lv_obj_set_pos(s_star_objs[i], st->x_q8 >> 8, st->y_q8 >> 8);
    }

    // Copper bars: vertical sine bob, phase-shifted per bar.
    for (int i = 0; i < INTRO_BARS; i++) {
        const float ph = (float) s_intro_t_ms * 0.004f + (float) i * 0.9f;
        lv_obj_set_y(s_intro_bars[i], 60 + i * 14 + (int) (sinf(ph) * 18.0f));
    }

scroller:
    // Sine scroller: leftward at INTRO_SCROLL_PXS, y rides a sine of the
    // x position (original sine_amp/sine_period defaults).
    s_scroll_x_q8 -= (int32_t) ((INTRO_SCROLL_PXS * dt_ms * 256u) / 1000u);
    const int sx = s_scroll_x_q8 >> 8;
    const int w  = lv_obj_get_width(s_intro_scroll);
    if (sx < -w) s_scroll_x_q8 = (int32_t) DUI_HOR_RES << 8;
    const int sy = DUI_VER_RES - 60 +
                   (int) (sinf((float) sx / INTRO_SINE_PERIOD * 6.2832f) *
                          INTRO_SINE_AMP);
    lv_obj_set_pos(s_intro_scroll, sx, sy);
}

static void build_status_screen(void) {
    lv_obj_t *scr = s_scr_status;
    lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);

    // Title bar
    lv_obj_t *bar = lv_obj_create(scr);
    lv_obj_remove_style_all(bar);
    lv_obj_set_size(bar, DUI_HOR_RES, 36);
    lv_obj_set_pos(bar, 0, 0);
    lv_obj_set_style_bg_color(bar, lv_color_hex(COL_TITLEBAR), 0);
    lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);

    s_lbl_title = lv_label_create(bar);
    lv_obj_set_style_text_color(s_lbl_title, lv_color_hex(COL_TITLE_TXT), 0);
    lv_obj_set_style_text_font(s_lbl_title, &lv_font_montserrat_24, 0);
    lv_label_set_text(s_lbl_title, "ZACUS MASTER");
    lv_obj_align(s_lbl_title, LV_ALIGN_LEFT_MID, 8, 0);

    // Rows
    int y = 52;
    const int LINE_H = 30;
    make_row(scr, y, "IP",       &s_row_ip);     y += LINE_H;
    make_row(scr, y, "Wake",     &s_row_wake);   y += LINE_H;
    make_row(scr, y, "Etape",    &s_row_step);   y += LINE_H;
    make_row(scr, y, "Enigme",   &s_row_armed);  y += LINE_H;
    make_row(scr, y, "Resolues", &s_row_solved); y += LINE_H;

    // Assembled code — big, orange
    s_lbl_code = lv_label_create(scr);
    lv_obj_set_style_text_color(s_lbl_code, lv_color_hex(COL_CODE), 0);
    lv_obj_set_style_text_font(s_lbl_code, &lv_font_montserrat_28, 0);
    lv_label_set_text(s_lbl_code, "Code: ---");
    lv_obj_set_pos(s_lbl_code, 8, y + 12);
}

static void apply_status(const display_status_t *s) {
    // Status screen rows
    lv_label_set_text(s_row_ip.value,   s->ip[0] ? s->ip : "(aucune)");
    lv_label_set_text(s_row_wake.value, s->wake_active ? "oui" : "non");
    lv_label_set_text(s_row_step.value, s->step_id[0] ? s->step_id : "(aucune)");
    lv_label_set_text(s_row_armed.value, s->armed[0] ? s->armed : "none");

    char tmp[48];
    snprintf(tmp, sizeof(tmp), "%u", (unsigned) s->solved_count);
    lv_label_set_text(s_row_solved.value, tmp);

    char code[32];
    snprintf(code, sizeof(code), "Code: %s", s->code[0] ? s->code : "---");
    lv_label_set_text(s_lbl_code, code);

    // Scene screen — scene metadata wins, with sensible fallbacks.
    lv_label_set_text(s_scene_title,
                      s->scene_title[0] ? s->scene_title : s->step_id);
    if (s->scene_subtitle[0]) {
        lv_label_set_text(s_scene_subtitle, s->scene_subtitle);
    } else {
        if (s->armed[0] && strcmp(s->armed, "none") != 0) {
            snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed);
        } else {
            snprintf(tmp, sizeof(tmp), "resolues: %u",
                     (unsigned) s->solved_count);
        }
        lv_label_set_text(s_scene_subtitle, tmp);
    }
    // Center slot: scenario symbol when provided, else the assembled code.
    lv_label_set_text(s_scene_code,
                      s->scene_symbol[0] ? s->scene_symbol
                                         : (s->code[0] ? s->code : ""));
    apply_effect(s->scene_effect);

    // Viewfinder: visible only while the QR puzzle is armed. When visible,
    // the centered code label yields its slot to the canvas (code moves into
    // the subtitle line).
    s_cam_visible = (s_scene_cam != NULL) && (strcmp(s->armed, "qr") == 0);
    if (s_scene_cam) {
        if (s_cam_visible) {
            lv_obj_clear_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
            lv_obj_add_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
            if (s->code[0]) {
                snprintf(tmp, sizeof(tmp), "enigme: qr  code: %s", s->code);
                lv_label_set_text(s_scene_subtitle, tmp);
            }
        } else {
            lv_obj_add_flag(s_scene_cam, LV_OBJ_FLAG_HIDDEN);
            lv_obj_clear_flag(s_scene_code, LV_OBJ_FLAG_HIDDEN);
        }
    }

    // Active step → scene view; idle → status view. The 5-way buttons can
    // override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
    // original ui_manager default transition (SceneTransition::kFade, 240).
    extern volatile uint8_t g_dui_view_override;
    lv_obj_t *want;
    if (!s_intro_done) {
        want = s_scr_intro;            // boot intro plays out first
    } else if (s_browser_open) {
        want = s_scr_browser;          // file browser on top of everything
    } else if (s_shell_open) {
        want = s_scr_shell;            // Workbench shell on top
    } else switch (g_dui_view_override) {
    case 1:  want = s_scr_status; break;
    case 2:  want = s_scr_scene;  break;
    default: want = s->step_id[0] ? s_scr_scene : s_scr_status; break;
    }
    if (lv_scr_act() != want) {
        lv_scr_load_anim(want, LV_SCR_LOAD_ANIM_FADE_ON, 240, 0, false);
    }
    extern volatile bool g_dui_on_scene;
    g_dui_on_scene = (want == s_scr_scene);
}

// View override shared with display_ui_handle_key (byte writes are atomic).
volatile uint8_t g_dui_view_override = 0;
// Mirror of the active view, written ONLY by the display task — lets
// display_ui_handle_key decide without calling LVGL from the buttons task.
volatile bool g_dui_on_scene = false;

// Convert the freshest grayscale frame into the canvas (display task only).
static void refresh_viewfinder(void) {
    if (!s_cam_visible || !s_cam_fresh || !s_cam_mutex) return;

    if (xSemaphoreTake(s_cam_mutex, pdMS_TO_TICKS(10)) != pdTRUE) return;
    // Convert in place from the shared gray buffer into the canvas buffer.
    const uint8_t *g = s_cam_gray;
    uint16_t *dst = (uint16_t *) s_cam_canvas_buf;
    for (size_t i = 0; i < (size_t) CAM_W * CAM_H; i++) {
        const uint8_t v = g[i];
        dst[i] = (uint16_t) (((v >> 3) << 11) | ((v >> 2) << 5) | (v >> 3));
    }
    s_cam_fresh = false;
    xSemaphoreGive(s_cam_mutex);

    lv_obj_invalidate(s_scene_cam);
}

// ─── Raw splash (pre-LVGL, visible during the rest of boot) ───────────────────

static void render_splash(void) {
    const int W = s_lcd.width();
    const int H = s_lcd.height();

    s_lcd.fillScreen(COL_BG);
    s_lcd.fillRect(0, 0, W, 36, COL_TITLEBAR);
    s_lcd.setTextColor(COL_TITLE_TXT);
    s_lcd.setFont(&fonts::Font4);
    s_lcd.setCursor(8, 8);
    s_lcd.print("ZACUS MASTER");

    s_lcd.setFont(&fonts::Font2);
    s_lcd.setTextColor(COL_VALUE);
    s_lcd.setCursor(8, 50);
    s_lcd.print("Demarrage...");

    s_lcd.setTextColor(COL_LABEL);
    s_lcd.setCursor(8, H - 20);
    s_lcd.print("FNK0102H  ESP-IDF 5.4  Phase 2a");
}

// ─── Backlight (LEDC PWM — free pair; camera XCLK owns TIMER_0/CHANNEL_0) ─────

#define DUI_BL_TIMER   LEDC_TIMER_1
#define DUI_BL_CHANNEL LEDC_CHANNEL_1
#define DUI_BL_FREQ_HZ 44100          // like the original Light_PWM
#define DUI_BL_RES     LEDC_TIMER_8_BIT

static esp_err_t backlight_init(void) {
    ledc_timer_config_t tcfg = {};
    tcfg.speed_mode      = LEDC_LOW_SPEED_MODE;
    tcfg.timer_num       = DUI_BL_TIMER;
    tcfg.duty_resolution = DUI_BL_RES;
    tcfg.freq_hz         = DUI_BL_FREQ_HZ;
    tcfg.clk_cfg         = LEDC_AUTO_CLK;
    esp_err_t err = ledc_timer_config(&tcfg);
    if (err != ESP_OK) return err;

    ledc_channel_config_t ccfg = {};
    ccfg.gpio_num   = TFT_PIN_BL;
    ccfg.speed_mode = LEDC_LOW_SPEED_MODE;
    ccfg.channel    = DUI_BL_CHANNEL;
    ccfg.timer_sel  = DUI_BL_TIMER;
    ccfg.duty       = 255;            // full on by default
    ccfg.hpoint     = 0;
    return ledc_channel_config(&ccfg);
}

extern "C" void display_ui_set_brightness(uint8_t pct) {
    if (pct > 100) pct = 100;
    const uint32_t duty = (255u * pct) / 100u;
    ledc_set_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL, duty);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, DUI_BL_CHANNEL);
}

// ─── Display task ─────────────────────────────────────────────────────────────

static void display_task(void *arg) {
    (void) arg;

    // LVGL lives entirely on this task.
    s_scr_status = lv_scr_act();
    build_status_screen();
    build_scene_screen();
    build_shell_screen();
    build_browser_screen();
    build_intro_screen();

    // Boot intro: shown immediately, hands over after INTRO_DURATION_MS
    // (any 5-way key skips it — see display_ui_handle_key).
    lv_scr_load(s_scr_intro);
    TickType_t intro_until = xTaskGetTickCount() +
                             pdMS_TO_TICKS(INTRO_DURATION_MS);

    display_status_t local_status;
    memset(&local_status, 0, sizeof(local_status));

    TickType_t last_cam = 0;
    uint8_t shell_sel_applied  = 0xFF;
    bool    shell_open_applied = false;

    for (;;) {
        // Intro: animate while active, hand over at the deadline. dt is the
        // REAL elapsed time: the loop period is vTaskDelay(10) + LVGL render
        // (up to ~30 ms on a full-canvas frame), so a fixed dt=10 would make
        // the intro clock lag wall time and starve the FX phases.
        if (!s_intro_done) {
            static TickType_t intro_prev_ticks;
            const TickType_t now_ticks = xTaskGetTickCount();
            if (intro_prev_ticks == 0) intro_prev_ticks = now_ticks;
            uint32_t dt = (uint32_t) pdTICKS_TO_MS(now_ticks - intro_prev_ticks);
            intro_prev_ticks = now_ticks;
            if (dt > 100) dt = 100;
            update_intro(dt);
            if (now_ticks >= intro_until) {
                s_intro_done = true;
                s_dirty = true;   // re-evaluate the wanted screen
            }
        }

        // Shell state (selection highlight / open-close) from the buttons task.
        if (s_shell_sel != shell_sel_applied ||
            s_shell_open != shell_open_applied) {
            shell_sel_applied  = s_shell_sel;
            shell_open_applied = s_shell_open;
            if (s_shell_open) shell_populate();   // lazy: littlefs mounts late
            apply_shell_selection();
            s_dirty = true;
        }

        // Browser reload requested (path change / open / selection move).
        if (s_browser_reload) {
            s_browser_reload = false;
            browser_reload();
            s_dirty = true;
        }

        if (s_dirty &&
            xSemaphoreTake(s_mutex, pdMS_TO_TICKS(20)) == pdTRUE) {
            memcpy(&local_status, &s_status, sizeof(local_status));
            s_dirty = false;
            xSemaphoreGive(s_mutex);
            apply_status(&local_status);
        }

        // Viewfinder refresh, throttled to ~5 fps (full-frame gray→rgb565
        // conversion is ~77k pixels — keep the UI task responsive).
        const TickType_t now = xTaskGetTickCount();
        if ((now - last_cam) >= pdMS_TO_TICKS(200)) {
            refresh_viewfinder();
            last_cam = now;
        }

        lv_timer_handler();
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// ─── Public API ──────────────────────────────────────────────────────────────

extern "C" esp_err_t display_ui_init(void) {
    // Backlight PWM up first (panel garbage stays dark otherwise).
    esp_err_t err = backlight_init();
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "backlight LEDC init: %s — falling back to plain GPIO",
                 esp_err_to_name(err));
        gpio_config_t bl_cfg = {};
        bl_cfg.pin_bit_mask = (1ULL << TFT_PIN_BL);
        bl_cfg.mode         = GPIO_MODE_OUTPUT;
        gpio_config(&bl_cfg);
        gpio_set_level((gpio_num_t) TFT_PIN_BL, 1);
    }

    // Initialise panel
    if (!s_lcd.init()) {
        ESP_LOGE(TAG, "LovyanGFX panel init failed");
        return ESP_FAIL;
    }
    s_lcd.setRotation(1);   // landscape, matches FREENOVE_LCD_ROTATION 1

    ESP_LOGI(TAG, "ST7796 panel up (%dx%d)",
             (int) s_lcd.width(), (int) s_lcd.height());

    render_splash();

    // ── LVGL ──────────────────────────────────────────────────────────────
    lv_init();

    // Draw buffer: width × 40 lines, single. DEVIATION from the original
    // (draw_in_psram = false): on this firmware the camera DMA needs a 30 KB
    // contiguous INTERNAL block (cam_hal), and a 38.4 KB internal draw buffer
    // starved it (largest free block dropped to 28 KB → esp_camera_init
    // ESP_FAIL). PSRAM costs some blit speed in CPU mode but keeps internal
    // RAM for camera/voice DMA. The original Arduino UI never ran esp-sr +
    // WS streaming + camera concurrently, so it could afford internal.
    const size_t buf_px = (size_t) DUI_HOR_RES * DUI_BUF_LINES;
    s_buf1 = (lv_color_t *) heap_caps_malloc(buf_px * sizeof(lv_color_t),
                                             MALLOC_CAP_SPIRAM);
    if (!s_buf1) {
        ESP_LOGE(TAG, "LVGL draw buffer alloc failed (%u B)",
                 (unsigned) (buf_px * sizeof(lv_color_t)));
        return ESP_ERR_NO_MEM;
    }
    lv_disp_draw_buf_init(&s_draw_buf, s_buf1, NULL, buf_px);

    lv_disp_drv_init(&s_disp_drv);
    s_disp_drv.hor_res  = DUI_HOR_RES;
    s_disp_drv.ver_res  = DUI_VER_RES;
    s_disp_drv.flush_cb = lvgl_flush_cb;
    s_disp_drv.draw_buf = &s_draw_buf;
    if (!lv_disp_drv_register(&s_disp_drv)) {
        ESP_LOGE(TAG, "lv_disp_drv_register failed");
        return ESP_FAIL;
    }

    // Tick: 2 ms periodic (LV_TICK_CUSTOM off, like the original).
    const esp_timer_create_args_t targs = {
        .callback        = lvgl_tick_cb,
        .arg             = NULL,
        .dispatch_method = ESP_TIMER_TASK,
        .name            = "lv_tick",
        .skip_unhandled_events = true,
    };
    err = esp_timer_create(&targs, &s_tick_timer);
    if (err == ESP_OK) err = esp_timer_start_periodic(s_tick_timer, 2000);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "LVGL tick timer: %s", esp_err_to_name(err));
        return err;
    }

    ESP_LOGI(TAG, "LVGL %d.%d up (buf %u lines, PSRAM)",
             lv_version_major(), lv_version_minor(), (unsigned) DUI_BUF_LINES);

    // State
    s_mutex     = xSemaphoreCreateMutex();
    s_cam_mutex = xSemaphoreCreateMutex();
    if (!s_mutex || !s_cam_mutex) {
        ESP_LOGE(TAG, "failed to create mutex");
        return ESP_ERR_NO_MEM;
    }
    memset(&s_status, 0, sizeof(s_status));
    s_dirty   = false;
    s_running = true;

    // Spawn refresh task (ALL LVGL + panel access lives here).
    BaseType_t rc = xTaskCreate(display_task, "display_ui", 8192, NULL, 3, NULL);
    if (rc != pdPASS) {
        ESP_LOGE(TAG, "xTaskCreate failed");
        return ESP_ERR_NO_MEM;
    }

    ESP_LOGI(TAG, "display_ui_init OK — LVGL status screen active");
    return ESP_OK;
}

extern "C" void display_ui_set_status(const display_status_t *s) {
    if (!s || !s_running || !s_mutex) {
        return;
    }
    if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
        memcpy(&s_status, s, sizeof(s_status));
        s_dirty = true;
        xSemaphoreGive(s_mutex);
    }
}

extern "C" void display_ui_handle_key(uint8_t key) {
    static uint8_t s_brightness = 100;

    // Any key skips the boot intro.
    if (!s_intro_done) {
        s_intro_done = true;
        s_dirty = true;
        return;
    }

    // ── Browser open: list navigation (SELECT enters dirs, MENU goes up
    //    then closes at the littlefs root).
    if (s_browser_open) {
        switch (key) {
        case 2:   // DOWN
            if (s_browser_sel + 1 < s_browser_count) s_browser_sel++;
            s_browser_reload = true;
            break;
        case 5:   // UP
            if (s_browser_sel > 0) s_browser_sel--;
            s_browser_reload = true;
            break;
        case 1: { // SELECT — enter directory
            const uint8_t sel = s_browser_sel;
            if (sel < s_browser_count && s_browser_isdir[sel]) {
                const size_t len = strlen(s_browser_path);
                snprintf(s_browser_path + len, sizeof(s_browser_path) - len,
                         "/%s", s_browser_items[sel]);
                s_browser_sel = 0;
                s_browser_reload = true;
            }
            break;
        }
        case 3: { // MENU — up one level, close at root
            char *slash = strrchr(s_browser_path, '/');
            if (slash && slash != s_browser_path &&
                strcmp(s_browser_path, "/littlefs") != 0) {
                *slash = '\0';
                s_browser_sel = 0;
                s_browser_reload = true;
            } else {
                s_browser_open = false;
                s_shell_open = true;     // back to the shell
            }
            break;
        }
        default:
            break;
        }
        s_dirty = true;
        return;
    }

    // ── Shell open: navigate the Workbench grid (original key semantics:
    //    1=SELECT launch, 2=DOWN row, 3=MENU close, 4=LEFT/RIGHT, 5=UP row).
    if (s_shell_open) {
        switch (key) {
        case 1: {  // SELECT — launch tile
            const uint8_t sel = s_shell_sel;
            s_shell_open = false;
            switch (sel) {
            case 0: g_dui_view_override = 1; break;   // Statut
            case 1: g_dui_view_override = 2; break;   // Scene
            case 2: g_dui_view_override = 0; break;   // Auto
            case 3:                                    // Lumiere — cycle
                s_brightness = (s_brightness <= 20) ? 100
                              : (uint8_t)(s_brightness - 40);
                display_ui_set_brightness(s_brightness);
                s_shell_open = true;   // stay in the shell for feedback
                break;
            case 4:                                    // Fichiers — browser
                strncpy(s_browser_path, "/littlefs", sizeof(s_browser_path));
                s_browser_sel = 0;
                s_browser_open = true;
                s_browser_reload = true;
                break;
            default:                                   // dynamic app
                if (sel < s_shell_count && !s_pending_launch_set) {
                    strncpy(s_pending_launch,
                            s_shell_dyn[sel - SHELL_BUILTINS].id,
                            sizeof(s_pending_launch) - 1);
                    s_pending_launch[sizeof(s_pending_launch) - 1] = '\0';
                    s_pending_launch_set = true;
                }
                break;
            }
            break;
        }
        case 2:   // DOWN — next row
            if (s_shell_sel + SHELL_GRID_COLS < s_shell_count)
                s_shell_sel = (uint8_t)(s_shell_sel + SHELL_GRID_COLS);
            break;
        case 5:   // UP — previous row
            if (s_shell_sel >= SHELL_GRID_COLS)
                s_shell_sel = (uint8_t)(s_shell_sel - SHELL_GRID_COLS);
            break;
        case 4:   // LEFT/RIGHT — next column (wraps)
            s_shell_sel = (uint8_t)((s_shell_sel + 1) % s_shell_count);
            break;
        case 3:   // MENU — close shell
            s_shell_open = false;
            break;
        default:
            break;
        }
        s_dirty = true;
        return;
    }

    // ── Shell closed: view shortcuts + brightness.
    switch (key) {
    case 1:   // SELECT — toggle scene↔status manually
    case 4: { // LEFT/RIGHT — same toggle
        // g_dui_on_scene is the display task's mirror — no LVGL call here.
        g_dui_view_override = g_dui_on_scene ? 1 : 2;
        s_dirty = true;   // wake the display task decision
        break;
    }
    case 3:   // MENU — open the Workbench shell
        s_shell_open = true;
        s_dirty = true;
        break;
    case 5:   // UP — brightness +
        s_brightness = (s_brightness >= 80) ? 100 : s_brightness + 20;
        display_ui_set_brightness(s_brightness);
        break;
    case 2:   // DOWN — brightness −
        s_brightness = (s_brightness <= 20) ? 10 : s_brightness - 20;
        display_ui_set_brightness(s_brightness);
        break;
    default:
        break;
    }
}

extern "C" bool display_ui_take_pending_launch(char *id_out, size_t cap) {
    if (!s_pending_launch_set || !id_out || cap == 0) return false;
    strncpy(id_out, s_pending_launch, cap - 1);
    id_out[cap - 1] = '\0';
    s_pending_launch_set = false;
    return true;
}

extern "C" void display_ui_camera_frame(const uint8_t *gray, int width, int height) {
    // Runs in the qr_puzzle scan task — copy fast, never block scanning.
    if (!gray || width != CAM_W || height != CAM_H) return;
    if (!s_running || !s_cam_mutex || !s_cam_gray || !s_cam_visible) return;
    if (xSemaphoreTake(s_cam_mutex, 0) != pdTRUE) return;  // skip frame if busy
    memcpy(s_cam_gray, gray, (size_t) CAM_W * CAM_H);
    s_cam_fresh = true;
    xSemaphoreGive(s_cam_mutex);
}
