CleanEX
#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 400
#| viewerWidth: "100%"

suppressPackageStartupMessages(library(shiny))
suppressPackageStartupMessages(library(bslib))

# ── Colonne escluse di default ────────────────────────────────────────────────
DEFAULT_DROP <- c("longitude", "latitude", "altitude", "qual_sat", "etal")

COL_RENAME <- c(
  "jj/mm/aaaa" = "date",
  "hh:mm:ss"   = "time",
  "groupe"     = "group",
  "mesure"     = "measure"
)

PARAM_COLS <- c("Chl", "Flav", "Anth", "NBI")

# ── Dizionario UI bilingue ────────────────────────────────────────────────────
TR <- list(
  it = list(
    app_subtitle    = "Pulizia e visualizzazione dati da",
    app_descr       = "Sensore ottico portatile per la misura non distruttiva di Chl, Flav, Anth e NBI su foglie intatte — Metos Global.",
    privacy_title    = "Sicurezza dei Dati & Privacy al 100%",
    privacy_body     = "Questa applicazione viene eseguita interamente all'interno del tuo browser tramite tecnologia WebAssembly. I tuoi dati non lasciano mai il tuo computer e nessun file viene inviato a server esterni. È utilizzabile anche offline. Nota: il primo avvio potrebbe essere necessario qualche secondo per il caricamento dell'ambiente R.",
    status_waiting  = "In attesa",
    status_loaded   = "✓ File caricato",
    status_clean    = "✓ File pulito",
    status_trt      = "✓ Trattamenti aggiunti",
    status_final    = "✓ File finale pronto",
    status_error    = "✗ Errore",
    step1           = "1 — Carica il file",
    file_placeholder= "Seleziona un file CSV dal Dualex…",
    file_btn        = "Sfoglia",
    file_info       = function(nr, nc) paste0(nr, " misure · ", nc, " colonne rilevate"),
    step2           = "2 — Seleziona le colonne da mantenere",
    sel_all         = "Seleziona tutte",
    desel_all       = "Deseleziona tutte",
    col_meta        = "colonne selezionate",
    step3           = "3 — Genera il file pulito",
    btn_clean       = "Pulisci",
    btn_dl_clean    = "Scarica CSV pulito",
    summary         = "Riepilogo",
    renamed_lbl     = "Colonne rinominate automaticamente:",
    stat_measures   = "misure",
    stat_cols       = "colonne",
    stat_groups     = "gruppi",
    stat_file       = "nome file",
    preview8        = "Anteprima dati",
    step4           = "4 — Assegna i trattamenti ai gruppi",
    trt_hint        = "Inserisci il nome del trattamento per ciascun gruppo. Se lasciato vuoto, viene usato il numero del gruppo.",
    trt_placeholder = "Trattamento per gruppo ",
    btn_trt         = "Aggiungi trattamento",
    trt_preview     = "Anteprima con trattamenti",
    btn_dl_trt      = "Scarica CSV con trattamenti",
    step5           = "5 — Formato finale e download",
    pivot_label     = "Trasponi parametri in formato long",
    pivot_sub       = "Le colonne dei parametri verranno unite in una colonna 'parameter' con i valori in 'value'",
    btn_final       = "Genera file finale",
    btn_dl_final    = "Scarica CSV finale",
    stat_rows       = "righe",
    stat_format     = "formato",
    final_preview   = "Anteprima file finale",
    step6           = "6 — Grafico esplorativo",
    chart_param_lbl = "Seleziona i parametri da visualizzare:",
    chart_hint      = "Media ± errore standard (SE) per ciascun trattamento. Solo a scopo di controllo qualità rapido — non sostituisce l'analisi statistica.",
    btn_chart       = "Genera grafico",
    btn_dl_png      = "Scarica PNG",
    chart_no_params = "Nessun parametro numerico disponibile nel file finale.",
    chart_ylab      = "Valore medio",
    jitter_label    = "Mostra punti individuali (jitter)",
    jitter_sub      = "Visualizza ogni singola misura come puntino, utile per individuare outlier o valutare la distribuzione",
    step7           = "7 — Unisci con un file precedente (opzionale)",
    merge_hint      = "Carica un CSV precedentemente esportato da CleanEX nello stesso formato (wide o long). Le colonne devono corrispondere esattamente. I dati verranno impilati in ordine cronologico.",
    merge_file_ph   = "Seleziona un CSV CleanEX precedente…",
    merge_file_btn  = "Sfoglia",
    btn_merge       = "Unisci i file",
    btn_dl_merge    = "Scarica CSV combinato",
    merge_ok        = function(n1, n2) paste0("✓ Unione completata: ", n1, " + ", n2, " righe = ", n1+n2, " righe totali"),
    merge_err_cols  = "Errore: le colonne del file caricato non corrispondono al file corrente. Verifica che entrambi i file siano nello stesso formato (wide o long) e abbiano gli stessi parametri.",
    merge_preview_curr = "Anteprima file corrente (prime 5 righe):",
    merge_preview_prev = "Anteprima file precedente (prime 5 righe):",
    credits         = list(
      pre  = "Applicazione creata da ",
      bug  = " · Segnala problemi su ",
      or   = " o via ",
      mail = "mail"
    )
  ),
  en = list(
    app_subtitle    = "Data cleaning and visualisation from",
    app_descr       = "Handheld optical sensor for non-destructive measurement of Chl, Flav, Anth and NBI on intact leaves — Metos Global.",
    privacy_title    = "100% Data Security & Privacy",
    privacy_body     = "This application runs entirely within your browser using WebAssembly technology. Your data never leaves your computer, and no files are sent to external servers. It can also be used offline. Note: the initial launch may take a few seconds to load the R environment.",
    status_waiting  = "Waiting",
    status_loaded   = "✓ File loaded",
    status_clean    = "✓ File cleaned",
    status_trt      = "✓ Treatments added",
    status_final    = "✓ Final file ready",
    status_error    = "✗ Error",
    step1           = "1 — Load file",
    file_placeholder= "Select a CSV file from the Dualex…",
    file_btn        = "Browse",
    file_info       = function(nr, nc) paste0(nr, " measurements · ", nc, " columns detected"),
    step2           = "2 — Select columns to keep",
    sel_all         = "Select all",
    desel_all       = "Deselect all",
    col_meta        = "columns selected",
    step3           = "3 — Generate cleaned file",
    btn_clean       = "Clean",
    btn_dl_clean    = "Download cleaned CSV",
    summary         = "Summary",
    renamed_lbl     = "Automatically renamed columns:",
    stat_measures   = "measurements",
    stat_cols       = "columns",
    stat_groups     = "groups",
    stat_file       = "file name",
    preview8        = "Data preview",
    step4           = "4 — Assign treatments to groups",
    trt_hint        = "Enter the treatment name for each group. If left blank, the group number will be used.",
    trt_placeholder = "Treatment for group ",
    btn_trt         = "Add treatment",
    trt_preview     = "Preview with treatments",
    btn_dl_trt      = "Download CSV with treatments",
    step5           = "5 — Final format and download",
    pivot_label     = "Transpose parameters to long format",
    pivot_sub       = "Parameter columns will be merged into a 'parameter' column with values in 'value'",
    btn_final       = "Generate final file",
    btn_dl_final    = "Download final CSV",
    stat_rows       = "rows",
    stat_format     = "format",
    final_preview   = "Final file preview",
    step6           = "6 — Exploratory chart",
    chart_param_lbl = "Select parameters to display:",
    chart_hint      = "Mean ± standard error (SE) for each treatment. For quick quality control only — not a substitute for proper statistical analysis.",
    btn_chart       = "Generate chart",
    btn_dl_png      = "Download PNG",
    chart_no_params = "No numeric parameters available in the final file.",
    chart_ylab      = "Mean value",
    jitter_label    = "Show individual data points (jitter)",
    jitter_sub      = "Overlay each measurement as a dot, useful to spot outliers or assess distribution",
    step7           = "7 — Merge with a previous file (optional)",
    merge_hint      = "Load a CSV previously exported from CleanEX in the same format (wide or long). Columns must match exactly. Data will be stacked in chronological order.",
    merge_file_ph   = "Select a previous CleanEX CSV…",
    merge_file_btn  = "Browse",
    btn_merge       = "Merge files",
    btn_dl_merge    = "Download combined CSV",
    merge_ok        = function(n1, n2) paste0("✓ Merge complete: ", n1, " + ", n2, " rows = ", n1+n2, " total rows"),
    merge_err_cols  = "Error: columns in the uploaded file do not match the current file. Make sure both files are in the same format (wide or long) and have the same parameters.",
    merge_preview_curr = "Current file preview (first 5 rows):",
    merge_preview_prev = "Previous file preview (first 5 rows):",
    credits         = list(
      pre  = "Application developed by ",
      bug  = " · Report issues on ",
      or   = " or via ",
      mail = "email"
    )
  )
)

# ── Parsing Dualex ────────────────────────────────────────────────────────────
parse_dx_raw <- function(filepath) {
  raw_bytes <- readBin(filepath, what = "raw", n = file.info(filepath)$size)
  raw_bytes <- raw_bytes[raw_bytes != as.raw(0x00)]
  raw_lines <- trimws(strsplit(rawToChar(raw_bytes), "\n")[[1]])

  header_fields <- trimws(strsplit(sub("^#", "", raw_lines[5]), ";")[[1]])
  data_lines    <- raw_lines[6:length(raw_lines)]
  skip_next     <- 0L
  keep_lines    <- character(0)

  for (line in data_lines) {
    if (line == "")                  { if (skip_next > 0L) skip_next <- skip_next - 1L; next }
    if (startsWith(line, "#Groupe")) { skip_next <- 3L; next }
    if (startsWith(line, "#"))       { if (skip_next > 0L) skip_next <- skip_next - 1L; next }
    if (skip_next > 0L)              { skip_next <- skip_next - 1L; next }
    keep_lines <- c(keep_lines, line)
  }

  n_cols     <- length(header_fields)
  split_rows <- lapply(strsplit(keep_lines, ";"), function(x) {
    length(x) <- n_cols; x[is.na(x)] <- ""; x
  })
  df           <- as.data.frame(do.call(rbind, split_rows), stringsAsFactors = FALSE)
  colnames(df) <- header_fields
  df
}

apply_rename <- function(df) {
  for (old in names(COL_RENAME)) {
    idx <- which(tolower(colnames(df)) == tolower(old))
    if (length(idx)) colnames(df)[idx[1]] <- COL_RENAME[[old]]
  }
  df
}

pivot_long <- function(df) {
  id_cols    <- c("date", "time", "temp", "group", "treatment", "measure", "face")
  id_present <- id_cols[id_cols %in% colnames(df)]
  param_cols <- setdiff(colnames(df), id_present)
  if (!length(param_cols)) return(df)
  id_data   <- df[, id_present, drop = FALSE]
  long_rows <- do.call(rbind, lapply(param_cols, function(p) {
    tmp <- id_data; tmp$parameter <- p; tmp$value <- df[[p]]; tmp
  }))
  long_rows <- long_rows[order(rep(seq_len(nrow(df)), length(param_cols))), ]
  rownames(long_rows) <- NULL
  long_rows
}

# ── Calcolo media + SE per trattamento ────────────────────────────────────────
compute_mean_se <- function(df, param, trt_col = "treatment") {
  vals <- suppressWarnings(as.numeric(df[[param]]))
  trt  <- df[[trt_col]]
  ok   <- !is.na(vals) & !is.na(trt)
  vals <- vals[ok]; trt <- trt[ok]
  if (!length(vals)) return(NULL)

  agg_mean <- tapply(vals, trt, mean, na.rm = TRUE)
  agg_se   <- tapply(vals, trt, function(x) {
    n <- sum(!is.na(x))
    if (n < 2) return(0)
    sd(x, na.rm = TRUE) / sqrt(n)
  })
  data.frame(
    treatment = names(agg_mean),
    mean      = as.numeric(agg_mean),
    se        = as.numeric(agg_se[names(agg_mean)]),
    stringsAsFactors = FALSE
  )
}

# ── Encoder base64 (pura base R, nessuna dipendenza esterna) ─────────────────
base64_encode_raw <- function(raw_bytes) {
  alphabet <- strsplit("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", "")[[1]]
  n   <- length(raw_bytes)
  pad <- (3 - n %% 3) %% 3
  if (pad > 0) raw_bytes <- c(raw_bytes, raw(pad))
  m <- matrix(as.integer(raw_bytes), nrow = 3)
  idx <- rbind(
    bitwShiftR(m[1, ], 2),
    bitwOr(bitwShiftL(bitwAnd(m[1, ], 3L), 4), bitwShiftR(m[2, ], 4)),
    bitwOr(bitwShiftL(bitwAnd(m[2, ], 15L), 2), bitwShiftR(m[3, ], 6)),
    bitwAnd(m[3, ], 63L)
  )
  out <- alphabet[idx + 1]
  out <- matrix(out, nrow = 4)
  if (pad >= 1) out[4, ncol(out)] <- "="
  if (pad == 2) out[3, ncol(out)] <- "="
  paste(out, collapse = "")
}

# ── Estrae l'unità di misura dal nome colonna, es. "Length(cm)" → "cm" ───────
extract_unit <- function(param_name) {
  m <- regmatches(param_name, regexpr("\\(([^)]+)\\)\\s*$", param_name))
  if (length(m) == 0 || m == "") return(NULL)
  sub("^\\((.+)\\)$", "\\1", m)
}

# ── Rendering grafico a barre con error bar, stile theme_bw, palette ggsci npg ─
draw_bar_chart <- function(df, params, trt_col = "treatment", ylab = "Mean value",
                            is_dark = FALSE, show_jitter = FALSE) {
  pal_npg_base <- c("#E64B35", "#4DBBD5", "#00A087", "#3C5488", "#F39B7F",
                     "#8491B4", "#91D1C2", "#DC0000", "#7E6148", "#B09C85")
  pal      <- adjustcolor(pal_npg_base, alpha.f = 0.8)
  fg       <- "#1A1A1A"
  major_gr <- "#E5E5E5"
  minor_gr <- "#F0F0F0"
  panel_bg <- "#FFFFFF"
  plot_bg  <- "#FFFFFF"

  all_treatments <- sort(unique(as.character(df[[trt_col]])))
  trt_colors <- setNames(pal[((seq_along(all_treatments) - 1) %% length(pal)) + 1],
                          all_treatments)

  par(bg = plot_bg, col.axis = fg, col.lab = fg, col.main = fg, fg = fg,
      font.main = 1, cex.main = 1.4, cex.lab = 1.3, cex.axis = 1.2)

  n_params <- length(params)
  if (n_params > 1) {
    n_col <- 2
    n_row <- ceiling(n_params / n_col)
    par(mfrow = c(n_row, n_col), mar = c(7, 6.5, 3.2, 1.4), mgp = c(4, 0.9, 0))
  } else {
    par(mfrow = c(1, 1), mar = c(7, 6.5, 3.2, 1.4), mgp = c(4, 0.9, 0))
  }

  for (i in seq_along(params)) {
    p   <- params[i]
    agg <- compute_mean_se(df, p, trt_col)
    if (is.null(agg) || !nrow(agg)) next

    bar_cols <- trt_colors[agg$treatment]
    n_bars   <- nrow(agg)

    raw_vals <- suppressWarnings(as.numeric(df[[p]]))
    raw_trt  <- as.character(df[[trt_col]])
    ok_pts   <- !is.na(raw_vals) & !is.na(raw_trt)
    pts_vals <- raw_vals[ok_pts]
    pts_trt  <- raw_trt[ok_pts]

    ylim_max <- max(agg$mean + agg$se, na.rm = TRUE) * 1.18
    if (show_jitter && length(pts_vals)) {
      ylim_max <- max(ylim_max, max(pts_vals, na.rm = TRUE) * 1.1)
    }

    plot.new()
    plot.window(xlim = c(0.2, n_bars * 1.2 + 0.2), ylim = c(0, ylim_max))
    rect(par("usr")[1], par("usr")[3], par("usr")[2], par("usr")[4],
         col = panel_bg, border = NA)

    y_major <- pretty(c(0, ylim_max))
    y_major <- y_major[y_major >= 0 & y_major <= ylim_max]
    y_minor <- (head(y_major, -1) + tail(y_major, -1)) / 2
    abline(h = y_minor, col = minor_gr, lwd = 0.7)
    abline(h = y_major, col = major_gr, lwd = 1)

    bp <- barplot(agg$mean, col = bar_cols, border = "#1A1A1A", lwd = 0.9,
                  ylim = c(0, ylim_max), add = TRUE,
                  ylab = "", main = "", names.arg = rep("", n_bars),
                  axes = FALSE)

    if (show_jitter && length(pts_vals)) {
      bar_x <- setNames(as.numeric(bp), agg$treatment)
      jx <- bar_x[pts_trt] + runif(length(pts_trt), -0.13, 0.13)
      points(jx, pts_vals, pch = 21, bg = adjustcolor("#1A1A1A", alpha.f = 0.45),
             col = adjustcolor("#1A1A1A", alpha.f = 0.6), cex = 0.85, lwd = 0.6)
    }

    arrows(x0 = bp, y0 = agg$mean, x1 = bp, y1 = agg$mean + agg$se,
           angle = 90, length = 0.08, col = fg, lwd = 1.6)
    arrows(x0 = bp, y0 = agg$mean, x1 = bp, y1 = pmax(agg$mean - agg$se, 0),
           angle = 90, length = 0.08, col = fg, lwd = 1.6)

    unit      <- extract_unit(p)
    ylab_full <- if (!is.null(unit)) paste0(ylab, " [", unit, "]") else ylab

    usr <- par("usr")
    y_off <- (usr[4] - usr[3]) * 0.03
    text(x = bp, y = usr[3] - y_off, labels = agg$treatment,
         srt = 30, adj = c(1, 1), xpd = TRUE, cex = 1.2, col = fg)

    axis(2, at = y_major, col = fg, col.axis = fg, cex.axis = 1.2, las = 1, mgp = c(4, 0.9, 0))
    box(col = fg, lwd = 1)
    title(ylab = ylab_full, cex.lab = 1.3, col.lab = fg)
    title(main = p, cex.main = 1.4, col.main = fg, font.main = 2)
  }
  par(mfrow = c(1, 1))
  invisible(trt_colors)
}

# ── Serializza un vettore R in array JS di stringhe ───────────────────────────
jsonlite_like_array <- function(x) {
  esc <- gsub('"', '\\\\"', x)
  paste0("[", paste0('"', esc, '"', collapse = ","), "]")
}

# ── Parametri numerici disponibili per il grafico (wide o long) ──────────────
get_available_chart_params <- function(df) {
  excl <- c("date", "time", "temp", "group", "treatment", "measure", "face")
  if ("parameter" %in% colnames(df) && "value" %in% colnames(df)) {
    sort(unique(df$parameter))
  } else {
    cand <- setdiff(colnames(df), excl)
    cand[vapply(cand, function(c) {
      !all(is.na(suppressWarnings(as.numeric(df[[c]]))))
    }, logical(1))]
  }
}

df_to_csv_string <- function(df, sep = ",") {
  con <- textConnection("out", "w")
  write.table(df, con, sep = sep, row.names = FALSE, quote = FALSE, na = "")
  close(con)
  paste(out, collapse = "\n")
}

# ── UI ───────────────────────────────────────────────────────────────────────
ui <- page_fluid(
  title = "CleanEX 🧹",
  theme = bs_theme(
    version       = 5,
    base_font     = "system-ui",
    heading_font  = "system-ui",
    bg            = "#F7F8FA",
    fg            = "#1A1D23",
    primary       = "#2D6A4F",
    secondary     = "#B7D4C6",
    border_radius = "6px"
  ),

  tags$head(tags$style(HTML("
    html, body { padding: 1rem 1.5rem !important; margin: 0 !important; background: #F7F8FA !important; }
    @media (prefers-color-scheme: dark) {
      html, body { background: #141A13 !important; color: #D8E8D5 !important; }
    }
    h1.title { display: none !important; }
    .dx-card {
      background: #fff; border: 1px solid #E2E6EA; border-radius: 10px;
      padding: 1.8rem 2.2rem; margin-bottom: 1.4rem;
      box-shadow: 0 1px 4px rgba(0,0,0,.06);
    }
    .privacy-banner {
      background: #F0F9FF; border: 1px solid #BAE6FD; border-radius: 10px;
      padding: 1rem 1.4rem; margin-bottom: 1.4rem;
      display: flex; align-items: flex-start; gap: .9rem;
    }
    .privacy-icon  { font-size: 1.3rem; flex-shrink: 0; line-height: 1.5; }
    .privacy-title { font-size: .82rem; font-weight: 700; color: #0C4A6E; margin-bottom: .2rem; letter-spacing: .03em; text-transform: uppercase; }
    .privacy-body  { font-size: .82rem; color: #075985; margin: 0; line-height: 1.55; }
    .app-title    { font-size: 1.75rem; font-weight: 700; letter-spacing: -.3px; color: #1A1D23; margin-bottom: .2rem; }
    .app-subtitle { font-size: 1.05rem; color: #6B7280; margin-bottom: 0; }
    .section-label { font-size: .88rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: #6B7280; margin-bottom: .8rem; }
    .file-chip { display: inline-block; font-size: .78rem; background: #EAF4EF; border: 1px solid #C6E8D6; border-radius: 4px; padding: .2rem .6rem; color: #2D6A4F; font-weight: 600; margin-top: .5rem; }
    .lang-switcher {
      display: inline-flex; align-items: center;
      border: 1.5px solid #D1D5DB; border-radius: 6px; overflow: hidden;
      font-size: .85rem; font-weight: 700;
    }
    .lang-btn {
      padding: .35rem .8rem; cursor: pointer; background: #fff; color: #6B7280;
      border: none; border-right: 1px solid #D1D5DB;
      transition: all .12s; user-select: none; line-height: 1.4;
    }
    .lang-btn:last-child { border-right: none; }
    .lang-btn.active { background: #2D6A4F; color: #fff; }
    .lang-btn:hover:not(.active) { background: #F3F4F6; color: #1A1D23; }
    .col-grid { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
    .col-toggle {
      display: inline-flex; align-items: center; gap: .4rem; padding: .42rem .9rem;
      border-radius: 999px; border: 1.5px solid #D1D5DB; background: #fff;
      font-size: .92rem; font-weight: 500; color: #374151;
      cursor: pointer; transition: all .12s; user-select: none;
    }
    .col-toggle.active  { border-color: #2D6A4F; background: #EAF4EF; color: #1C4430; }
    .col-toggle.active::before { content: '✓'; font-size: .82rem; color: #2D6A4F; font-weight: 700; }
    .col-toggle.excluded { border-color: #D1D5DB; background: #F9FAFB; color: #9CA3AF; text-decoration: line-through; }
    .toggle-meta { font-size: .9rem; color: #6B7280; margin-top: .4rem; margin-bottom: 0; }
    .toggle-actions { display: flex; gap: .8rem; margin-bottom: .9rem; }
    .link-btn { background: none; border: none; padding: 0; font-size: .85rem; color: #2D6A4F; font-weight: 600; cursor: pointer; text-decoration: underline; }
    .link-btn:hover { color: #1C4430; }
    .badge-idle  { background: #E9ECF0; color: #4B5563; }
    .badge-ok    { background: #D1FAE5; color: #065F46; }
    .badge-error { background: #FEE2E2; color: #991B1B; }
    .status-badge { display: inline-block; font-size: .92rem; font-weight: 600; padding: .3em .85em; border-radius: 999px; }
    .shiny-input-container { width: 100% !important; }
    .form-group { margin-bottom: 0; }
    .form-control, .input-group-text, .form-select { font-size: .95rem !important; padding: .45rem .75rem !important; }
    .input-group { width: 100% !important; }
    .input-group .form-control { flex: 1 1 auto; min-width: 0; }
    #clean_btn, #trt_btn, #pivot_btn {
      border: none; color: #fff; font-weight: 600; font-size: 1rem;
      padding: .6rem 1.7rem; border-radius: 6px; transition: background .15s;
    }
    #clean_btn { background: #2D6A4F; } #clean_btn:hover { background: #245740; }
    #trt_btn   { background: #1D4ED8; } #trt_btn:hover   { background: #1E40AF; }
    #pivot_btn { background: #7C3AED; } #pivot_btn:hover { background: #6D28D9; }
    #chart_btn {
      border: none; color: #fff; font-weight: 700; font-size: .95rem;
      padding: .58rem 1.6rem; border-radius: 6px; background: #0E7490; transition: background .15s;
    }
    #chart_btn:hover { background: #0C5E76; }
    .dl-wrap .btn {
      border: 1.5px solid #2D6A4F; color: #2D6A4F; background: transparent;
      font-weight: 600; font-size: 1rem; padding: .6rem 1.7rem;
      border-radius: 6px; transition: all .15s;
    }
    .dl-wrap .btn:hover { background: #EAF4EF; }
    .trt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: .85rem; margin-bottom: 1.2rem; }
    .trt-row  { display: flex; align-items: center; gap: .6rem; }
    .trt-badge {
      flex-shrink: 0; min-width: 2.4rem; text-align: center;
      background: #F3F4F6; border: 1px solid #E2E6EA; border-radius: 6px;
      padding: .35rem .6rem; font-size: .85rem; font-weight: 700; color: #374151;
    }
    .trt-input {
      flex: 1; border: 1.5px solid #D1D5DB; border-radius: 6px;
      padding: .4rem .7rem; font-size: .9rem; color: #1A1D23;
      outline: none; transition: border-color .12s;
    }
    .trt-input:focus { border-color: #2D6A4F; }
    .trt-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; font-size: .86rem; }
    .trt-table thead th { background: #F3F4F6; font-weight: 700; color: #374151; border-bottom: 2px solid #D1D5DB; padding: .5rem .8rem; text-align: left; }
    .trt-table tbody td { padding: .4rem .8rem; border-bottom: 1px solid #F0F1F3; vertical-align: middle; }
    .trt-table tbody tr:last-child td { border-bottom: none; }
    .trt-table tbody tr:hover td { background: #FAFBFA; }
    .pivot-toggle {
      display: flex; align-items: center; gap: .85rem; padding: 1rem 1.2rem;
      border-radius: 8px; border: 1.5px solid #E2E6EA; background: #FAFAFA;
      margin-bottom: 1.2rem; cursor: pointer; transition: all .12s;
    }
    .pivot-toggle:hover { border-color: #7C3AED; background: #F5F3FF; }
    .pivot-toggle.on    { border-color: #7C3AED; background: #F5F3FF; }
    .pivot-switch {
      width: 2.6rem; height: 1.4rem; border-radius: 999px;
      background: #D1D5DB; position: relative; flex-shrink: 0; transition: background .15s;
    }
    .pivot-toggle.on .pivot-switch { background: #7C3AED; }
    .pivot-switch::after {
      content: ''; position: absolute; top: .18rem; left: .18rem;
      width: 1.04rem; height: 1.04rem; border-radius: 50%;
      background: #fff; transition: left .15s;
    }
    .pivot-toggle.on .pivot-switch::after { left: 1.38rem; }
    .pivot-label { font-size: 1rem; font-weight: 600; color: #374151; }
    .pivot-sub   { font-size: .88rem; color: #6B7280; margin-top: .15rem; }
    .chart-param-grid { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
    .chart-param-toggle {
      display: inline-flex; align-items: center; gap: .4rem; padding: .4rem .9rem;
      border-radius: 999px; border: 1.5px solid #C5D4C2; background: #fff;
      font-size: .85rem; font-weight: 500; color: #374151;
      cursor: pointer; transition: all .12s; user-select: none;
    }
    .chart-param-toggle.active { border-color: #0E7490; background: #E0F4F8; color: #0C4A5C; }
    .chart-param-toggle.active::before { content: '✓'; font-size: .76rem; color: #0E7490; font-weight: 700; }
    .chart-param-toggle.excluded { border-color: #DDE5DB; background: #F9FAF9; color: #9CA3AF; }
    .chart-wrap { background: #fff; border-radius: 6px; border: 1px solid #E2E6EA; padding: 1rem; margin-top: 1rem; }
    .chart-wrap img { width: 100%; height: auto; display: block; border-radius: 4px; }
    .chart-hint { font-size: .82rem; color: #6B7280; margin-bottom: 1rem; }
    .stats-strip {
      display: flex; flex-wrap: wrap; gap: 2.2rem; padding: 1rem 1.4rem;
      background: #F0FBF5; border-radius: 6px; border: 1px solid #C6E8D6; margin-bottom: 1.4rem;
    }
    .stat-item { display: flex; flex-direction: column; }
    .stat-val  { font-size: 1.5rem; font-weight: 700; color: #2D6A4F; line-height: 1; }
    .stat-lbl  { font-size: .85rem; color: #6B7280; margin-top: .25rem; }
    .preview-wrap { overflow-x: auto; overflow-y: auto; max-height: 340px; border-radius: 6px; border: 1px solid #E2E6EA; }
    .preview-wrap thead th { background: #F3F4F6; font-weight: 700; color: #374151; border-bottom: 2px solid #D1D5DB; padding: .55rem .8rem; position: sticky; top: 0; z-index: 1; }
    .preview-wrap table { font-size: .95rem !important; margin-bottom: 0; white-space: nowrap; }
    .preview-wrap tbody td { padding: .45rem .8rem; color: #4B5563; border-bottom: 1px solid #F0F1F3; }
    .preview-wrap tbody tr:last-child td { border-bottom: none; }
    .section-divider { border: none; border-top: 1px dashed #D1D5DB; margin: 1.4rem 0; }
    .rename-chips { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: .4rem; }
    .rename-chip { font-size: .8rem; padding: .25rem .7rem; border-radius: 4px; background: #FFF7ED; border: 1px solid #FED7AA; color: #92400E; font-family: monospace; }
    .rename-chip .arr { color: #9CA3AF; margin: 0 .3rem; }
    .rename-chip .to  { color: #1C4430; font-weight: 700; }
    /* ── dark mode ─────────────────────────────────────────────────────────── */
    body.dark, body.dark html { background: #141A13 !important; color: #D8E8D5 !important; }
    body.dark .dx-card { background: #1E261D; border-color: #334430; box-shadow: 0 1px 6px rgba(0,0,0,.3); }
    body.dark .app-title { color: #E8F4E5; }
    body.dark .app-subtitle { color: #7A9975; }
    body.dark .section-label { color: #7A9975; }
    body.dark .section-divider { border-color: #334430; }
    body.dark .badge-idle  { background: #253023; color: #7A9975; }
    body.dark .badge-ok    { background: #1A3D22; color: #6FCF7F; }
    body.dark .badge-error { background: #3D1A1A; color: #F87171; }
    body.dark .lang-switcher { border-color: #334430; }
    body.dark .lang-btn { background: #1E261D; color: #7A9975; border-color: #334430; }
    body.dark .lang-btn.active { background: #3A6B35; color: #fff; }
    body.dark .lang-btn:hover:not(.active) { background: #253023; color: #D8E8D5; }
    body.dark .privacy-banner { background: #1A2D1A; border-color: #2A4A2A; }
    body.dark .privacy-title { color: #6FCF7F; }
    body.dark .privacy-body  { color: #7A9975; }
    body.dark .file-chip { background: #253023; border-color: #334430; color: #6FCF7F; }
    body.dark .col-toggle { background: #1E261D; border-color: #334430; color: #C5D8C2; }
    body.dark .col-toggle.active { border-color: #5A9B55; background: #1E3A1A; color: #A8E0A0; }
    body.dark .col-toggle.excluded { background: #161E15; color: #4A5E47; }
    body.dark .toggle-meta { color: #7A9975; }
    body.dark .link-btn { color: #6FCF7F; }
    body.dark .link-btn:hover { color: #A8E0A0; }
    body.dark #clean_btn { background: #3A6B35; } body.dark #clean_btn:hover { background: #4A8A44; }
    body.dark #trt_btn   { background: #1D4ED8; } body.dark #trt_btn:hover   { background: #2563EB; }
    body.dark #chart_btn { background: #0E7490; } body.dark #chart_btn:hover { background: #155E75; }
    body.dark .dl-wrap .btn { border-color: #5A9B55; color: #6FCF7F; }
    body.dark .dl-wrap .btn:hover { background: #1E3A1A; }
    body.dark .stats-strip { background: #1A2D1A; border-color: #2A4A2A; }
    body.dark .stat-val { color: #6FCF7F; }
    body.dark .stat-lbl { color: #7A9975; }
    body.dark .preview-wrap { border-color: #334430; background: #1E261D; }
    body.dark .preview-wrap thead th { background: #253023; color: #C5D8C2; border-color: #334430; }
    body.dark .preview-wrap tbody td { background: #1E261D; color: #B8D0B5; border-color: #2A3528; }
    body.dark .preview-wrap tbody tr:hover td { background: #243426; }
    body.dark .preview-wrap table { background: #1E261D; }
    body.dark .rename-chip { background: #2D2010; border-color: #5A3E1A; color: #D4A050; }
    body.dark .rename-chip .to { color: #6FCF7F; }
    body.dark .trt-table thead th { background: #253023; color: #C5D8C2; border-color: #334430; }
    body.dark .trt-table tbody td { border-color: #2A3528; color: #B8D0B5; }
    body.dark .trt-table tbody tr:hover td { background: #1E2E1D; }
    body.dark .trt-badge { background: #253023; border-color: #334430; color: #B8D0B5; }
    body.dark .trt-input { background: #253023; border-color: #334430; color: #D8E8D5; }
    body.dark .trt-input:focus { border-color: #5A9B55; }
    body.dark .pivot-toggle { background: #1E261D; border-color: #334430; }
    body.dark .pivot-toggle:hover, body.dark .pivot-toggle.on { border-color: #7C3AED; background: #1E1A2D; }
    body.dark .pivot-switch { background: #334430; }
    body.dark .pivot-label { color: #D8E8D5; }
    body.dark .pivot-sub   { color: #7A9975; }
    body.dark .chart-param-toggle { background: #1E261D; border-color: #334430; color: #C5D8C2; }
    body.dark .chart-param-toggle.active { border-color: #22B8CF; background: #122A30; color: #67E8F9; }
    body.dark .chart-param-toggle.excluded { background: #161E15; color: #4A5E47; }
    body.dark .chart-wrap { background: #1E261D; border-color: #334430; }
    body.dark .chart-hint { color: #7A9975; }
    body.dark .shiny-table-output table,
    body.dark table.table,
    body.dark table {
      background: #1E261D !important;
      color: #B8D0B5 !important;
    }
    body.dark table.table > :not(caption) > * > * { background: #1E261D !important; color: #B8D0B5 !important; }
    body.dark .form-control, body.dark .form-select, body.dark input[type=text] {
      background: #253023 !important; border-color: #334430 !important; color: #D8E8D5 !important;
    }
    body.dark .input-group-text { background: #334430 !important; border-color: #3D5438 !important; color: #7A9975 !important; }
  "))),

  tags$script(HTML("
    (function() {
      function sendHeight() {
        var h = document.body.scrollHeight;
        window.parent.postMessage({ type: 'cleanex-resize', height: h }, '*');
      }
      var ro = new ResizeObserver(function() { sendHeight(); });
      ro.observe(document.body);
      var mo = new MutationObserver(function() { sendHeight(); });
      mo.observe(document.body, { childList: true, subtree: true, attributes: true });
      sendHeight();
      [100, 300, 600, 1000, 1800, 3000].forEach(function(ms) {
        setTimeout(sendHeight, ms);
      });
    })();

    (function() {
      var mq = window.matchMedia('(prefers-color-scheme: dark)');
      function applyDark(on) { document.body.classList.toggle('dark', on); }
      applyDark(mq.matches);
      mq.addEventListener('change', function(e) { applyDark(e.matches); });
    })();

    function setLang(lang) {
      document.querySelectorAll('.lang-btn').forEach(function(b) {
        b.classList.toggle('active', b.getAttribute('data-lang') === lang);
      });
      Shiny.setInputValue('ui_lang', lang, {priority: 'event'});
    }

    Shiny.addCustomMessageHandler('downloadCSV', function(msg) {
      var blob = new Blob([msg.content], {type: 'text/csv;charset=utf-8;'});
      var link = document.createElement('a');
      link.href = URL.createObjectURL(blob);
      link.download = msg.filename;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    });

    Shiny.addCustomMessageHandler('downloadPNG', function(msg) {
      var link = document.createElement('a');
      link.href = 'data:image/png;base64,' + msg.data;
      link.download = msg.filename;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    });

    Shiny.addCustomMessageHandler('updateColMeta', function(msg) {
      var el = document.getElementById('col-meta');
      if (el) el.textContent = msg.text;
    });

    function toggleCol(el) { el.classList.toggle('active'); el.classList.toggle('excluded'); updateMeta(); }

    function setAll(state) {
      document.querySelectorAll('.col-toggle').forEach(function(el) {
        el.classList.toggle('active', state); el.classList.toggle('excluded', !state);
      });
      updateMeta();
    }

    function updateMeta() {
      var active = document.querySelectorAll('.col-toggle.active').length;
      var total  = document.querySelectorAll('.col-toggle').length;
      var sel = [];
      document.querySelectorAll('.col-toggle.active').forEach(function(t) { sel.push(t.getAttribute('data-col')); });
      Shiny.setInputValue('selected_cols',    sel,    {priority: 'event'});
      Shiny.setInputValue('active_col_count', active, {priority: 'event'});
    }

    function togglePivot() {
      var el = document.getElementById('pivot-toggle');
      var on = el.classList.toggle('on');
      Shiny.setInputValue('pivot_long', on, {priority: 'event'});
    }

    function toggleJitter() {
      var el = document.getElementById('jitter-toggle');
      var on = el.classList.toggle('on');
      Shiny.setInputValue('show_jitter', on, {priority: 'event'});
    }

    function toggleChartParam(el) {
      el.classList.toggle('active');
      el.classList.toggle('excluded');
      var sel = [];
      document.querySelectorAll('.chart-param-toggle.active').forEach(function(t) {
        sel.push(t.getAttribute('data-param'));
      });
      Shiny.setInputValue('chart_params', sel, {priority: 'event'});
    }

    $(document).on('shiny:value', function(e) {
      if (e.name === 'col_selector_panel') setTimeout(updateMeta, 50);
    });
  ")),

  # ── Header ────────────────────────────────────────────────────────────────
  div(class = "dx-card",
    div(style = "display:flex; align-items:center; justify-content:space-between; flex-wrap:nowrap; gap:.5rem;",
      div(class = "app-title", "CleanEX 🧹"),
      div(style = "display:flex; align-items:center; gap:.8rem; flex-wrap:nowrap; flex-shrink:0;",
        uiOutput("status_badge"),
        div(class = "lang-switcher",
          tags$button(class = "lang-btn",        `data-lang` = "it", onclick = "setLang('it')", "IT"),
          tags$button(class = "lang-btn active", `data-lang` = "en", onclick = "setLang('en')", "EN")
        )
      )
    ),
    uiOutput("app_subtitle")
  ),

  uiOutput("privacy_banner"),
  uiOutput("step1_panel"),
  uiOutput("col_selector_panel"),
  uiOutput("step3_panel"),
  uiOutput("preview_panel"),
  uiOutput("treatment_panel"),
  uiOutput("pivot_panel"),
  uiOutput("chart_panel"),
  uiOutput("merge_panel"),

  hr(class = "section-divider"),
  div(style = "text-align:center; font-size:.92rem; color:#6B7280; padding:.5rem 0 1.5rem;",
    uiOutput("app_credits")
  )
)

# ── Server ───────────────────────────────────────────────────────────────────
server <- function(input, output, session) {

  rv <- reactiveValues(
    df_raw = NULL, df_clean = NULL, df_trt = NULL, df_final = NULL,
    groups = NULL, error = NULL, base_name = NULL, chart_png_b64 = NULL,
    df_merged = NULL, merge_error = NULL, df_prev_preview = NULL
  )

  lang <- reactive({ if (is.null(input$ui_lang)) "en" else input$ui_lang })
  t    <- reactive({ TR[[lang()]] })

  output$app_subtitle <- renderUI({
    tx <- t()
    tagList(
      p(class = "app-subtitle", tx$app_subtitle, " ",
        tags$a(href = "https://metos.global/en/dualex/",
               target = "_blank",
               style = "color:#2D6A4F; font-weight:700; text-decoration:underline;",
               "Dualex — Metos Global.")),
      p(class = "app-subtitle", style = "font-size:.9rem; color:#9CA3AF; margin-top:.1rem;",
        tx$app_descr)
    )
  })

  output$privacy_banner <- renderUI({
    tx <- t()
    div(class = "privacy-banner",
      div(class = "privacy-icon", "🔒"),
      div(
        div(class = "privacy-title", tx$privacy_title),
        p(class  = "privacy-body",  tx$privacy_body)
      )
    )
  })

  output$app_credits <- renderUI({
    cr <- t()$credits
    tagList(
      cr$pre,
      tags$a(href = "https://www.mauromaver.eu", target = "_blank",
             style = "color:#2D6A4F; font-weight:700; text-decoration:underline;", "Mauro Maver"),
      " · ",
      tags$span(style = "color:#9CA3AF; font-size:.88rem;", "v.1.0"),
      cr$bug,
      tags$a(href = "https://github.com/Stramon1um", target = "_blank",
             style = "color:#2D6A4F; font-weight:600; text-decoration:underline;", "GitHub"),
      cr$or,
      tags$a(href = "mailto:mauro.maver@univr.it",
             style = "color:#2D6A4F; font-weight:600; text-decoration:underline;", cr$mail),
      " 😊"
    )
  })

  # ── Step 1 ────────────────────────────────────────────────────────────────
  output$step1_panel <- renderUI({
    tx <- t()
    div(class = "dx-card",
      p(class = "section-label", tx$step1),
      fileInput("file_in", label = NULL, accept = c(".csv", "text/csv"),
                placeholder = tx$file_placeholder, buttonLabel = tx$file_btn),
      uiOutput("file_info_chip")
    )
  })

  observeEvent(input$file_in, {
    rv$df_raw <- rv$df_clean <- rv$df_trt <- rv$df_final <- rv$groups <- rv$error <- rv$chart_png_b64 <- NULL
    tryCatch({
      rv$df_raw    <- parse_dx_raw(input$file_in$datapath)
      rv$base_name <- sub("\\.csv$", "", input$file_in$name, ignore.case = TRUE)
    }, error = function(e) rv$error <- conditionMessage(e))
  })

  output$file_info_chip <- renderUI({
    req(rv$df_raw)
    div(class = "file-chip", t()$file_info(nrow(rv$df_raw), ncol(rv$df_raw)))
  })

  # ── Step 2: Pill colonne ──────────────────────────────────────────────────
  output$col_selector_panel <- renderUI({
    req(rv$df_raw)
    tx       <- t()
    all_cols <- colnames(rv$df_raw)
    n_active <- sum(!tolower(all_cols) %in% tolower(DEFAULT_DROP))
    pills <- lapply(all_cols, function(col) {
      is_active <- !tolower(col) %in% tolower(DEFAULT_DROP)
      tags$span(
        class      = paste("col-toggle", if (is_active) "active" else "excluded"),
        `data-col` = col,
        onclick    = "toggleCol(this);",
        col
      )
    })
    div(class = "dx-card",
      p(class = "section-label", tx$step2),
      div(class = "toggle-actions",
        tags$button(class = "link-btn", onclick = "setAll(true)",  tx$sel_all),
        tags$button(class = "link-btn", onclick = "setAll(false)", tx$desel_all)
      ),
      div(class = "col-grid", pills),
      p(class = "toggle-meta", id = "col-meta",
        paste(n_active, "di", length(all_cols), tx$col_meta))
    )
  })

  observe({
    req(rv$df_raw)
    active <- if (!is.null(input$active_col_count)) input$active_col_count
              else sum(!tolower(colnames(rv$df_raw)) %in% tolower(DEFAULT_DROP))
    session$sendCustomMessage("updateColMeta",
      list(text = paste(active, "of", ncol(rv$df_raw), t()$col_meta)))
  })

  # ── Step 3: Pulizia + rinomina ────────────────────────────────────────────
  output$step3_panel <- renderUI({
    req(rv$df_raw)
    div(class = "dx-card",
      p(class = "section-label", t()$step3),
      div(style = "display:flex; gap:.8rem; align-items:center; flex-wrap:wrap;",
        actionButton("clean_btn", t()$btn_clean, icon = icon("broom")),
        div(class = "dl-wrap", uiOutput("dl_ui"))
      )
    )
  })

  observeEvent(input$clean_btn, {
    req(rv$df_raw)
    rv$df_clean <- rv$df_trt <- rv$df_final <- rv$groups <- rv$error <- rv$chart_png_b64 <- NULL
    tryCatch({
      df   <- rv$df_raw
      keep <- input$selected_cols
      if (is.null(keep) || !length(keep))
        keep <- colnames(df)[!tolower(colnames(df)) %in% tolower(DEFAULT_DROP)]
      df      <- df[, colnames(df)[colnames(df) %in% keep], drop = FALSE]
      df      <- apply_rename(df)
      grp_col <- grep("^group$", colnames(df), ignore.case = TRUE, value = TRUE)
      if (!length(grp_col)) grp_col <- grep("group", colnames(df), ignore.case = TRUE, value = TRUE)
      if (length(grp_col)) {
        grps      <- unique(df[[grp_col[1]]])
        nums      <- suppressWarnings(as.numeric(grps))
        rv$groups <- if (!anyNA(nums)) grps[order(nums)] else sort(grps)
      }
      rv$df_clean <- df
    }, error = function(e) rv$error <- conditionMessage(e))
  })

  output$dl_ui <- renderUI({
    req(rv$df_clean)
    div(class = "dl-wrap", actionButton("dl_btn", t()$btn_dl_clean, icon = icon("download")))
  })

  observeEvent(input$dl_btn, {
    req(rv$df_clean)
    session$sendCustomMessage("downloadCSV", list(
      filename = paste0(rv$base_name, "_clean.csv"),
      content  = df_to_csv_string(rv$df_clean)
    ))
  })

  # ── Anteprima step 3 ─────────────────────────────────────────────────────
  output$preview_panel <- renderUI({
    tx <- t()
    if (!is.null(rv$error) && is.null(rv$df_clean)) {
      div(class = "dx-card",
        div(style = "color:#991B1B; font-size:.9rem;", icon("circle-exclamation"), " ", rv$error))
    } else if (!is.null(rv$df_clean)) {
      df      <- rv$df_clean
      grp     <- if (!is.null(rv$groups)) length(rv$groups) else "—"
      applied <- COL_RENAME[names(COL_RENAME) %in% tolower(colnames(rv$df_raw))]
      chips   <- lapply(names(applied), function(old)
        div(class = "rename-chip", span(old), span(class = "arr", "→"), span(class = "to", applied[[old]]))
      )
      div(class = "dx-card",
        p(class = "section-label", tx$summary),
        if (length(chips)) tagList(
          p(style = "font-size:.82rem; color:#6B7280; margin-bottom:.4rem;", tx$renamed_lbl),
          div(class = "rename-chips", chips),
          hr(class = "section-divider")
        ),
        div(class = "stats-strip",
          div(class="stat-item", span(class="stat-val", nrow(df)), span(class="stat-lbl", tx$stat_measures)),
          div(class="stat-item", span(class="stat-val", ncol(df)), span(class="stat-lbl", tx$stat_cols)),
          div(class="stat-item", span(class="stat-val", grp),      span(class="stat-lbl", tx$stat_groups)),
          div(class="stat-item", span(class="stat-val", paste0(rv$base_name, "_clean.csv")), span(class="stat-lbl", tx$stat_file))
        ),
        p(class = "section-label", tx$preview8),
        div(class = "preview-wrap", tableOutput("preview_tbl"))
      )
    }
  })
  output$preview_tbl <- renderTable({ req(rv$df_clean); rv$df_clean },
    striped = FALSE, hover = TRUE, bordered = FALSE, spacing = "xs", na = "")

  # ── Step 4: Trattamenti per gruppo (tabella, ogni misura = replica) ──────
  output$treatment_panel <- renderUI({
    req(rv$df_clean, rv$groups)
    tx     <- t()
    inputs <- lapply(rv$groups, function(g) {
      div(class = "trt-row",
        div(class = "trt-badge", g),
        tags$input(type = "text", id = paste0("trt_", g), class = "trt-input",
          placeholder = paste0(tx$trt_placeholder, g),
          oninput = paste0("Shiny.setInputValue('trt_", g, "', this.value, {priority:'event'})"))
      )
    })
    div(class = "dx-card",
      p(class = "section-label", tx$step4),
      p(style = "font-size:.86rem; color:#6B7280; margin-bottom:1rem;", tx$trt_hint),
      div(class = "trt-grid", inputs),
      hr(class = "section-divider"),
      div(style = "display:flex; gap:.8rem; align-items:center; flex-wrap:wrap;",
        actionButton("trt_btn", tx$btn_trt, icon = icon("tags")),
        div(class = "dl-wrap", uiOutput("dl_trt_ui"))
      ),
      uiOutput("trt_preview_wrap")
    )
  })

  observeEvent(input$trt_btn, {
    req(rv$df_clean, rv$groups)
    rv$df_trt <- rv$df_final <- rv$error <- rv$chart_png_b64 <- NULL
    tryCatch({
      df      <- rv$df_clean
      grp_col <- grep("^group$", colnames(df), ignore.case = TRUE, value = TRUE)
      if (!length(grp_col)) grp_col <- grep("group", colnames(df), ignore.case = TRUE, value = TRUE)
      grp_col <- grp_col[1]
      trt_map <- setNames(
        vapply(rv$groups, function(g) {
          val <- input[[paste0("trt_", g)]]
          if (is.null(val) || trimws(val) == "") as.character(g) else trimws(val)
        }, character(1)),
        rv$groups
      )
      df$treatment <- trt_map[df[[grp_col]]]
      grp_pos  <- which(colnames(df) == grp_col)
      rest     <- setdiff(colnames(df), c(colnames(df)[seq_len(grp_pos)], "treatment"))
      rv$df_trt <- df[, c(colnames(df)[seq_len(grp_pos)], "treatment", rest), drop = FALSE]
    }, error = function(e) rv$error <- conditionMessage(e))
  })

  output$dl_trt_ui <- renderUI({
    req(rv$df_trt)
    div(class = "dl-wrap", actionButton("dl_trt_btn", t()$btn_dl_trt, icon = icon("download")))
  })

  observeEvent(input$dl_trt_btn, {
    req(rv$df_trt)
    session$sendCustomMessage("downloadCSV", list(
      filename = paste0(rv$base_name, "_clean.csv"),
      content  = df_to_csv_string(rv$df_trt)
    ))
  })

  output$trt_preview_wrap <- renderUI({
    req(rv$df_trt)
    tagList(
      hr(class = "section-divider"),
      p(class = "section-label", t()$trt_preview),
      div(class = "preview-wrap", tableOutput("trt_preview_tbl"))
    )
  })
  output$trt_preview_tbl <- renderTable({ req(rv$df_trt); rv$df_trt },
    striped = FALSE, hover = TRUE, bordered = FALSE, spacing = "xs", na = "")

  # ── Step 5: Pivot long + download finale ─────────────────────────────────
  output$pivot_panel <- renderUI({
    req(rv$df_trt)
    tx           <- t()
    params_found <- PARAM_COLS[PARAM_COLS %in% colnames(rv$df_trt)]
    div(class = "dx-card",
      p(class = "section-label", tx$step5),
      if (length(params_found))
        div(id = "pivot-toggle", class = "pivot-toggle", onclick = "togglePivot();",
          div(class = "pivot-switch"),
          div(
            div(class = "pivot-label", tx$pivot_label),
            div(class = "pivot-sub",   paste(tx$pivot_sub, paste(params_found, collapse = ", ")))
          )
        ),
      div(style = "display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; margin-top:.5rem;",
        actionButton("pivot_btn", tx$btn_final, icon = icon("file-csv")),
        uiOutput("dl_final_ui")
      ),
      uiOutput("final_preview_wrap")
    )
  })

  observeEvent(input$pivot_btn, {
    req(rv$df_trt)
    rv$df_final <- rv$error <- rv$chart_png_b64 <- NULL
    tryCatch({
      rv$df_final <- if (isTRUE(input$pivot_long)) pivot_long(rv$df_trt) else rv$df_trt
    }, error = function(e) rv$error <- conditionMessage(e))
  })

  output$dl_final_ui <- renderUI({
    req(rv$df_final)
    div(class = "dl-wrap", actionButton("dl_final_btn", t()$btn_dl_final, icon = icon("download")))
  })

  observeEvent(input$dl_final_btn, {
    req(rv$df_final)
    session$sendCustomMessage("downloadCSV", list(
      filename = paste0(rv$base_name, "_clean.csv"),
      content  = df_to_csv_string(rv$df_final)
    ))
  })

  output$final_preview_wrap <- renderUI({
    req(rv$df_final)
    tx <- t()
    tagList(
      hr(class = "section-divider"),
      div(class = "stats-strip",
        div(class="stat-item", span(class="stat-val", nrow(rv$df_final)), span(class="stat-lbl", tx$stat_rows)),
        div(class="stat-item", span(class="stat-val", ncol(rv$df_final)), span(class="stat-lbl", tx$stat_cols)),
        div(class="stat-item",
          span(class="stat-val", if (isTRUE(input$pivot_long)) "long" else "wide"),
          span(class="stat-lbl", tx$stat_format)),
        div(class="stat-item",
          span(class="stat-val", paste0(rv$base_name, "_clean.csv")),
          span(class="stat-lbl", tx$stat_file))
      ),
      p(class = "section-label", tx$final_preview),
      div(class = "preview-wrap", tableOutput("final_preview_tbl"))
    )
  })
  output$final_preview_tbl <- renderTable({ req(rv$df_final); rv$df_final },
    striped = FALSE, hover = TRUE, bordered = FALSE, spacing = "xs", na = "")

  # ── Step 6: Grafico esplorativo ───────────────────────────────────────────
  output$chart_panel <- renderUI({
    req(rv$df_final)
    tx <- t()

    if (!"treatment" %in% colnames(rv$df_final)) return(NULL)

    excl <- c("date", "time", "temp", "group", "treatment", "measure", "face")
    avail_params <- get_available_chart_params(rv$df_final)

    if (!length(avail_params)) {
      return(div(class = "dx-card", p(class = "section-label", tx$step6),
                  p(style = "font-size:.85rem; color:#9CA3AF;", tx$chart_no_params)))
    }

    pills <- lapply(avail_params, function(p) {
      tags$span(class = "chart-param-toggle active", `data-param` = p,
                onclick = "toggleChartParam(this);", p)
    })

    div(class = "dx-card",
      p(class = "section-label", tx$step6),
      p(class = "chart-hint", tx$chart_hint),
      p(style = "font-size:.82rem; color:#6B7280; margin-bottom:.5rem; font-weight:600;", tx$chart_param_lbl),
      div(class = "chart-param-grid", pills),
      div(id = "jitter-toggle", class = "pivot-toggle", onclick = "toggleJitter();",
        div(class = "pivot-switch"),
        div(
          div(class = "pivot-label", tx$jitter_label),
          div(class = "pivot-sub",   tx$jitter_sub)
        )
      ),
      div(style = "display:flex; gap:.8rem; align-items:center; flex-wrap:wrap;",
        actionButton("chart_btn", tx$btn_chart, icon = icon("chart-column")),
        uiOutput("dl_png_ui")
      ),
      uiOutput("chart_render_wrap")
    )
  })

  observeEvent(input$chart_btn, {
    req(rv$df_final)
    rv$chart_png_b64 <- NULL

    sel_params <- input$chart_params
    if (is.null(sel_params) || !length(sel_params)) {
      sel_params <- get_available_chart_params(rv$df_final)
    }
    if (!length(sel_params)) return(NULL)

    is_long <- "parameter" %in% colnames(rv$df_final) && "value" %in% colnames(rv$df_final)

    plot_df <- if (is_long) {
      base <- rv$df_final[rv$df_final$parameter %in% sel_params, , drop = FALSE]
      id_cols <- setdiff(colnames(base), c("parameter", "value"))
      wide <- reshape(base, idvar = id_cols, timevar = "parameter",
                       direction = "wide", v.names = "value")
      colnames(wide) <- sub("^value\\.", "", colnames(wide))
      wide
    } else rv$df_final

    tmpfile <- tempfile(fileext = ".png")
    n_p     <- length(sel_params)
    n_col   <- if (n_p > 1) 2 else 1
    n_row   <- ceiling(n_p / n_col)
    png(tmpfile, width = 5.2 * n_col, height = 4.6 * n_row, units = "in", res = 300)
    draw_bar_chart(plot_df, sel_params, ylab = t()$chart_ylab, is_dark = FALSE,
                    show_jitter = isTRUE(input$show_jitter))
    dev.off()

    raw_bytes <- readBin(tmpfile, "raw", file.info(tmpfile)$size)
    rv$chart_png_b64 <- base64_encode_raw(raw_bytes)
    unlink(tmpfile)
  })

  output$chart_render_wrap <- renderUI({
    req(rv$chart_png_b64)
    div(class = "chart-wrap",
      tags$img(src = paste0("data:image/png;base64,", rv$chart_png_b64))
    )
  })

  output$dl_png_ui <- renderUI({
    req(rv$chart_png_b64)
    div(class = "dl-wrap", actionButton("dl_png_btn", t()$btn_dl_png, icon = icon("image")))
  })

  observeEvent(input$dl_png_btn, {
    req(rv$chart_png_b64)
    session$sendCustomMessage("downloadPNG", list(
      filename = paste0(rv$base_name, "_chart.png"),
      data     = rv$chart_png_b64
    ))
  })

  # ── Step 7: Merge con file precedente ────────────────────────────────────────
  output$merge_panel <- renderUI({
    req(rv$df_final)
    tx <- t()
    div(class = "dx-card",
      p(class = "section-label", tx$step7),
      p(style = "font-size:.9rem; color:#6B7280; margin-bottom:1rem;", tx$merge_hint),
      # ── Anteprima file corrente ───────────────────────────────────────────
      p(style = "font-size:.86rem; font-weight:600; color:#374151; margin-bottom:.4rem;",
        tx$merge_preview_curr),
      div(class = "preview-wrap", style = "margin-bottom:1.2rem;",
        tableOutput("merge_curr_tbl")),
      hr(class = "section-divider"),
      # ── Caricamento file precedente ───────────────────────────────────────
      fileInput("merge_file_in", label = NULL, accept = c(".csv", "text/csv"),
                placeholder = tx$merge_file_ph, buttonLabel = tx$merge_file_btn),
      uiOutput("merge_prev_preview_ui"),
      # ── Pulsanti ─────────────────────────────────────────────────────────
      div(style = "display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; margin-top:.5rem;",
        actionButton("merge_btn", tx$btn_merge, icon = icon("object-group"),
          style = "border:none;color:#fff;font-weight:600;font-size:1rem;padding:.6rem 1.7rem;border-radius:6px;background:#0E7490;"),
        uiOutput("dl_merge_ui")
      ),
      uiOutput("merge_status_ui")
    )
  })

  output$merge_curr_tbl <- renderTable({
    req(rv$df_final)
    head(rv$df_final, 5)
  }, striped = FALSE, hover = TRUE, bordered = FALSE, spacing = "xs", na = "")

  # Carica preview file precedente appena viene scelto il file
  observeEvent(input$merge_file_in, {
    rv$df_prev_preview <- rv$df_merged <- rv$merge_error <- NULL
    tryCatch({
      rv$df_prev_preview <- read.csv(input$merge_file_in$datapath,
                                      check.names = FALSE, stringsAsFactors = FALSE)
    }, error = function(e) rv$merge_error <- t()$merge_err_read)
  })

  output$merge_prev_preview_ui <- renderUI({
    req(rv$df_prev_preview)
    tx <- t()
    tagList(
      p(style = "font-size:.86rem; font-weight:600; color:#374151; margin-bottom:.4rem;",
        tx$merge_preview_prev),
      div(class = "preview-wrap", style = "margin-bottom:1.2rem;",
        tableOutput("merge_prev_tbl"))
    )
  })

  output$merge_prev_tbl <- renderTable({
    req(rv$df_prev_preview)
    head(rv$df_prev_preview, 5)
  }, striped = FALSE, hover = TRUE, bordered = FALSE, spacing = "xs", na = "")

  observeEvent(input$merge_btn, {
    req(rv$df_final, rv$df_prev_preview)
    rv$df_merged <- rv$merge_error <- NULL
    tryCatch({
      df_prev   <- rv$df_prev_preview
      cols_curr <- colnames(rv$df_final)
      cols_prev <- colnames(df_prev)
      if (!identical(sort(cols_curr), sort(cols_prev))) {
        rv$merge_error <- t()$merge_err_cols
        return()
      }
      df_prev <- df_prev[, cols_curr, drop = FALSE]
      rv$df_merged <- rbind(df_prev, rv$df_final)
    }, error = function(e) {
      rv$merge_error <- t()$merge_err_read
    })
  })

  output$merge_status_ui <- renderUI({
    if (!is.null(rv$merge_error)) {
      div(style = "margin-top:.8rem; padding:.7rem 1rem; background:#FEE2E2; border:1px solid #FECACA; border-radius:6px; font-size:.9rem; color:#991B1B;",
        icon("circle-exclamation"), " ", rv$merge_error)
    } else if (!is.null(rv$df_merged)) {
      n1 <- nrow(rv$df_final)
      n2 <- nrow(rv$df_merged) - n1
      div(style = "margin-top:.8rem; padding:.7rem 1rem; background:#D1FAE5; border:1px solid #6EE7B7; border-radius:6px; font-size:.9rem; color:#065F46;",
        t()$merge_ok(n2, n1))
    }
  })

  output$dl_merge_ui <- renderUI({
    req(rv$df_merged)
    div(class = "dl-wrap",
      actionButton("dl_merge_btn", t()$btn_dl_merge, icon = icon("download")))
  })

  observeEvent(input$dl_merge_btn, {
    req(rv$df_merged)
    session$sendCustomMessage("downloadCSV", list(
      filename = paste0(rv$base_name, "_combined.csv"),
      content  = df_to_csv_string(rv$df_merged)
    ))
  })

  # ── Badge stato ───────────────────────────────────────────────────────────
  output$status_badge <- renderUI({
    tx <- t()
    if (!is.null(rv$error))         div(class="status-badge badge-error", tx$status_error)
    else if (!is.null(rv$df_final)) div(class="status-badge badge-ok",   tx$status_final)
    else if (!is.null(rv$df_trt))   div(class="status-badge badge-ok",   tx$status_trt)
    else if (!is.null(rv$df_clean)) div(class="status-badge badge-ok",   tx$status_clean)
    else if (!is.null(rv$df_raw))   div(class="status-badge badge-ok",   tx$status_loaded)
    else                             div(class="status-badge badge-idle", tx$status_waiting)
  })
}

shinyApp(ui, server)
 
Cookie Preferences