inst/shiny/modules/server_modules.R

#' Server Module Definitions
#'
#' This file contains server-side logic for the modular UI components.
#' Each module handles its own state and communicates with the global state.
#' 

# =============================================================================
# HTML Data Dictionary Generator
# Generates professional, print-ready HTML data dictionaries
# =============================================================================

#' Null-safe accessor operator
#' @noRd
`%||%` <- function(x, y) {
  if (is.null(x) || (is.character(x) && length(x) == 1 && x == "")) y else x
}

#' Generate HTML Data Dictionary
#'
#' Creates a professional, print-ready HTML data dictionary document from
#' variable metadata.
#'
#' @param dictionary_data A list containing:
#'   \describe{
#'     \item{variables}{Named list of variable definitions. Each variable can have:
#'       `type`, `description`, `unit`, `min_value`, `max_value`, `required`,
#'       `unique`, `pattern`, `categorical_values`, `statistics`}
#'     \item{missing_values}{Character vector of global missing value codes}
#'   }
#' @param dataset_info A list containing dataset metadata:
#'   \describe{
#'     \item{name}{Dataset name}
#'     \item{description}{Dataset description}
#'     \item{version}{Version string}
#'     \item{author}{List of author information}
#'   }
#' @param output_file Character. Path for the output HTML file. If `NULL`,
#'   a temporary file is created.
#' @param include_stats Logical. Whether to include summary statistics in
#'   the output. Default is `TRUE`.
#'
#' @return Character string containing the path to the generated HTML file.
#'
#' @details
#' The generated HTML document includes:
#' \itemize{
#'   \item Professional styling optimized for both screen and print
#'   \item Table of contents with navigation links
#'   \item Dataset overview with summary statistics
#'   \item Detailed variable definitions with constraints
#'   \item Categorical value tables
#'   \item Summary statistics (if available and requested)
#' }
#'
#' The HTML file can be converted to PDF by opening in a web browser and
#' using the browser's print function (Ctrl+P / Cmd+P).
#'
#' @examples
#' \dontrun{
#' # Define variables
#' dict_data <- list(
#'   variables = list(
#'     participant_id = list(
#'       type = "string",
#'       description = "Unique participant identifier",
#'       required = TRUE,
#'       unique = TRUE
#'     ),
#'     age = list(
#'       type = "integer",
#'       description = "Participant age in years",
#'       unit = "years",
#'       min_value = 18,
#'       max_value = 100
#'     )
#'   ),
#'   missing_values = c("NA", "-999")
#' )
#'
#' # Dataset info
#' info <- list(
#'   name = "My Study",
#'   description = "A psychological study"
#' )
#'
#' # Generate dictionary
#' generate_html_dictionary(dict_data, info, "my_dictionary.html")
#' }
#'
#' @export

generate_html_dictionary <- function(dictionary_data, 
                                     dataset_info = NULL, 
                                     output_file = NULL,
                                     include_stats = TRUE) {
  
  if (is.null(output_file)) {
    output_file <- file.path(tempdir(), 
                             paste0("data_dictionary_", format(Sys.Date(), "%Y%m%d"), ".html"))
  }
  
  if (!grepl("\\.html$", output_file, ignore.case = TRUE)) {
    output_file <- gsub("\\.[^.]+$", ".html", output_file)
    if (!grepl("\\.html$", output_file)) {
      output_file <- paste0(output_file, ".html")
    }
  }
  
  dir.create(dirname(output_file), showWarnings = FALSE, recursive = TRUE)
  html_content <- generate_html_content(dictionary_data, dataset_info, include_stats)
  writeLines(html_content, output_file, useBytes = TRUE)
  return(output_file)
}

#' Generate HTML Content
#' @noRd
generate_html_content <- function(dictionary_data, dataset_info, include_stats) {
  
  css <- get_dictionary_css()
  
  html_parts <- c('<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Data Dictionary', 
  if (!is.null(dataset_info$name)) paste0(" - ", escape_html(dataset_info$name)) else "",
  '</title>
  <style>', css, '</style>
</head>
<body>
  <header class="document-header">
    <div class="header-content">
      <h1 class="document-title">Data Dictionary</h1>')
  
  if (!is.null(dataset_info$name)) {
    html_parts <- c(html_parts, '<h2 class="dataset-name">', escape_html(dataset_info$name), '</h2>')
  }
  
  html_parts <- c(html_parts, '<p class="generation-date">Generated on ', 
                  format(Sys.Date(), "%B %d, %Y"), '</p>
    </div>
  </header>
  <main class="container">')
  
  html_parts <- c(html_parts, generate_toc_html(dictionary_data, dataset_info))
  html_parts <- c(html_parts, generate_overview_html(dictionary_data, dataset_info))
  
  if (!is.null(dictionary_data$missing_values) && length(dictionary_data$missing_values) > 0) {
    html_parts <- c(html_parts, generate_missing_values_html(dictionary_data$missing_values))
  }
  
  html_parts <- c(html_parts, generate_variables_html(dictionary_data$variables, include_stats, dictionary_data$missing_values))
  
  html_parts <- c(html_parts, '
  </main>
  <footer class="document-footer">
    <div class="footer-content">
      <p>Generated following the <a href="https://psych-ds.github.io/" target="_blank">Psych-DS Standard</a></p>
      <p class="generator-info">Created with the psychds R package</p>
    </div>
  </footer>
</body>
</html>')
  
  return(paste(html_parts, collapse = "\n"))
}

#' Get CSS Styles
#' @noRd
get_dictionary_css <- function() {
'
:root {
  --primary: #2c3e50;
  --secondary: #3498db;
  --accent: #1abc9c;
  --text: #2d2d2d;
  --muted: #6c757d;
  --surface: #f8f9fa;
  --border: #dee2e6;
  --light-blue: #e8f4f8;
  --success: #28a745;
  --warning: #ffc107;
  --danger: #dc3545;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: 14px;
  line-height: 1.7;
  color: var(--text);
  background: #fff;
  margin: 0;
  padding: 0;
}

h1, h2, h3, h4, h5 {
  font-weight: 600;
  line-height: 1.3;
  margin-top: 0;
  color: var(--primary);
}

h2 {
  font-size: 1.5rem;
  border-bottom: 3px solid var(--secondary);
  padding-bottom: 0.6rem;
  margin-bottom: 1.5rem;
  margin-top: 2rem;
}

h3 {
  font-size: 1.15rem;
  color: var(--primary);
  margin-bottom: 0.75rem;
}

h4 {
  font-size: 0.95rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: 0.75rem;
  margin-top: 1.25rem;
}

code, .monospace {
  font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", Consolas, monospace;
  font-size: 0.9em;
  background: #f5f7fa;
  padding: 0.2em 0.5em;
  border-radius: 3px;
  color: #c7254e;
}

a { color: var(--secondary); text-decoration: none; }
a:hover { text-decoration: underline; }

.document-header {
  background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
  color: white;
  padding: 3rem 2rem;
  text-align: center;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.document-header h1, .document-header h2 {
  color: white;
  border: none;
}

.document-title {
  font-size: 2.4rem;
  font-weight: 300;
  margin-bottom: 0.5rem;
  letter-spacing: -0.5px;
}

.dataset-name {
  font-size: 1.4rem;
  font-weight: 400;
  opacity: 0.95;
  margin-bottom: 0.75rem;
}

.generation-date {
  font-size: 0.875rem;
  opacity: 0.8;
  margin: 0;
  font-weight: 300;
}

.container {
  max-width: 1000px;
  margin: 0 auto;
  padding: 2.5rem 2rem;
}

.section {
  margin-bottom: 3rem;
}

.section-header {
  display: flex;
  align-items: center;
  gap: 0.875rem;
  margin-bottom: 1.5rem;
}

.section-icon {
  width: 36px;
  height: 36px;
  background: var(--secondary);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 1.1rem;
}

.toc {
  background: var(--light-blue);
  border-radius: 8px;
  padding: 1.5rem 1.75rem;
  margin-bottom: 2.5rem;
  border-left: 4px solid var(--secondary);
}

.toc h2 {
  font-size: 1.1rem;
  margin-bottom: 1rem;
  border: none;
  padding: 0;
  color: var(--primary);
}

.toc-list {
  list-style: none;
  padding: 0;
  margin: 0;
  columns: 2;
  column-gap: 2.5rem;
}

.toc-list li {
  margin-bottom: 0.5rem;
  break-inside: avoid;
  padding-left: 0;
}

.toc-list a {
  color: var(--text);
  display: inline-flex;
  align-items: baseline;
  gap: 0.5rem;
}

.toc-list a:hover {
  color: var(--secondary);
}

.toc-number {
  color: var(--secondary);
  font-weight: 700;
  font-size: 0.9em;
  min-width: 1.5em;
}

.overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1.25rem;
  margin-bottom: 2rem;
}

.stat-card {
  background: white;
  border-radius: 8px;
  padding: 1.5rem;
  text-align: center;
  border: 2px solid var(--border);
  box-shadow: 0 1px 3px rgba(0,0,0,0.08);
  transition: transform 0.2s, box-shadow 0.2s;
}

.stat-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

.stat-value {
  font-size: 2rem;
  font-weight: 700;
  color: var(--secondary);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.stat-label {
  font-size: 0.8rem;
  color: var(--muted);
  margin-top: 0.5rem;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 600;
}

.description-box {
  background: var(--surface);
  border-radius: 8px;
  padding: 1.5rem;
  margin-bottom: 1.5rem;
  border-left: 4px solid var(--accent);
}

.description-box p {
  margin: 0;
  line-height: 1.7;
}

.info-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.info-list li {
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--border);
  display: grid;
  grid-template-columns: 140px 1fr;
  gap: 1.5rem;
  align-items: start;
}

.info-list li:last-child { border-bottom: none; }

.info-label {
  font-weight: 600;
  color: var(--muted);
  font-size: 0.9em;
}

.info-value {
  color: var(--text);
}

.missing-values-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.625rem;
  margin-top: 1rem;
}

.missing-value-badge {
  background: #fff9e6;
  border: 1.5px solid #ffc107;
  color: #856404;
  padding: 0.4rem 0.875rem;
  border-radius: 24px;
  font-family: monospace;
  font-size: 0.875rem;
  font-weight: 600;
}

.variable-card {
  background: white;
  border: 1px solid var(--border);
  border-radius: 8px;
  margin-bottom: 2rem;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  page-break-inside: avoid;
  transition: box-shadow 0.2s;
}

.variable-card:hover {
  box-shadow: 0 4px 16px rgba(0,0,0,0.12);
}

.variable-header {
  background: linear-gradient(to right, #f8f9fa, #ffffff);
  padding: 1.125rem 1.5rem;
  border-bottom: 2px solid var(--border);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.variable-name-section {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.variable-name {
  font-family: "SF Mono", Consolas, monospace;
  font-size: 1.125rem;
  font-weight: 700;
  color: var(--primary);
  letter-spacing: -0.3px;
}

.variable-display-name {
  font-size: 0.85rem;
  color: var(--muted);
  font-weight: 400;
  font-style: italic;
}

.variable-type-badge {
  background: var(--secondary);
  color: white;
  padding: 0.3rem 0.875rem;
  border-radius: 24px;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.75px;
  font-weight: 700;
}

.variable-body {
  padding: 1.5rem;
}

.variable-description {
  font-size: 0.95rem;
  line-height: 1.7;
  margin-bottom: 1.5rem;
  padding-bottom: 1.125rem;
  border-bottom: 1px solid var(--border);
  color: var(--text);
}

.badge-container {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 1.125rem;
}

.badge {
  font-size: 0.7rem;
  padding: 0.3rem 0.625rem;
  border-radius: 4px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 700;
}

.badge-required { background: var(--danger); color: white; }
.badge-unique { background: #6f42c1; color: white; }
.badge-identifier { background: #17a2b8; color: white; }

.properties-section {
  margin-bottom: 1.5rem;
}

.properties-section h4 {
  margin-top: 0;
  margin-bottom: 1rem;
}

.property-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 1rem;
}

.property-item {
  background: var(--surface);
  padding: 0.875rem 1.125rem;
  border-radius: 6px;
  border-left: 3px solid var(--secondary);
}

.property-label {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.6px;
  color: var(--muted);
  margin-bottom: 0.375rem;
  font-weight: 600;
}

.property-value {
  font-weight: 600;
  color: var(--primary);
  font-size: 0.95rem;
  word-break: break-word;
}

.property-value.monospace {
  font-family: "SF Mono", Consolas, monospace;
  font-size: 0.875em;
  background: white;
  padding: 0.25rem 0.5rem;
  border-radius: 3px;
  display: inline-block;
}

.file-sources {
  margin-top: 1.25rem;
}

.file-sources h4 {
  margin-bottom: 0.75rem;
}

.file-badge-container {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}

.file-badge {
  background: #e8f4f8;
  border: 1px solid #3498db;
  color: #2c3e50;
  padding: 0.3rem 0.75rem;
  border-radius: 16px;
  font-size: 0.75rem;
  font-weight: 500;
}

.file-badge-more {
  background: #fff3cd;
  border: 1px solid #ffc107;
  color: #856404;
  font-weight: 600;
}

.file-list-details summary {
  padding: 6px 0;
  font-weight: 500;
  transition: color 0.2s;
}

.file-list-details summary:hover {
  color: var(--secondary) !important;
}

.file-list-details[open] summary span {
  transform: rotate(90deg);
  display: inline-block;
  transition: transform 0.2s;
}

.file-list-details summary::-webkit-details-marker {
  display: none;
}


.categorical-section {
  margin-top: 1.5rem;
}

.categorical-section h4 {
  margin-bottom: 1rem;
}

.categorical-table {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  font-size: 0.875rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}

.categorical-table thead tr {
  background: var(--surface);
}

.categorical-table th {
  padding: 0.875rem 1.125rem;
  text-align: left;
  font-weight: 700;
  color: var(--muted);
  text-transform: uppercase;
  font-size: 0.7rem;
  letter-spacing: 0.6px;
  border-bottom: 2px solid var(--border);
}

.categorical-table tbody tr {
  border-bottom: 1px solid var(--border);
  transition: background-color 0.15s;
}

.categorical-table tbody tr:last-child {
  border-bottom: none;
}

.categorical-table tbody tr:hover {
  background-color: #f8f9fa;
}

.categorical-table td {
  padding: 0.875rem 1.125rem;
  vertical-align: top;
}

.categorical-table td:first-child {
  font-family: monospace;
  font-weight: 700;
  color: var(--secondary);
  background: #f8f9fa;
  white-space: nowrap;
}

.categorical-table td:nth-child(2) {
  font-weight: 600;
}

.categorical-table td:nth-child(3) {
  color: var(--muted);
}

.stats-section {
  margin-top: 1.5rem;
  padding-top: 1.5rem;
  border-top: 2px solid var(--border);
}

.stats-section h4 {
  margin-top: 0;
  margin-bottom: 1rem;
}

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap: 0.875rem;
}

.stats-item {
  text-align: center;
  padding: 0.875rem;
  background: var(--surface);
  border-radius: 6px;
  border: 1px solid var(--border);
}

.stats-item .stats-value {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--primary);
  font-variant-numeric: tabular-nums;
}

.stats-item .stats-label {
  font-size: 0.7rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-top: 0.375rem;
  font-weight: 600;
}

.notes-section {
  margin-top: 1.5rem;
  padding: 1.125rem 1.5rem;
  background: #fffbf0;
  border-radius: 6px;
  border-left: 4px solid #ffc107;
}

.notes-section h4 {
  margin-top: 0;
  margin-bottom: 0.75rem;
  color: #856404;
}

.notes-section p {
  margin: 0;
  color: #856404;
  line-height: 1.7;
}

.document-footer {
  background: var(--surface);
  padding: 2rem;
  text-align: center;
  margin-top: 3rem;
  border-top: 2px solid var(--border);
}

.document-footer p {
  margin: 0.5rem 0;
  color: var(--muted);
  font-size: 0.875rem;
}

.generator-info {
  font-size: 0.75rem !important;
  opacity: 0.7;
}

@media print {
  body {
    font-size: 11pt;
    line-height: 1.6;
  }
  
  .document-header {
    background: var(--primary) !important;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
    padding: 1.5rem;
  }
  
  .container {
    max-width: none;
    padding: 0;
  }
  
  .toc {
    page-break-after: always;
  }
  
  .variable-card {
    box-shadow: none;
    border: 1px solid #333;
    page-break-inside: avoid;
    margin-bottom: 1.5rem;
  }
  
  .stat-card {
    border: 1px solid #333 !important;
    box-shadow: none;
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
  
  a[href]::after {
    content: none;
  }
  
  .variable-card:hover,
  .stat-card:hover {
    transform: none;
    box-shadow: none;
  }
  
  h2 {
    page-break-after: avoid;
  }
  
  .categorical-table {
    page-break-inside: avoid;
  }
}

@media (max-width: 768px) {
  .document-header { padding: 2rem 1rem; }
  .container { padding: 1.5rem 1rem; }
  .toc-list { columns: 1; }
  .overview-grid { grid-template-columns: 1fr; }
  .property-grid { grid-template-columns: 1fr; }
  .info-list li { grid-template-columns: 1fr; gap: 0.5rem; }
  .stats-grid { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); }
}

@media (max-width: 480px) {
  .document-title { font-size: 1.8rem; }
  .dataset-name { font-size: 1.1rem; }
  .variable-name { font-size: 1rem; }
}
'
}

#' Generate Table of Contents
#' @noRd
generate_toc_html <- function(dictionary_data, dataset_info) {
  html <- '<nav class="toc">
    <h2>📑 Contents</h2>
    <ol class="toc-list">
      <li><a href="#overview"><span class="toc-number">1.</span> Overview</a></li>'
  
  if (!is.null(dictionary_data$missing_values) && length(dictionary_data$missing_values) > 0) {
    html <- paste0(html, '<li><a href="#missing-values"><span class="toc-number">2.</span> Missing Values</a></li>')
  }
  
  html <- paste0(html, '<li><a href="#variables"><span class="toc-number">3.</span> Variables</a></li>')
  
  if (!is.null(dictionary_data$variables)) {
    var_names <- names(dictionary_data$variables)
    for (i in seq_along(var_names)) {
      var_id <- make_id(var_names[i])
      html <- paste0(html, '<li><a href="#var-', var_id, '"><span class="toc-number">3.', i, '</span> ', 
                     escape_html(var_names[i]), '</a></li>')
    }
  }
  
  html <- paste0(html, '</ol></nav>')
  return(html)
}

#' Generate Overview Section
#' @noRd
generate_overview_html <- function(dictionary_data, dataset_info) {
  n_vars <- length(dictionary_data$variables)
  var_types <- sapply(dictionary_data$variables, function(v) v$type %||% "unspecified")
  type_counts <- table(var_types)
  required_count <- sum(sapply(dictionary_data$variables, function(v) isTRUE(v$required)))
  categorical_count <- sum(sapply(dictionary_data$variables, function(v) 
    !is.null(v$categorical_values) && length(v$categorical_values) > 0))
  
  html <- '<section class="section" id="overview">
    <div class="section-header">
      <div class="section-icon">📊</div>
      <h2>Dataset Overview</h2>
    </div>'
  
  if (!is.null(dataset_info$description) && dataset_info$description != "") {
    html <- paste0(html, '<div class="description-box"><p>', 
                   escape_html(dataset_info$description), '</p></div>')
  }
  
  html <- paste0(html, '<div class="overview-grid">
      <div class="stat-card">
        <div class="stat-value">', n_vars, '</div>
        <div class="stat-label">Variables</div>
      </div>')
  
  if (required_count > 0) {
    html <- paste0(html, '<div class="stat-card">
        <div class="stat-value">', required_count, '</div>
        <div class="stat-label">Required</div>
      </div>')
  }
  
  if (categorical_count > 0) {
    html <- paste0(html, '<div class="stat-card">
        <div class="stat-value">', categorical_count, '</div>
        <div class="stat-label">Categorical</div>
      </div>')
  }
  
  html <- paste0(html, '<div class="stat-card">
        <div class="stat-value">', length(type_counts), '</div>
        <div class="stat-label">Data Types</div>
      </div>
    </div>')
  
  if (!is.null(dataset_info)) {
    html <- paste0(html, '<ul class="info-list">')
    if (!is.null(dataset_info$name)) {
      html <- paste0(html, '<li><span class="info-label">Dataset</span><span>', 
                     escape_html(dataset_info$name), '</span></li>')
    }
    if (!is.null(dataset_info$version)) {
      html <- paste0(html, '<li><span class="info-label">Version</span><span>', 
                     escape_html(dataset_info$version), '</span></li>')
    }
    if (!is.null(dataset_info$author) && length(dataset_info$author) > 0) {
      author_names <- sapply(dataset_info$author, function(a) {
        if (is.character(a) || is.atomic(a)) {
          as.character(a)
        } else if (!is.null(a$givenName) && !is.null(a$familyName)) {
          paste(a$givenName, a$familyName)
        } else if (!is.null(a$name)) { a$name } else { "Unknown" }
      })
      html <- paste0(html, '<li><span class="info-label">Authors</span><span>', 
                     escape_html(paste(author_names, collapse = ", ")), '</span></li>')
    }
    html <- paste0(html, '</ul>')
  }
  
  html <- paste0(html, '</section>')
  return(html)
}

#' Generate Missing Values Section
#' @noRd
generate_missing_values_html <- function(missing_values) {
  html <- '<section class="section" id="missing-values">
    <div class="section-header">
      <div class="section-icon">⚠️</div>
      <h2>Global Missing Value Codes</h2>
    </div>
    <p>These codes represent missing values across all variables:</p>
    <div class="missing-values-list">'
  
  for (mv in missing_values) {
    html <- paste0(html, '<span class="missing-value-badge">', 
                   escape_html(as.character(mv)), '</span>')
  }
  
  html <- paste0(html, '</div></section>')
  return(html)
}

#' Generate Variables Section
#' @noRd
generate_variables_html <- function(variables, include_stats, missing_values = NULL) {
  if (is.null(variables) || length(variables) == 0) {
    return('<section class="section" id="variables">
      <h2>Variable Definitions</h2>
      <p>No variables defined.</p>
    </section>')
  }
  
  html <- '<section class="section" id="variables">
    <div class="section-header">
      <div class="section-icon">📋</div>
      <h2>Variable Definitions</h2>
    </div>'
  
  var_names <- names(variables)
  for (i in seq_along(var_names)) {
    html <- paste0(html, generate_variable_card_html(var_names[i], variables[[var_names[i]]], 
                                                     i, make_id(var_names[i]), include_stats, missing_values))
  }
  
  html <- paste0(html, '</section>')
  return(html)
}

#' Generate Single Variable Card
#' @noRd
generate_variable_card_html <- function(var_name, var_info, index, var_id, include_stats, missing_values = NULL) {
  var_type <- var_info$type %||% "string"
  
  html <- paste0('<article class="variable-card" id="var-', var_id, '">
      <div class="variable-header">
        <div class="variable-name-section">
          <span class="variable-name">', escape_html(var_name), '</span>')
  
  # Display name if available
  if (!is.null(var_info$display_name) && var_info$display_name != "" && var_info$display_name != var_name) {
    html <- paste0(html, '<span class="variable-display-name">', 
                   escape_html(var_info$display_name), '</span>')
  }
  
  html <- paste0(html, '</div>
        <span class="variable-type-badge">', escape_html(var_type), '</span>
      </div>
      <div class="variable-body">')
  
  # Badges
  badges <- character()
  if (isTRUE(var_info$required)) {
    badges <- c(badges, '<span class="badge badge-required">Required</span>')
  }
  if (isTRUE(var_info$unique)) {
    badges <- c(badges, '<span class="badge badge-unique">Unique Values</span>')
  }
  # Check if it's likely an identifier
  if (grepl("\\b(id|identifier|subject|participant|session)\\b", tolower(var_name))) {
    badges <- c(badges, '<span class="badge badge-identifier">Identifier</span>')
  }
  
  if (length(badges) > 0) {
    html <- paste0(html, '<div class="badge-container">', paste(badges, collapse = ""), '</div>')
  }
  
  # Description
  if (!is.null(var_info$description) && var_info$description != "") {
    html <- paste0(html, '<p class="variable-description">', 
                   escape_html(var_info$description), '</p>')
  }
  
  # File sources (if available)
  if (!is.null(var_info$files) && length(var_info$files) > 0) {
    n_files <- length(var_info$files)
    
    html <- paste0(html, '<div class="file-sources">
          <h4>Appears in Files</h4>')
    
    # If 5 or fewer files, show all as badges
    if (n_files <= 5) {
      html <- paste0(html, '<div class="file-badge-container">')
      for (f in var_info$files) {
        html <- paste0(html, '<span class="file-badge">', escape_html(basename(f)), '</span>')
      }
      html <- paste0(html, '</div>')
    } else {
      # If more than 5 files, show first 3 as badges plus count, then compact list
      html <- paste0(html, '<div class="file-badge-container">')
      for (i in 1:3) {
        html <- paste0(html, '<span class="file-badge">', escape_html(basename(var_info$files[i])), '</span>')
      }
      html <- paste0(html, '<span class="file-badge file-badge-more">+ ', (n_files - 3), ' more</span>')
      html <- paste0(html, '</div>')
      
      # Show complete list in compact format
      html <- paste0(html, '<details class="file-list-details" style="margin-top: 8px;">
            <summary style="cursor: pointer; color: #6c757d; font-size: 0.85rem; user-select: none;">
              <span style="color: #3498db;">▸</span> Show all ', n_files, ' files
            </summary>
            <div style="margin-top: 8px; padding: 10px; background-color: #f8f9fa; border-radius: 4px; font-size: 0.85rem; max-height: 200px; overflow-y: auto;">
              ', paste(sapply(var_info$files, function(f) paste0('<span style="display: block; padding: 2px 0;">', escape_html(basename(f)), '</span>')), collapse = ''), '
            </div>
          </details>')
    }
    
    html <- paste0(html, '</div>')
  }
  
  # Properties section
  props <- list()
  if (!is.null(var_info$unit) && var_info$unit != "") {
    props[["Unit of Measurement"]] <- var_info$unit
  }
  if (!is.null(var_info$source) && var_info$source != "") {
    props[["Data Source/Instrument"]] <- var_info$source
  }
  if (!is.null(var_info$measurement_technique) && var_info$measurement_technique != "") {
    props[["Measurement Technique"]] <- var_info$measurement_technique
  }
  if (!is.null(var_info$min_value) && var_info$min_value != "") {
    props[["Minimum Value"]] <- list(v = var_info$min_value, mono = TRUE)
  }
  if (!is.null(var_info$max_value) && var_info$max_value != "") {
    props[["Maximum Value"]] <- list(v = var_info$max_value, mono = TRUE)
  }
  if (!is.null(var_info$pattern) && var_info$pattern != "") {
    props[["Required Pattern"]] <- list(v = var_info$pattern, mono = TRUE)
  }
  if (!is.null(var_info$default_value) && var_info$default_value != "") {
    props[["Default Value"]] <- list(v = var_info$default_value, mono = TRUE)
  }
  
  if (length(props) > 0) {
    html <- paste0(html, '<div class="properties-section">
          <h4>Properties & Constraints</h4>
          <div class="property-grid">')
    
    for (pn in names(props)) {
      pv <- props[[pn]]
      if (is.list(pv)) {
        val <- escape_html(as.character(pv$v))
        cls <- if (isTRUE(pv$mono)) ' monospace' else ''
      } else {
        val <- escape_html(as.character(pv))
        cls <- ''
      }
      html <- paste0(html, '<div class="property-item">
            <div class="property-label">', escape_html(pn), '</div>
            <div class="property-value', cls, '">', val, '</div>
          </div>')
    }
    html <- paste0(html, '</div></div>')
  }
  
  # Categorical values (skip for boolean - true/false is implied by the type)
  # Also filter out any values that are defined as missing values
  if (!is.null(var_info$categorical_values) && 
      length(var_info$categorical_values) > 0 && 
      var_type != "boolean") {
    
    # Filter out categorical values that match missing value codes
    filtered_values <- var_info$categorical_values
    if (!is.null(missing_values) && length(missing_values) > 0) {
      filtered_values <- Filter(function(cv) {
        val <- as.character(cv$value %||% "")
        # Check if this value is NOT in the missing values list
        !(val %in% missing_values)
      }, var_info$categorical_values)
    }
    
    # Only show categorical values section if there are non-missing values
    if (length(filtered_values) > 0) {
      html <- paste0(html, '<div class="categorical-section">
          <h4>Allowed Values</h4>
          <table class="categorical-table">
            <thead>
              <tr>
                <th>Value</th>
                <th>Label</th>
                <th>Description</th>
              </tr>
            </thead>
            <tbody>')
      
      for (cv in filtered_values) {
        v <- cv$value %||% ""
        l <- cv$label %||% v
        d <- cv$description %||% ""
        html <- paste0(html, '<tr>
              <td>', escape_html(as.character(v)), '</td>
              <td>', escape_html(as.character(l)), '</td>
              <td>', escape_html(as.character(d)), '</td>
            </tr>')
      }
      html <- paste0(html, '</tbody></table></div>')
    }
  }
  
  # Statistics
  if (include_stats && !is.null(var_info$statistics)) {
    s <- var_info$statistics
    html <- paste0(html, '<div class="stats-section">
          <h4>Summary Statistics</h4>
          <div class="stats-grid">')
    
    if (!is.null(s$n)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', s$n, '</div>
            <div class="stats-label">N</div>
          </div>')
    }
    if (!is.null(s$missing)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', s$missing, '</div>
            <div class="stats-label">Missing</div>
          </div>')
    }
    if (!is.null(s$mean)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', round(s$mean, 2), '</div>
            <div class="stats-label">Mean</div>
          </div>')
    }
    if (!is.null(s$sd)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', round(s$sd, 2), '</div>
            <div class="stats-label">SD</div>
          </div>')
    }
    if (!is.null(s$median)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', round(s$median, 2), '</div>
            <div class="stats-label">Median</div>
          </div>')
    }
    if (!is.null(s$min)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', s$min, '</div>
            <div class="stats-label">Min</div>
          </div>')
    }
    if (!is.null(s$max)) {
      html <- paste0(html, '<div class="stats-item">
            <div class="stats-value">', s$max, '</div>
            <div class="stats-label">Max</div>
          </div>')
    }
    
    html <- paste0(html, '</div></div>')
  }
  
  # Notes
  if (!is.null(var_info$notes) && var_info$notes != "") {
    html <- paste0(html, '<div class="notes-section">
          <h4>Additional Notes</h4>
          <p>', escape_html(var_info$notes), '</p>
        </div>')
  }
  
  html <- paste0(html, '</div></article>')
  return(html)
}

#' Escape HTML
#' @noRd
escape_html <- function(text) {
  if (is.null(text) || length(text) == 0) return("")
  text <- as.character(text)
  text <- gsub("&", "&amp;", text, fixed = TRUE)
  text <- gsub("<", "&lt;", text, fixed = TRUE)
  text <- gsub(">", "&gt;", text, fixed = TRUE)
  text <- gsub('"', "&quot;", text, fixed = TRUE)
  text <- gsub("'", "&#39;", text, fixed = TRUE)
  return(text)
}

#' Make valid HTML ID
#' @noRd
make_id <- function(text) {
  id <- tolower(text)
  id <- gsub("[^a-z0-9]+", "-", id)
  id <- gsub("^-|-$", "", id)
  if (grepl("^[0-9]", id)) id <- paste0("v-", id)
  return(id)
}

#' Main entry point for dictionary generation
#' @param dictionary_data The dictionary data
#' @param dataset_info Dataset metadata  
#' @param output_file Output file path
#' @param include_stats Include statistics
#' @param format Ignored (always HTML)
#' @return List with status and file path
generate_dictionary_html <- function(dictionary_data, 
                                     dataset_info = NULL,
                                     output_file = NULL,
                                     include_stats = TRUE,
                                     format = "html") {
  
  if (is.null(output_file)) {
    output_file <- paste0("data_dictionary_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".html")
  }
  
  if (!grepl("\\.html$", output_file, ignore.case = TRUE)) {
    output_file <- gsub("\\.[^.]+$", ".html", output_file)
    if (!grepl("\\.html$", output_file)) output_file <- paste0(output_file, ".html")
  }
  
  result <- tryCatch({
    generated_file <- generate_html_dictionary(
      dictionary_data = dictionary_data,
      dataset_info = dataset_info,
      output_file = output_file,
      include_stats = include_stats
    )
    list(success = TRUE, file = generated_file, format = "html", method = "HTML Document")
  }, error = function(e) {
    list(success = FALSE, error = e$message, format = "html", method = NULL)
  })
  
  return(result)
}



#' List Data Files (CSV and TSV)
#'
#' Lists all CSV and TSV files in a directory, returning relative paths
#'
#' @param dir_path Path to directory
#' @return Character vector of relative file paths
list_data_files <- function(dir_path) {
  if (is.null(dir_path) || dir_path == "" || !dir.exists(dir_path)) {
    return(character(0))
  }
  # CSV/TSV recursively. Use fs (faster classification via readdir d_type,
  # avoids a stat() per entry) with a case-insensitive extension regexp;
  # fall back to base list.files() if fs is unavailable.
  root <- path.expand(dir_path)
  pat  <- "\\.([cC][sS][vV]|[tT][sS][vV])$"
  tryCatch(
    {
      if (requireNamespace("fs", quietly = TRUE)) {
        hits <- fs::dir_ls(root, recurse = TRUE, type = "file",
                           regexp = pat, fail = FALSE)
        as.character(fs::path_rel(hits, start = root))
      } else {
        list.files(dir_path, pattern = "\\.(csv|tsv)$", recursive = TRUE,
                   full.names = FALSE, ignore.case = TRUE)
      }
    },
    error = function(e) character(0)
  )
}

#' Organize Directory Hierarchy
#'
#' Organizes a flat list of file paths into a hierarchical structure
#'
#' @param files Character vector of file paths (relative paths with / separators)
#' @return Named list representing directory hierarchy
organize_directory_hierarchy <- function(files) {
  if (length(files) == 0) {
    return(list())
  }
  
  hierarchy <- list()
  
  # Process each file
  for (file_path in files) {
    # Skip if empty
    if (is.null(file_path) || file_path == "") next
    
    # Normalize path separators to forward slashes
    file_path <- gsub("\\\\", "/", file_path)
    
    # Split into directory components
    parts <- strsplit(file_path, "/")[[1]]
    
    # If file is in a subdirectory
    if (length(parts) > 1) {
      # Build directory path progressively
      current_path <- ""
      for (i in 1:(length(parts) - 1)) {
        if (i == 1) {
          current_path <- parts[i]
        } else {
          current_path <- paste(current_path, parts[i], sep = "/")
        }
        
        # Add directory to hierarchy if not already present
        if (!current_path %in% names(hierarchy)) {
          hierarchy[[current_path]] <- list(
            name = parts[i],
            level = i - 1,
            is_file = FALSE,
            files = character(0)
          )
        }
      }
      
      # Add file to its parent directory
      parent_dir <- paste(parts[1:(length(parts) - 1)], collapse = "/")
      if (parent_dir %in% names(hierarchy)) {
        hierarchy[[parent_dir]]$files <- c(
          hierarchy[[parent_dir]]$files,
          parts[length(parts)]
        )
      }
    }
  }
  
  return(hierarchy)
}

#' File Browser Server Module - Fixed Version
#'
#' Handles file listing and selection
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param dir_path Reactive expression that returns the directory path
#' @param session The current session object
fileBrowserServer <- function(id, state, dir_path, session) {
  moduleServer(id, function(input, output, session) {
    # Store selected files
    selected <- reactiveVal(character(0))

    # Cache the recursive scan: runs once per directory and is reused by every
    # handler and the renderUI, so clicking a file never re-walks the disk.
    files_r <- reactive({
      d <- dir_path()
      if (is.null(d) || d == "") return(character(0))
      list_data_files(d)
    })
    hierarchy_r <- reactive({
      organize_directory_hierarchy(files_r())
    })
    
    # Track the last directory to detect changes
    last_dir <- reactiveVal(NULL)
    restored <- reactiveVal(FALSE)  # Track if we've restored from state
    
    # Detect when directory changes and clear selection
    observe({
      current_dir <- dir_path()
      
      # Skip if no directory yet
      if (is.null(current_dir) || current_dir == "") {
        return()
      }
      
      # Check if directory has changed
      last <- last_dir()
      dir_changed <- is.null(last) || current_dir != last
      
      if (dir_changed && !is.null(last)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("FILE BROWSER: Directory changed from '", last, "' to '", current_dir, "' - clearing selection")
        }
        
        # Clear selection
        selected(character(0))
        
        # Update global state to reflect cleared selection
        state$data_files <- character(0)
        
        # Reset restored flag
        restored(FALSE)
      } else if (!dir_changed && !restored() && !is.null(state$data_files) && length(state$data_files) > 0) {
        # Same directory and haven't restored yet - restore selection from state
        if (isTRUE(getOption("psychds.verbose"))) {
          message("FILE BROWSER: Restoring selection from state: ", length(state$data_files), " files")
        }
        selected(state$data_files)
        restored(TRUE)
      }
      
      # Update last directory
      last_dir(current_dir)
    })

    # Handle file selection
    observeEvent(input$toggle_file, {
      file <- input$toggle_file
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Toggle file: ", file)
      }

      current <- selected()
      if (file %in% current) {
        selected(setdiff(current, file))
      } else {
        selected(c(current, file))
      }

      # Update global state
      state$data_files <- selected()
    })

    # Handle directory selection
    observeEvent(input$select_dir, {
      dir_prefix <- input$select_dir
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Select directory: ", dir_prefix)
      }

      # Get all files
      all_files <- all_files <- files_r()

      # Find files in this directory
      dir_files <- all_files[startsWith(all_files, dir_prefix)]

      # Get current selection
      current <- selected()

      # Check if all directory files are already selected
      if (all(dir_files %in% current)) {
        # Deselect all files in this directory
        selected(setdiff(current, dir_files))
      } else {
        # Select all files in this directory
        selected(unique(c(current, dir_files)))
      }

      # Update global state
      state$data_files <- selected()
    })
    
    # Handle Select All button
    observeEvent(input$select_all, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Select all files clicked")
      }
      
      # Get all files in the current directory
      all_files <- all_files <- files_r()
      
      # Select all files
      selected(all_files)
      
      # Update global state
      state$data_files <- selected()
    })
    
    # Handle Deselect All button
    observeEvent(input$deselect_all, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Deselect all files clicked")
      }
      
      # Clear selection
      selected(character(0))
      
      # Update global state
      state$data_files <- selected()
    })

    # Render the file browser (cached scan + O(n) build)
    output$file_container <- renderUI({
      current_dir <- dir_path()
      if (is.null(current_dir) || current_dir == "") {
        return(div(
          style = "text-align: center; padding-top: 30px; color: #999;",
          "Please select a project directory first"
        ))
      }

      files <- files_r()
      if (length(files) == 0) {
        return(div(
          style = "text-align: center; padding-top: 30px; color: #999;",
          "No CSV or TSV files found in this directory"
        ))
      }

      hierarchy <- hierarchy_r()
      dir_paths <- names(hierarchy)
      if (length(dir_paths) > 0) {
        dir_paths <- dir_paths[order(
          sapply(dir_paths, function(d) length(strsplit(d, "/")[[1]])),
          dir_paths
        )]
      }

      current_selection <- selected()
      num_selected <- length(current_selection)
      ns <- session$ns

      # One block per directory (header + its files), built with lapply (O(n)).
      dir_blocks <- lapply(dir_paths, function(dp) {
        dir_info <- hierarchy[[dp]]
        if (isTRUE(dir_info$is_file)) return(NULL)

        dir_indentation <- paste0(rep("&nbsp;", dir_info$level * 3), collapse = "")
        dir_file_paths <- if (length(dir_info$files) > 0) {
          paste0(dp, "/", dir_info$files)
        } else {
          character(0)
        }
        dir_selected <- any(dir_file_paths %in% current_selection)
        all_selected <- length(dir_file_paths) > 0 &&
          all(dir_file_paths %in% current_selection)

        header <- div(
          style = paste0(
            "margin-top: 5px; font-weight: bold; cursor: pointer; ",
            if (all_selected) "color: #2196F3;" else if (dir_selected) "color: #64B5F6;" else ""
          ),
          HTML(paste0(dir_indentation, dir_info$name, "/")),
          onclick = paste0("Shiny.setInputValue('", ns("select_dir"), "', '",
                           dp, "', {priority: 'event'})")
        )

        file_rows <- if (length(dir_info$files) > 0) {
          lapply(sort(dir_info$files), function(filename) {
            file_path <- paste0(dp, "/", filename)
            is_selected <- file_path %in% current_selection
            div(
              style = paste0(
                "padding: 2px 5px; margin: 1px 0; cursor: pointer; ",
                if (is_selected) "background-color: #e3f2fd; border-radius: 3px;" else ""
              ),
              HTML(paste0(dir_indentation, "&nbsp;&nbsp;&nbsp;", filename)),
              onclick = paste0("Shiny.setInputValue('", ns("toggle_file"), "', '",
                               file_path, "', {priority: 'event'})")
            )
          })
        } else {
          list()
        }

        tagList(header, file_rows)
      })
      dir_blocks <- Filter(Negate(is.null), dir_blocks)

      # Root-level files (no "/" in the relative path).
      root_files <- sort(files[!grepl("/", files)])
      root_block <- list()
      if (length(root_files) > 0) {
        if (length(dir_paths) > 0) {
          root_block <- c(root_block, list(
            div(style = "margin-top: 8px; font-weight: bold;", "Root files:")
          ))
        }
        root_rows <- lapply(root_files, function(file) {
          is_selected <- file %in% current_selection
          div(
            style = paste0(
              "padding: 2px 5px; margin: 1px 0; cursor: pointer; ",
              if (is_selected) "background-color: #e3f2fd; border-radius: 3px;" else ""
            ),
            HTML(if (length(dir_paths) > 0) paste0("&nbsp;&nbsp;&nbsp;", file) else file),
            onclick = paste0("Shiny.setInputValue('", ns("toggle_file"), "', '",
                             file, "', {priority: 'event'})")
          )
        })
        root_block <- c(root_block, root_rows)
      }

      count_div <- div(
        style = "color: #999; font-size: 12px; margin-bottom: 10px;",
        paste0("Found ", length(files), " data files, selected ", num_selected)
      )

      do.call(tagList, c(list(count_div), dir_blocks, root_block))
    })

    # Return selected files
    return(selected)
  })
}

#' Optional Directories Server Module
#'
#' Handles selection and customization of optional directories
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param session The current session object
optionalDirsServer <- function(id, state, session) {
  moduleServer(id, function(input, output, session) {
    # Store custom directories locally
    custom_dirs <- reactiveVal(list())

    # Handle adding custom directories
    observeEvent(input$add, {
      if (input$custom_name != "") {
        # Format directory name
        custom_dir <- input$custom_name
        if (!grepl("/$", custom_dir)) {
          custom_dir <- paste0(custom_dir, "/")
        }

        # Check if directory already exists
        current <- custom_dirs()
        dir_names <- sapply(current, function(x) x$name)

        if (custom_dir %in% dir_names) {
          showNotification("This directory has already been added", type = "warning")
          return()
        }

        # Create unique ID for checkbox
        dir_id <- generate_id("dir")

        # Add to list of custom directories
        new_dir <- list(
          id = dir_id,
          name = custom_dir,
          selected = TRUE
        )

        custom_dirs(c(current, list(new_dir)))

        # Add checkbox to UI
        insertUI(
          selector = paste0("#", session$ns("container")),
          where = "beforeEnd",
          ui = checkboxInput(
            session$ns(dir_id),
            custom_dir,
            value = TRUE
          )
        )

        # Clear the input
        updateTextInput(session, "custom_name", value = "")

        # Update global state
        updateGlobalState()
      }
    })

    # Update standard directory selections in global state
    observe({
      standard_dirs <- list(
        analysis = input$dir_analysis,
        materials = input$dir_materials,
        results = input$dir_results,
        products = input$dir_products,
        documentation = input$dir_documentation
      )

      # Update global state for standard directories
      for (dir_name in names(standard_dirs)) {
        state$optional_dirs[[dir_name]] <- standard_dirs[[dir_name]]
      }
    })

    # Update custom directory selections when checkboxes change
    observe({
      # Get the current custom directories
      current <- custom_dirs()

      # Update selected status for each directory
      for (i in seq_along(current)) {
        dir_id <- current[[i]]$id
        if (exists(dir_id, where = input)) {
          current[[i]]$selected <- input[[dir_id]]
        }
      }

      # Update local state
      custom_dirs(current)

      # Update global state
      updateGlobalState()
    })

    # Update global state with custom directories
    updateGlobalState <- function() {
      current <- custom_dirs()

      # Filter to only selected directories and create character vector
      selected_dirs <- character(0)

      if (length(current) > 0) {
        for (i in seq_along(current)) {
          if (current[[i]]$selected) {
            # Strip trailing slash
            dir_name <- gsub("/$", "", current[[i]]$name)
            selected_dirs <- c(selected_dirs, dir_name)
          }
        }
      }

      # Update global state with character vector
      state$optional_dirs$custom <- selected_dirs
    }

    # Return reactive that provides all selected directories
    return(reactive({
      all_dirs <- c()

      # Add standard directories
      if (input$dir_analysis) all_dirs <- c(all_dirs, "analysis")
      if (input$dir_materials) all_dirs <- c(all_dirs, "materials")
      if (input$dir_results) all_dirs <- c(all_dirs, "results")
      if (input$dir_products) all_dirs <- c(all_dirs, "products")
      if (input$dir_documentation) all_dirs <- c(all_dirs, "documentation")

      # Add custom directories
      current <- custom_dirs()

      if (length(current) > 0) {
        selected_custom <- character(0)

        for (i in seq_along(current)) {
          if (current[[i]]$selected) {
            dir_name <- gsub("/$", "", current[[i]]$name)
            selected_custom <- c(selected_custom, dir_name)
          }
        }

        all_dirs <- c(all_dirs, selected_custom)
      }

      return(all_dirs)
    }))
  })
}

#' Step 1 Server Module - Fixed Version
#'
#' Handles project directory and data file selection
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param session The current session object
step1Server <- function(id, state, session) {
  # Store root session for accessing global inputs
  root_session <- session
  
  moduleServer(id, function(input, output, session) {
    # Initialize directory input handler
    dir_path <- directoryInputServer("project_dir", state, session)

    # Log directory path changes for debugging
    observe({
      path <- dir_path()
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Step1: Directory path updated to: ", path)
      }
    })
    
    # Restore inputs from state when returning to step 1
    observe({
      # Only run when we're on step 1
      req(state$current_step == 1)
      
      # Restore project name from state if available
      if (!is.null(state$project_name) && state$project_name != "") {
        updateTextInput(session, "project_name", value = state$project_name)
      }
    })
    
    # Save project_name to state as user types
    observeEvent(input$project_name, {
      if (!is.null(input$project_name)) {
        state$project_name <- input$project_name
      }
    })
    
    # Handle sidebar step navigation (using root session input)
    observeEvent(root_session$input$sidebar_goto_step, {
      target_step <- root_session$input$sidebar_goto_step
      if (!is.null(target_step) && target_step >= 1 && target_step <= 3) {
        state$current_step <- target_step
        # Update sidebar indicators
        root_session$sendCustomMessage("updateSidebarStep", list(step = target_step))
      }
    }, ignoreInit = TRUE)

    # Initialize file browser with the reactive directory path
    selected_files <- fileBrowserServer("files", state, dir_path, session)

    # Initialize optional directories
    selected_dirs <- optionalDirsServer("opt_dirs", state, session)

    # Handle Continue button
    observeEvent(input$continue, {
      # Check if project name is provided
      if (is.null(input$project_name) || trimws(input$project_name) == "") {
        showModal(modalDialog(
          title = "Missing Information",
          "Please enter a name for your project directory.",
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
      # Check if a valid directory is selected
      } else if (is.null(dir_path()) || dir_path() == "") {
        showModal(modalDialog(
          title = "Error",
          "Please select a data directory.",
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
      } else if (length(selected_files()) == 0) {
        showModal(modalDialog(
          title = "Warning",
          "No data files are selected. Do you want to continue anyway?",
          easyClose = TRUE,
          footer = tagList(
            modalButton("Cancel"),
            actionButton(session$ns("continue_no_files"), "Continue Anyway", class = "btn-warning")
          )
        ))
      } else {
        # Proceed to next step
        proceedToNextStep()
      }
    })

    # Handle continuing with no files
    observeEvent(input$continue_no_files, {
      # Double-check project name is still valid
      if (is.null(input$project_name) || trimws(input$project_name) == "") {
        showModal(modalDialog(
          title = "Missing Information",
          "Please enter a name for your project directory.",
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
        return()
      }
      removeModal()
      proceedToNextStep()
    })

    # Function to proceed to next step
    proceedToNextStep <- function() {
      # Update global state with current selections
      state$project_dir <- dir_path()
      state$data_files <- selected_files()
      state$project_name <- input$project_name  # Save project name to state

      # Save optional directories to state
      state$optional_dirs$directories <- selected_dirs()

      # Show summary before proceeding
      dirs <- selected_dirs()
      
      # Get project name for display (use placeholder if empty)
      project_name_display <- if (!is.null(input$project_name) && input$project_name != "") {
        input$project_name
      } else {
        "(not yet named)"
      }

      # Create a nicer display for files
      files_html <- if(length(state$data_files) > 0) {
        tagList(
          div(
            style = "max-height: 200px; overflow-y: auto; border: 1px solid #ddd; padding: 8px; margin-top: 5px; background-color: #f8f9fa;",
            lapply(state$data_files, function(file) {
              div(
                style = "padding: 4px 0; border-bottom: 1px solid #eee;",
                icon("file"),
                span(style = "margin-left: 5px;", file)
              )
            })
          )
        )
      } else {
        "None"
      }

      # Create a nicer display for directories
      dirs_html <- if(length(dirs) > 0) {
        tagList(
          div(
            style = "max-height: 150px; overflow-y: auto; border: 1px solid #ddd; padding: 8px; margin-top: 5px; background-color: #f8f9fa;",
            lapply(dirs, function(dir) {
              div(
                style = "padding: 4px 0; border-bottom: 1px solid #eee;",
                icon("folder"),
                span(style = "margin-left: 5px;", dir)
              )
            })
          )
        )
      } else {
        "None"
      }

      showModal(modalDialog(
        title = "Dataset Creation - Step 1 Complete",
        div(
          # Project Folder section
          strong("Project Folder:"),
          div(
            style = "margin: 5px 0 15px 0; padding: 8px; background-color: #f8f9fa; border: 1px solid #ddd; border-radius: 4px;",
            icon("folder", style = "color: #f0ad4e; margin-right: 8px;"),
            span(style = "font-weight: 500;", project_name_display)
          ),
          
          # Selected Files section
          strong("Selected Files:"),
          div(
            style = "color: #666; font-size: 12px; margin-top: 3px;",
            "Chosen from: ", tags$code(state$project_dir)
          ),
          files_html,
          
          # Optional Directories section
          div(style = "margin-top: 15px;", strong("Optional Directories:")),
          dirs_html
        ),
        # Valid sizes are "m" (default), "s", "l", or "xl"
        size = "l",
        easyClose = TRUE,
        footer = tagList(
          modalButton("Cancel"),
          actionButton(session$ns("proceed_step2"), "Proceed to Step 2", class = "btn-primary")
        )
      ))
    }

    # Handle proceeding to step 2
    observeEvent(input$proceed_step2, {
      # Move to step 2
      removeModal()
      state$current_step <- 2
      
      # Update sidebar step indicators
      session$sendCustomMessage("updateSidebarStep", list(step = 2))

      # Create data dictionary templates for selected files
      if (length(state$data_files) > 0) {
        # Only create new data dictionary if it doesn't exist yet
        if (is.null(state$data_dict) || length(state$data_dict) == 0) {
          data_dict <- list()
          for (file in state$data_files) {
            # Create full path to file
            file_path <- file.path(state$project_dir, file)

            # Extract column information if file exists
            if (file.exists(file_path)) {
              data_dict[[file]] <- extract_data_structure(file_path)
            } else {
              # Create empty data dictionary if file doesn't exist yet
              data_dict[[file]] <- data.frame(
                name = character(0),
                type = character(0),
                description = character(0),
                stringsAsFactors = FALSE
              )
            }
          }

          # Update global state
          state$data_dict <- data_dict
        }
      }
    })
  })
}

#' Step 2 Server Module
#'
#' Handles data dictionary creation and editing
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param session The current session object
step2Server <- function(id, state, session) {
  moduleServer(id, function(input, output, session) {
    # Initialize reactive values for authors
    authors <- reactiveVal(list())
    detected_variables <- reactiveVal(data.frame())

    observe({
      # Adding this dependency ensures it runs when you return to this step
      step <- state$current_step

      # Initialize dataset name from state
      if (!is.null(state$dataset_info$name)) {
        updateTextInput(session, "dataset_name", value = state$dataset_info$name)
      }

      # Initialize dataset description from state
      if (!is.null(state$dataset_info$description)) {
        updateTextAreaInput(session, "dataset_description", value = state$dataset_info$description)
      }

      # Initialize authors from state if available
      if (length(authors()) == 0 && !is.null(state$dataset_info$authors) &&
          length(state$dataset_info$authors) > 0) {
        authors(state$dataset_info$authors)
      }
    })

    # Store input values immediately when they change
    observeEvent(input$dataset_name, {
      if (!is.null(input$dataset_name)) {
        state$dataset_info$name <- input$dataset_name
      }
    })

    observeEvent(input$dataset_description, {
      if (!is.null(input$dataset_description)) {
        state$dataset_info$description <- input$dataset_description
      }
    })

    # Store authors in state when they change
    observe({
      if (length(authors()) > 0) {
        state$dataset_info$authors <- authors()
      }
    })
    

    # On initialization, analyze selected files
    observe({
      req(state$project_dir)
      req(length(state$data_files) > 0)

      # Extract variable information from selected files
      variables_df <- extractVariableInfo(state$project_dir, state$data_files)

      # Only set if we have actual data
      if (!is.null(variables_df) && nrow(variables_df) > 0) {
        detected_variables(variables_df)
      }
    })

    

    # Extract variable information from files
    extractVariableInfo <- function(project_dir, file_paths) {
      # Initialize results data frame
      result <- data.frame(
        variable = character(),
        present_in = character(),
        stringsAsFactors = FALSE
      )

      # Track variables across files
      all_vars <- list()

      # Process each file
      for (file_path in file_paths) {
        full_path <- file.path(project_dir, file_path)

        if (file.exists(full_path)) {
          # Read column names from data file (CSV or TSV)
          tryCatch({
            # Read just the header row
            header <- colnames(read_data_file(full_path, nrows = 1))

            # Add to the variable tracking
            for (var_name in header) {
              if (var_name %in% names(all_vars)) {
                all_vars[[var_name]] <- c(all_vars[[var_name]], file_path)  # Use full relative path
              } else {
                all_vars[[var_name]] <- file_path  # Use full relative path
              }
            }
          }, error = function(e) {
            # Handle errors reading the file
            warning(paste("Error reading", file_path, ":", e$message))
          })
        }
      }

      # Convert to data frame
      for (var_name in names(all_vars)) {
        result <- rbind(result, data.frame(
          variable = var_name,
          present_in = paste(all_vars[[var_name]], collapse = ", "),
          stringsAsFactors = FALSE
        ))
      }

      # Sort alphabetically by variable name
      if (nrow(result) > 0) {
        result <- result[order(result$variable), ]
      }

      return(result)
    }

    # Render the variables table
    output$variables_table <- DT::renderDataTable({
      vars_df <- detected_variables()

      if (is.null(vars_df) || nrow(vars_df) == 0) {
        return(DT::datatable(
          data.frame(
            Variable = "No variables detected",
            `Present In` = "Please select data files in Step 1",
            check.names = FALSE
          ),
          options = list(
            dom = 't',
            paging = FALSE,
            searching = FALSE,
            info = FALSE
          ),
          rownames = FALSE
        ))
      }

      # Create formatted file lists with scrollable container
      vars_df$present_in <- sapply(vars_df$present_in, function(files_str) {
        files <- strsplit(files_str, ", ")[[1]]
        file_divs <- character(length(files))

        for (i in seq_along(files)) {
          file_divs[i] <- sprintf('<div>%s</div>', files[i])
        }

        # Create scrollable container with CSS class
        paste0('<div class="file-list-scrollable">',
               paste(file_divs, collapse = ""),
               '</div>')
      })

      DT::datatable(
        vars_df,
        options = list(
          pageLength = 5,
          lengthMenu = c(5, 10, 15),
          scrollX = TRUE,
          dom = "ftip",
          columnDefs = list(
            list(className = 'dt-left', targets = "_all")
          )
        ),
        rownames = FALSE,
        selection = "none",
        escape = FALSE  # Important: allows HTML in cells
      )
    })

    # Render author list
    output$author_list <- renderUI({
      author_list <- authors()

      if (length(author_list) == 0) {
        return(
          div(
            style = "padding: 10px; text-align: center; color: #666;",
            "No authors added yet. Click 'Add New Author' below."
          )
        )
      }

      # Header row with columns for first/last name
      tagList(
        div(
          style = "display: flex; background-color: #f8f9fa; padding: 5px; border-bottom: 1px solid #ced4da;",
          div(style = "flex: 1;", strong("Given Name")),
          div(style = "flex: 1;", strong("Family Name")),
          div(style = "flex: 1;", strong("ORCID ID")),
          div(style = "flex: 0; width: 40px;", "")
        ),
        # Author rows
        lapply(seq_along(author_list), function(i) {
          author <- author_list[[i]]
          div(
            style = "display: flex; padding: 5px; border-bottom: 1px solid #ced4da;",
            div(
              style = "flex: 1;",
              author$first_name
            ),
            div(
              style = "flex: 1;",
              author$last_name
            ),
            div(
              style = "flex: 1;",
              author$orcid
            ),
            div(
              style = "flex: 0; width: 40px;",
              actionButton(
                inputId = session$ns(paste0("remove_author_", i)),
                label = NULL,
                icon = icon("trash"),
                class = "btn-sm btn-danger",
                style = "padding: 2px 6px;"
              )
            )
          )
        })
      )
    })

    # Handle adding a new author
    observeEvent(input$add_author, {
      showModal(modalDialog(
        title = "Add Author",
        # Stack fields vertically instead of flex layout
        div(
          textInput(session$ns("new_author_first"), "Given Name", ""),
          textInput(session$ns("new_author_last"), "Family Name", ""),
          textInput(session$ns("new_author_orcid"), "ORCID ID (optional)",
                    placeholder = "e.g., 0000-0002-1825-0097")
        ),
        footer = tagList(
          modalButton("Cancel"),
          actionButton(session$ns("save_new_author"), "Add", class = "btn-primary")
        ),
        easyClose = TRUE
      ))
    })

    # Save new author
    observeEvent(input$save_new_author, {
      if (input$new_author_first != "" && input$new_author_last != "") {
        current_authors <- authors()

        # Add new author with separate first and last name
        current_authors[[length(current_authors) + 1]] <- list(
          first_name = input$new_author_first,
          last_name = input$new_author_last,
          orcid = input$new_author_orcid
        )

        # Update authors
        authors(current_authors)

        # Also update state immediately
        state$dataset_info$authors <- current_authors

        # Close modal
        removeModal()
      } else {
        showNotification("First and last name are required", type = "error")
      }
    })

    # Handle removing authors
    observe({
      author_list <- authors()

      for (i in seq_along(author_list)) {
        local({
          local_i <- i
          observeEvent(input[[paste0("remove_author_", local_i)]], {
            current_authors <- authors()

            # Remove the author at the specified index
            if (local_i <= length(current_authors)) {
              current_authors <- current_authors[-local_i]
              authors(current_authors)

              # Also update state immediately
              state$dataset_info$authors <- current_authors
            }
          })
        })
      }
    })

    # Handle Back button
    observeEvent(input$back, {

      # Confirm going back
      showModal(modalDialog(
        title = "Go Back to Step 1?",
        "Are you sure you want to go back to Step 1? Your progress in Step 2 will be saved.",
        easyClose = TRUE,
        footer = tagList(
          modalButton("Cancel"),
          actionButton(session$ns("confirm_back"), "Go Back", class = "btn-warning")
        )
      ))
    })

    # Handle confirmed back action
    observeEvent(input$confirm_back, {
      removeModal()
      state$current_step <- 1
      
      # Update sidebar step indicators
      session$sendCustomMessage("updateSidebarStep", list(step = 1))
    })

    # Handle Continue button
    observeEvent(input$continue, {
      # Validate inputs
      if (is.null(input$dataset_name) || input$dataset_name == "") {
        showModal(modalDialog(
          title = "Missing Information",
          "Please enter a dataset name.",
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
        return()
      }

      if (is.null(input$dataset_description) || input$dataset_description == "") {
        showModal(modalDialog(
          title = "Missing Information",
          "Please enter a dataset description.",
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
        return()
      }

      # Create JSON preview
      json_preview <- createJsonPreview()

      # Show JSON preview modal
      showModal(modalDialog(
        title = "Dataset Metadata Preview",
        div(
          # All explanatory text above the preview
          p("Here's a preview of the metadata that will be generated. After Step 3, this information will be saved as a text file in your project directory called ", tags$code("dataset_description.json"), "."),
          
          # Use a pre tag for better code display
          tags$pre(
            class = "json-preview",
            style = "max-height: 400px; overflow-y: auto;",
            HTML(json_preview)
          )
        ),
        size = "l",
        easyClose = TRUE,
        footer = tagList(
          modalButton("Back"),
          actionButton(session$ns("confirm_continue"), "Confirm and Continue", class = "btn-primary")
        )
      ))
    })

    # Handle confirmed continue action
    observeEvent(input$confirm_continue, {
      removeModal()
      # Move to step 3
      state$current_step <- 3
      
      # Update sidebar step indicators
      session$sendCustomMessage("updateSidebarStep", list(step = 3))
    })

    # Create JSON Preview function
    createJsonPreview <- function() {
      # Get variables
      vars_df <- detected_variables()
      if (is.null(vars_df) || nrow(vars_df) == 0) {
        variables_array <- "[]"
      } else {
        variables_list <- paste0('"', vars_df$variable, '"')
        variables_array <- paste0(
          "[\n      ",
          paste(variables_list, collapse = ",\n      "),
          "\n    ]"
        )
      }

      # Format authors in the new format
      author_list <- authors()
      if (length(author_list) == 0) {
        authors_array <- "[]"
      } else {
        author_entries <- lapply(author_list, function(author) {
          orcid_part <- if (!is.null(author$orcid) && author$orcid != "") {
            sprintf(',\n        "@id": "%s"', author$orcid)
          } else {
            ""
          }

          sprintf(
            '{\n        "@type": "Person",\n        "givenName": "%s",\n        "familyName": "%s"%s\n      }',
            author$first_name,
            author$last_name,
            orcid_part
          )
        })

        authors_array <- paste0(
          "[\n      ",
          paste(author_entries, collapse = ",\n      "),
          "\n    ]"
        )
      }

      # Create the JSON template with the correct format
      json_template <- paste0(
        "{\n",
        '    "@context": "https://schema.org/",\n',
        '    "@type": "Dataset",\n',
        sprintf('    "name": "%s",\n', gsub('"', '\\\\"', input$dataset_name)),
        sprintf('    "description": "%s",\n', gsub('"', '\\\\"', input$dataset_description)),
        '    "author": ', authors_array, ',\n',
        '    "variableMeasured": ', variables_array, '\n',
        "}"
      )

      # Simple HTML escaping for display
      json_html <- gsub("<", "&lt;", json_template)
      json_html <- gsub(">", "&gt;", json_html)

      # Manual syntax highlighting with spans
      # Mark keys
      json_html <- gsub('("@[^"]+"):', '<span class="json-key">\\1</span>:', json_html)
      json_html <- gsub('("[^@][^"]*"):',  '<span class="json-key">\\1</span>:', json_html)

      # Mark string values
      json_html <- gsub('": "([^"]*)"', '": <span class="json-string">"\\1"</span>', json_html)

      # Mark punctuation
      json_html <- gsub('\\{', '<span class="json-punctuation">{</span>', json_html)
      json_html <- gsub('\\}', '<span class="json-punctuation">}</span>', json_html)
      json_html <- gsub('\\[', '<span class="json-punctuation">[</span>', json_html)
      json_html <- gsub('\\]', '<span class="json-punctuation">]</span>', json_html)
      json_html <- gsub(',(\n)', '<span class="json-punctuation">,</span>\\1', json_html)

      return(json_html)
    }
  })
}

#' Step 3 Server Module - Complete Working Version
#'
#' Handles filename standardization and mapping
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param session The current session object
step3Server <- function(id, state, session) {
  moduleServer(id, function(input, output, session) {

    # Handle Back button - go back to step 2
    observeEvent(input$back, {
      # Save current state before going back
      state$current_step <- 2
      
      # Update sidebar step indicators
      session$sendCustomMessage("updateSidebarStep", list(step = 2))
    })

    # Reactive values for managing file mapping state
    files <- reactiveVal(list())
    current_file <- reactiveVal(NULL)
    selected_keywords <- reactiveVal(list())
    file_mappings <- reactiveVal(list())
    selected_files <- reactiveVal(integer(0))
    constant_columns <- reactiveVal(list())
    ui_trigger <- reactiveVal(0)
    initialized <- reactiveVal(FALSE)
    last_data_files <- reactiveVal(NULL)  # Track the last set of data files we initialized with
    
    # Detect when data files change and reset state
    observe({
      current_files <- state$data_files
      
      # Skip if no files yet
      if (is.null(current_files) || length(current_files) == 0) {
        return()
      }
      
      # Check if files have changed
      last_files <- last_data_files()
      files_changed <- is.null(last_files) || 
                       length(current_files) != length(last_files) ||
                       !all(current_files == last_files)
      
      if (files_changed) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("STEP3: Data files have changed - resetting state")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  Old files: ", if (!is.null(last_files)) paste(last_files, collapse = ", ") else "NULL")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  New files: ", paste(current_files, collapse = ", "))
        }
        
        # Clear all state to force re-initialization
        initialized(FALSE)
        file_mappings(list())
        selected_files(integer(0))
        selected_keywords(list())
        current_file(NULL)
        constant_columns(list())
        
        # Clear state's file_mappings too so we don't restore stale data
        state$file_mappings <- NULL
        
        # Update last_data_files
        last_data_files(current_files)
        
        # Increment UI trigger to force re-render
        ui_trigger(ui_trigger() + 1)
      }
    }, priority = 100)  # High priority so it runs before initialization

    # Debug output for keyword troubleshooting
    output$debug_text <- renderPrint({
      keywords <- selected_keywords()
      if (length(keywords) > 0) {
        lapply(keywords, function(k) {
          list(name = k$name, value = k$value, id = k$id)
        })
      } else {
        "No keywords selected"
      }
    })

    #
    # Validates keyword values to ensure they meet naming requirements
    # @param value The keyword value to validate
    # @return List with valid (boolean) and message (string)
    #
    validateKeywordValue <- function(value) {
      if (is.null(value) || value == "") {
        return(list(valid = FALSE, message = "Value cannot be empty"))
      }
      
      if (!grepl("^[a-zA-Z0-9]+$", value)) {
        return(list(valid = FALSE, message = "Value must contain only letters and numbers (a-z, A-Z, 0-9). No spaces or special characters allowed."))
      }
      
      return(list(valid = TRUE, message = ""))
    }

    #
    # Validates custom keyword names
    # @param keyword The keyword name to validate
    # @return List with valid (boolean) and message (string)
    #
    validateCustomKeyword <- function(keyword) {
      if (is.null(keyword) || keyword == "") {
        return(list(valid = FALSE, message = "Keyword cannot be empty"))
      }
      
      if (!grepl("^[a-z]+$", keyword)) {
        return(list(valid = FALSE, message = "Custom keywords must contain only lowercase letters (a-z). No numbers, uppercase letters, spaces, or special characters allowed."))
      }
      
      return(list(valid = TRUE, message = ""))
    }

    # Initialize module with data files from parent state
    observe({
      req(state$project_dir)
      req(length(state$data_files) > 0)
      
      # Prevent re-initialization if already done
      if (initialized()) {
        return()
      }
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("STEP3 INIT: Project dir: ", state$project_dir)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("STEP3 INIT: Number of data files: ", length(state$data_files))
      }
      
      # Check if we have existing mappings from state (returning to step 3)
      if (!is.null(state$file_mappings) && length(state$file_mappings) > 0) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("STEP3 INIT: Restoring existing mappings from state")
        }
        file_mappings(state$file_mappings)
        
        # Restore constant columns analysis
        analyzeConstantColumns()
        
        # Set current file to first one
        if (length(state$file_mappings) > 0) {
          current_file(1)
          # Restore keywords for current file
          if (length(state$file_mappings[[1]]$keywords) > 0) {
            selected_keywords(state$file_mappings[[1]]$keywords)
          }
        }
      } else {
        # Create initial mappings for each data file
        mappings <- lapply(state$data_files, function(file) {
          list(
            original = file,
            new = "",
            keywords = list(),
            values = list(),
            partial_keywords = list()
          )
        })

        file_mappings(mappings)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("STEP3 INIT: Created new mappings")
        }

        # Analyze files for columns with constant values (for auto-naming)
        analyzeConstantColumns()

        # Set initial file selection
        if (length(mappings) > 0) {
          current_file(1)
          if (isTRUE(getOption("psychds.verbose"))) {
            message("STEP3 INIT: Set current file to index 1")
          }
        }
      }
      
      # Mark as initialized and remember which files we initialized with
      initialized(TRUE)
      last_data_files(state$data_files)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("STEP3 INIT: Initialization complete for ", length(state$data_files), " files")
      }
    })

    # Note: The old "Handle returning to step 3" observer has been removed
    # because we now have a more robust change detection system above that
    # monitors state$data_files directly

    # --- Validate Existing Filenames ---
    # Checks if original filenames already match Psych-DS naming conventions
    # Pattern: keyword-value_keyword-value_..._data.(csv|tsv)
    PSYCH_DS_FILENAME_REGEX <- "^([a-z]+-[a-zA-Z0-9]+)(_[a-z]+-[a-zA-Z0-9]+)*_data\\.(csv|tsv)$"
    STANDARD_KEYWORDS <- c("subject", "session", "study", "task", "condition", "stimulus", "trial", "description")
    
    parseKeywordsFromFilename <- function(filename, file_idx = 1) {
      # Extract the part before _data.ext
      base <- sub("_data\\.(csv|tsv)$", "", filename)
      # Split on underscores to get keyword-value pairs
      pairs <- strsplit(base, "_")[[1]]
      keywords <- list()
      for (i in seq_along(pairs)) {
        parts <- strsplit(pairs[i], "-", fixed = TRUE)[[1]]
        if (length(parts) >= 2) {
          kw_name <- parts[1]
          kw_value <- paste(parts[-1], collapse = "-")  # in case value contains hyphens... though it shouldn't
          keywords[[i]] <- list(
            name = kw_name,
            display = kw_name,
            value = kw_value,
            id = paste0(kw_name, "_", format(Sys.time(), "%Y%m%d%H%M%S"), "_f", file_idx, "k", i),
            custom = !(kw_name %in% STANDARD_KEYWORDS)
          )
        }
      }
      keywords
    }
    
    observeEvent(input$validate_existing_names, {
      mappings <- file_mappings()
      if (length(mappings) == 0) return()
      
      valid_count <- 0
      invalid_files <- character(0)
      
      for (i in seq_along(mappings)) {
        filename <- basename(mappings[[i]]$original)
        
        if (grepl(PSYCH_DS_FILENAME_REGEX, filename)) {
          # Parse keywords from the valid filename
          parsed_keywords <- parseKeywordsFromFilename(filename, i)
          mappings[[i]]$new <- filename
          mappings[[i]]$keywords <- parsed_keywords
          mappings[[i]]$partial_keywords <- parsed_keywords
          valid_count <- valid_count + 1
        } else {
          invalid_files <- c(invalid_files, filename)
        }
      }
      
      file_mappings(mappings)
      ui_trigger(ui_trigger() + 1)
      
      # Update current file keywords display
      current_idx <- current_file()
      if (!is.null(current_idx) && current_idx <= length(mappings)) {
        selected_keywords(mappings[[current_idx]]$keywords)
      }
      
      # Render results
      output$validate_existing_results <- renderUI({
        if (valid_count == length(mappings)) {
          # All files valid
          div(
            style = "margin-top: 12px; padding: 10px 14px; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px;",
            icon("check-circle", style = "color: #28a745; margin-right: 8px;"),
            tags$strong(paste0("All ", valid_count, " file", if(valid_count > 1) "s" else "", " match"), 
                       style = "color: #155724;"),
            span(" the Psych-DS naming convention! You can continue to the next step.",
                 style = "color: #155724;")
          )
        } else if (valid_count > 0) {
          # Some valid, some not
          div(
            style = "margin-top: 12px;",
            div(
              style = "padding: 10px 14px; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; margin-bottom: 8px;",
              icon("check-circle", style = "color: #28a745; margin-right: 6px;"),
              tags$strong(paste0(valid_count, " file", if(valid_count > 1) "s" else "", " accepted."),
                         style = "color: #155724;")
            ),
            div(
              style = "padding: 10px 14px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;",
              icon("exclamation-triangle", style = "color: #856404; margin-right: 6px;"),
              tags$strong(paste0(length(invalid_files), " file", if(length(invalid_files) > 1) "s" else "", 
                                " need renaming:"),
                         style = "color: #856404;"),
              tags$ul(
                style = "margin: 6px 0 0 0; padding-left: 20px; font-size: 13px; color: #856404;",
                lapply(invalid_files, function(f) tags$li(tags$code(f, style = "font-size: 12px;")))
              )
            )
          )
        } else {
          # None valid
          div(
            style = "margin-top: 12px; padding: 10px 14px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;",
            icon("exclamation-triangle", style = "color: #856404; margin-right: 8px;"),
            tags$strong("No files match ", style = "color: #856404;"),
            span("the Psych-DS naming pattern. Use the tools below to rename them.",
                 style = "color: #856404;"),
            div(
              style = "margin-top: 8px; font-size: 12px; color: #856404;",
              "Expected pattern: ",
              tags$code("keyword-value_keyword-value_data.csv", style = "font-size: 11px;"),
              " (e.g., ", tags$code("subject-01_task-memory_data.csv", style = "font-size: 11px;"), ")"
            )
          )
        }
      })
    })

    #
    # Analyzes data files to find columns with constant values
    # These columns can be used for automatic keyword-based naming
    #
    analyzeConstantColumns <- function() {
      mappings <- file_mappings()
      constant_cols <- list()
      
      withProgress(message = "Analyzing columns for auto-naming...", value = 0, {
        for (i in seq_along(mappings)) {
          file_path <- file.path(state$project_dir, mappings[[i]]$original)
          
          if (file.exists(file_path)) {
            tryCatch({
              # Read sample of data to find constant columns
              data <- read_data_file(file_path, stringsAsFactors = FALSE, nrows = 1000)
              
              const_cols <- list()
              
              for (col_name in names(data)) {
                col_data <- data[[col_name]]
                non_na <- col_data[!is.na(col_data) & col_data != ""]
                
                # Column is constant if all non-NA values are identical
                if (length(non_na) > 0 && length(unique(non_na)) == 1) {
                  const_value <- as.character(non_na[1])
                  const_cols[[col_name]] <- list(
                    column = col_name,
                    value = const_value
                  )
                }
              }
              
              constant_cols[[i]] <- const_cols
              
            }, error = function(e) {
              constant_cols[[i]] <- list()
            })
          } else {
            constant_cols[[i]] <- list()
          }
          
          setProgress(i / length(mappings))
        }
      })
      
      constant_columns(constant_cols)
    }
    
    # Helper: compute the destination path for a file mapping
    get_destination_path <- function(mapping) {
      if (mapping$new == "") return(NA_character_)
      file_parts <- strsplit(mapping$original, "/")[[1]]
      if (length(file_parts) > 2) {
        subdir_parts <- file_parts[2:(length(file_parts) - 1)]
        paste(c(subdir_parts, mapping$new), collapse = "/")
      } else {
        mapping$new
      }
    }
    
    # Reactive: find any duplicate destination paths among renamed files
    duplicate_paths <- reactive({
      mappings <- file_mappings()
      if (length(mappings) == 0) return(character(0))
      
      dest_paths <- vapply(mappings, get_destination_path, character(1))
      # Only consider files that have been renamed
      named <- dest_paths[!is.na(dest_paths) & dest_paths != ""]
      if (length(named) == 0) return(character(0))
      
      # Return the paths that appear more than once
      tab <- table(named)
      names(tab[tab > 1])
    })
    
    # Render duplicate warning UI
    output$duplicate_warning <- renderUI({
      dupes <- duplicate_paths()
      if (length(dupes) == 0) return(NULL)
      
      div(
        class = "alert",
        style = "background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; padding: 15px; margin-top: 15px; margin-bottom: 10px;",
        div(
          style = "display: flex; align-items: start;",
          icon("exclamation-triangle", style = "color: #721c24; font-size: 20px; margin-right: 12px; margin-top: 2px;"),
          div(
            style = "flex: 1;",
            strong("Duplicate filenames detected", style = "color: #721c24; display: block; margin-bottom: 5px;"),
            span(
              style = "color: #721c24; line-height: 1.5;",
              "Two or more files would share the same destination path. Please rename them so every file has a unique name."
            ),
            tags$ul(
              style = "margin-top: 8px; margin-bottom: 0; color: #721c24;",
              lapply(dupes, function(p) tags$li(tags$code(p)))
            )
          )
        )
      )
    })
    
    # Shared files-free directory browser (see directory_picker.R)
    bindDirectoryBrowser(
      input, output, session,
      browse_button = "save_dataset_dir_select",
      target_input  = "save_dataset_dir",
      title = "Select a destination folder",
      easy_close = FALSE,
      host_modal = TRUE,
      on_commit = function(p) { save_dir_value(p); show_save_modal() },
      on_cancel = function() show_save_modal()
    )
    
    output$save_full_path <- renderText({
      req(input$save_dataset_name, input$save_dataset_dir)
      if (input$save_dataset_name != "" && input$save_dataset_dir != "") {
        file.path(input$save_dataset_dir, input$save_dataset_name)
      } else {
        "Please enter a dataset name"
      }
    })

    # Display count of selected files
    output$selected_count_text <- renderUI({
      count <- length(selected_files())
      if (count > 0) {
        div(
          style = "color: #2196F3; font-weight: 500;",
          paste(count, if(count == 1) "file" else "files", "selected")
        )
      } else {
        div(
          style = "color: #999; font-style: italic;",
          "No files selected"
        )
      }
    })
    
    # Reactive flag for file selection state
    output$has_selection <- reactive({
      length(selected_files()) > 0
    })
    outputOptions(output, "has_selection", suspendWhenHidden = FALSE)
    
    # Check if selected files have constant columns (constant across ALL selected files)
    output$has_constant_columns <- reactive({
      selected <- selected_files()
      file_indices <- if (length(selected) > 0) selected else c(current_file())
      
      const_cols <- constant_columns()
      
      # Find columns that are constant in ALL selected files (intersection)
      all_const_cols <- NULL
      
      for (idx in file_indices) {
        if (!is.null(idx) && length(const_cols) >= idx && length(const_cols[[idx]]) > 0) {
          file_const_cols <- names(const_cols[[idx]])
          
          if (is.null(all_const_cols)) {
            all_const_cols <- file_const_cols
          } else {
            all_const_cols <- intersect(all_const_cols, file_const_cols)
          }
        } else {
          # This file has no constant columns, so intersection is empty
          return(FALSE)
        }
      }
      
      return(!is.null(all_const_cols) && length(all_const_cols) > 0)
    })
    outputOptions(output, "has_constant_columns", suspendWhenHidden = FALSE)
    
    # Update auto-naming dropdown options based on selected files
    observe({
      selected <- selected_files()
      file_indices <- if (length(selected) > 0) selected else c(current_file())
      
      if (length(file_indices) == 0) return()
      
      const_cols <- constant_columns()
      
      # Find columns that are constant in ALL selected files (intersection)
      all_const_cols <- NULL  # Will hold column names constant across all files
      
      for (idx in file_indices) {
        if (length(const_cols) >= idx && length(const_cols[[idx]]) > 0) {
          file_const_cols <- names(const_cols[[idx]])
          
          if (is.null(all_const_cols)) {
            # First file - start with its constant columns
            all_const_cols <- file_const_cols
          } else {
            # Subsequent files - keep only columns that appear in both
            all_const_cols <- intersect(all_const_cols, file_const_cols)
          }
        } else {
          # This file has no constant columns, so intersection is empty
          all_const_cols <- character(0)
          break
        }
      }
      
      # Update column dropdown
      if (!is.null(all_const_cols) && length(all_const_cols) > 0) {
        col_choices <- setNames(all_const_cols, all_const_cols)
        updateSelectInput(session, "auto_column", choices = col_choices)
        
        # Build keyword choices from standard and custom keywords
        standard_keywords <- c("subject", "session", "study", "task", "condition", 
                              "stimulus", "trial", "description")
        
        keywords <- selected_keywords()
        custom_keywords <- character(0)
        if (length(keywords) > 0) {
          custom_keywords <- sapply(keywords[sapply(keywords, function(k) {
            !is.null(k$custom) && k$custom
          })], function(k) k$name)
        }
        
        all_keywords <- c(standard_keywords, custom_keywords)
        keyword_choices <- setNames(all_keywords, all_keywords)
        
        updateSelectInput(session, "auto_keyword", choices = keyword_choices)
      }
    })
    
    # Apply auto-naming to selected files
    observeEvent(input$apply_auto_name, {
      keyword_name <- input$auto_keyword
      column_name <- input$auto_column
      
      if (is.null(keyword_name) || is.null(column_name)) {
        showNotification("Please select both a keyword and a column", type = "warning")
        return()
      }
      
      selected <- selected_files()
      file_indices <- if (length(selected) > 0) selected else c(current_file())
      
      if (length(file_indices) == 0) {
        showNotification("No files selected", type = "warning")
        return()
      }
      
      const_cols <- constant_columns()
      mappings <- file_mappings()
      
      success_count <- 0
      error_count <- 0
      filenames_generated <- 0
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-NAME: Processing ", length(file_indices), " files")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-NAME: Keyword: ", keyword_name, ", Column: ", column_name)
      }
      
      withProgress(message = "Applying auto-naming...", value = 0, {
        for (i in seq_along(file_indices)) {
          file_idx <- file_indices[i]
          setProgress(i / length(file_indices), 
                      detail = paste("File", i, "of", length(file_indices)))
          
          if (file_idx > length(mappings) || file_idx > length(const_cols)) {
            error_count <- error_count + 1
            next
          }
          
          if (length(const_cols[[file_idx]]) == 0 || 
              !(column_name %in% names(const_cols[[file_idx]]))) {
            error_count <- error_count + 1
            next
          }
          
          col_info <- const_cols[[file_idx]][[column_name]]
          keyword_value <- as.character(col_info$value)
          
          if (is.null(keyword_value) || keyword_value == "" || is.na(keyword_value)) {
            error_count <- error_count + 1
            next
          }
          
          # Validate the keyword value
          validation_result <- validateKeywordValue(keyword_value)
          if (!validation_result$valid) {
            showNotification(
              paste0("File ", mappings[[file_idx]]$original, ": ", validation_result$message),
              type = "error",
              duration = 5
            )
            error_count <- error_count + 1
            next
          }
          
          if (isTRUE(getOption("psychds.verbose"))) {
            message("AUTO-NAME: File ", file_idx, " - value: ", keyword_value)
          }
          
          # Update or add keyword
          file_keywords <- mappings[[file_idx]]$keywords
          if (length(file_keywords) == 0) {
            file_keywords <- list()
          }
          
          keyword_found <- FALSE
          for (j in seq_along(file_keywords)) {
            if (file_keywords[[j]]$name == keyword_name) {
              file_keywords[[j]]$value <- keyword_value
              keyword_found <- TRUE
              break
            }
          }
          
          if (!keyword_found) {
            keyword_id <- paste0(keyword_name, "_auto_f", file_idx, "_", format(Sys.time(), "%Y%m%d%H%M%S"))
            file_keywords <- c(file_keywords, list(list(
              name = keyword_name,
              display = keyword_name,
              value = keyword_value,
              id = keyword_id,
              custom = !(keyword_name %in% c("subject", "session", "study", "task", 
                                            "condition", "stimulus", "trial", "description"))
            )))
          }
          
          # Generate filename if all keywords complete
          filename_parts <- character(0)
          all_filled <- TRUE
          
          for (kw in file_keywords) {
            if (is.null(kw$value) || kw$value == "") {
              all_filled <- FALSE
              break
            }
            filename_parts <- c(filename_parts, paste0(kw$name, "-", kw$value))
          }
          
          # Save keywords
          mappings[[file_idx]]$keywords <- file_keywords
          mappings[[file_idx]]$partial_keywords <- file_keywords
          
          # Generate filename if ready
          if (all_filled && length(filename_parts) > 0) {
            original <- mappings[[file_idx]]$original
            ext <- tools::file_ext(original)
            
            filename <- if (ext != "") {
              paste0(paste(filename_parts, collapse = "_"), "_data.", ext)
            } else {
              paste0(paste(filename_parts, collapse = "_"), "_data")
            }
            
            mappings[[file_idx]]$new <- filename
            filenames_generated <- filenames_generated + 1
            
            if (isTRUE(getOption("psychds.verbose"))) {
              message("AUTO-NAME: Generated filename: ", filename)
            }
          } else {
            if (isTRUE(getOption("psychds.verbose"))) {
              message("AUTO-NAME: Incomplete keywords for file ", file_idx)
            }
          }
          
          success_count <- success_count + 1
        }
      })
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-NAME: Completed. Success: ", success_count, ", Errors: ", error_count, ", Filenames: ", filenames_generated)
      }
      
      file_mappings(mappings)
      ui_trigger(ui_trigger() + 1)
      
      # Update current file display if affected
      current_idx <- current_file()
      if (!is.null(current_idx) && current_idx %in% file_indices) {
        selected_keywords(mappings[[current_idx]]$keywords)
      }
      
      # Show notification
      if (filenames_generated > 0) {
        showNotification(
          paste("Successfully generated", filenames_generated, "filenames!"),
          type = "message",
          duration = 5
        )
      } else if (success_count > 0) {
        showNotification(
          paste("Applied keyword to", success_count, "files (filenames will generate when all keywords filled)"),
          type = "warning",
          duration = 5
        )
      } else {
        showNotification("No files were processed. Check that column exists in selected files.", 
                        type = "error", duration = 5)
      }
    })

    # Render the file mapping interface with subdirectory display
    output$file_mapping_rows <- renderUI({
      trigger_value <- ui_trigger()
      if (isTRUE(getOption("psychds.verbose"))) {
        message("RENDER file_mapping_rows - trigger value: ", trigger_value)
      }
      
      mappings <- file_mappings()
      selected <- selected_files()
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("RENDER: Number of mappings: ", length(mappings))
      }
      
      if (length(mappings) == 0) {
        return(div(class = "text-center text-muted", "No files selected"))
      }

      # Log first few files for debugging
      for (i in 1:min(3, length(mappings))) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  File ", i, " - original: ", mappings[[i]]$original, " - new: ", mappings[[i]]$new)
        }
      }

      # Compute current duplicate paths for highlighting
      dupes <- duplicate_paths()
      
      rows <- lapply(seq_along(mappings), function(i) {
        file_info <- mappings[[i]]
        is_current <- current_file() == i
        is_selected <- i %in% selected

        # Extract subdirectory path for display
        original_dir <- dirname(file_info$original)
        display_new_path <- if (!is.null(file_info$new) && file_info$new != "") {
          if (original_dir != "." && original_dir != "data") {
            # Include subdirectory in display
            file.path(original_dir, file_info$new)
          } else {
            file_info$new
          }
        } else {
          NULL
        }
        
        # Check if this file has a duplicate destination path
        dest <- get_destination_path(file_info)
        is_duplicate <- !is.na(dest) && dest %in% dupes

        div(
          class = paste("file-mapping-row", 
                      if(is_current) "active" else "", 
                      if(i %% 2 == 0) "even" else "odd"),
          style = paste0(
            "padding: 8px 15px; border-bottom: 1px solid #eee; cursor: pointer; ",
            "display: flex; align-items: center; ",
            if(is_duplicate) "background-color: #f8d7da; border-left: 4px solid #dc3545;"
            else if(is_current) "background-color: #e3f2fd; border-left: 4px solid #2196F3;" 
            else if(i %% 2 == 0) "background-color: #f8f9fa;" 
            else ""
          ),
          
          # Multi-select checkbox
          div(
            style = "width: 30px; flex-shrink: 0;",
            checkboxInput(
              session$ns(paste0("select_", i)),
              label = NULL,
              value = is_selected
            )
          ),
          
          # File information
          div(
            class = "row",
            style = "flex: 1; margin: 0;",
            onclick = paste0("Shiny.setInputValue('", session$ns("select_file"), "', ", i, ", {priority: 'event'});"),
            div(class = "col-xs-6",
                span(file_info$original, class = "original-filename")
            ),
            div(class = "col-xs-6",
              if (!is.null(display_new_path)) {
                if (is_duplicate) {
                  tagList(
                    icon("exclamation-triangle", style = "color: #dc3545; margin-right: 4px; font-size: 12px;"),
                    span(display_new_path, class = "new-filename", style = "color: #dc3545; font-weight: bold;")
                  )
                } else {
                  span(display_new_path, class = "new-filename", style = "color: #3498db; font-weight: bold;")
                }
              } else if (length(file_info$partial_keywords) > 0) {
                span(
                  paste(length(file_info$partial_keywords), "keywords applied"),
                  style = "color: #ff9800; font-style: italic;"
                )
              } else {
                span(
                  if (is_current) "← Configure keywords" else "Click to configure", 
                  class = "text-muted",
                  style = if (is_current) "font-style: italic;" else ""
                )
              }
            )
          )
        )
      })

      do.call(tagList, rows)
    })

    # Select all files
    observeEvent(input$select_all_files, {
      mappings <- file_mappings()
      all_indices <- seq_along(mappings)
      selected_files(all_indices)
      
      for (i in all_indices) {
        updateCheckboxInput(session, paste0("select_", i), value = TRUE)
      }
      
      showNotification(paste("Selected all", length(all_indices), "files"), type = "message")
    })

    # Deselect all files
    observeEvent(input$deselect_all_files, {
      mappings <- file_mappings()
      all_indices <- seq_along(mappings)
      selected_files(integer(0))
      
      for (i in all_indices) {
        updateCheckboxInput(session, paste0("select_", i), value = FALSE)
      }
      
      showNotification("Deselected all files", type = "message")
    })

    # Set up individual checkbox observers
    observe({
      mappings <- file_mappings()
      
      for (i in seq_along(mappings)) {
        local({
          local_i <- i
          checkbox_id <- paste0("select_", local_i)
          
          observeEvent(input[[checkbox_id]], {
            current_selected <- selected_files()
            
            if (input[[checkbox_id]]) {
              if (!local_i %in% current_selected) {
                selected_files(c(current_selected, local_i))
              }
            } else {
              selected_files(setdiff(current_selected, local_i))
            }
          }, ignoreInit = TRUE)
        })
      }
    })
    
    # Handle file selection and keyword restoration
    observeEvent(input$select_file, {
      new_file_index <- input$select_file
      if (isTRUE(getOption("psychds.verbose"))) {
        message("FILE SELECT: Switching to file index: ", new_file_index)
      }
      
      current_file(new_file_index)

      mappings <- file_mappings()

      if (new_file_index <= length(mappings)) {
        file_info <- mappings[[new_file_index]]
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("FILE SELECT: File ", new_file_index, " has ", length(file_info$keywords), " keywords")
        }
        
        # Restore keywords for this file
        keywords_to_restore <- NULL
        
        if (length(file_info$keywords) > 0) {
          keywords_to_restore <- file_info$keywords
          if (isTRUE(getOption("psychds.verbose"))) {
            message("FILE SELECT: Restoring from keywords field")
          }
        } else if (length(file_info$partial_keywords) > 0) {
          keywords_to_restore <- file_info$partial_keywords
          if (isTRUE(getOption("psychds.verbose"))) {
            message("FILE SELECT: Restoring from partial_keywords field")
          }
        }
        
        if (!is.null(keywords_to_restore)) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("FILE SELECT: Setting ", length(keywords_to_restore), " keywords")
          }
          selected_keywords(keywords_to_restore)
          
          # Update input fields with keyword values
          for (keyword in keywords_to_restore) {
            if (!is.null(keyword$value) && keyword$value != "") {
              input_name <- paste0("keyword_value_", keyword$id)
              updateTextInput(session, input_name, value = keyword$value)
            }
          }
        } else {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("FILE SELECT: No keywords to restore")
          }
          selected_keywords(list())
        }
      }
    })

    # Keyword selection handlers
    observeEvent(input$keyword_subject, { addKeyword("subject", "subject") })
    observeEvent(input$keyword_study, { addKeyword("study", "study") })
    observeEvent(input$keyword_session, { addKeyword("session", "session") })
    observeEvent(input$keyword_task, { addKeyword("task", "task") })
    observeEvent(input$keyword_condition, { addKeyword("condition", "condition") })
    observeEvent(input$keyword_stimulus, { addKeyword("stimulus", "stimulus") })
    observeEvent(input$keyword_trial, { addKeyword("trial", "trial") })
    observeEvent(input$keyword_description, { addKeyword("description", "description") })
    
    # Handle adding custom keyword
    observeEvent(input$add_custom_keyword, {
      keyword_name <- trimws(input$custom_keyword_name)
      
      if (is.null(keyword_name) || keyword_name == "") {
        showNotification("Please enter a keyword name", type = "warning")
        return()
      }
      
      # Validate keyword format
      validation <- validateCustomKeyword(keyword_name)
      if (!validation$valid) {
        showNotification(validation$message, type = "error")
        return()
      }
      
      # Add the keyword
      addKeyword(keyword_name, keyword_name)
      
      # Clear the input
      updateTextInput(session, "custom_keyword_name", value = "")
      
      showNotification(paste0("Added keyword: ", keyword_name), type = "message")
    })
    
    # Handle removing a keyword
    observeEvent(input$remove_keyword, {
      keyword_index <- input$remove_keyword
      if (isTRUE(getOption("psychds.verbose"))) {
        message("REMOVE KEYWORD: Removing keyword at index ", keyword_index)
      }
      
      keywords <- selected_keywords()
      
      if (keyword_index > 0 && keyword_index <= length(keywords)) {
        removed_name <- keywords[[keyword_index]]$name
        keywords <- keywords[-keyword_index]
        selected_keywords(keywords)
        updateKeywordMapping()
        if (isTRUE(getOption("psychds.verbose"))) {
          message("REMOVE KEYWORD: Removed '", removed_name, "'. Remaining keywords: ", length(keywords))
        }
      }
    }, ignoreInit = TRUE)
    
    # Handle keyword reordering via drag and drop
    observeEvent(input$keyword_order, {
      new_order <- input$keyword_order
      if (isTRUE(getOption("psychds.verbose"))) {
        message("REORDER KEYWORDS: New order received: ", paste(new_order, collapse = ", "))
      }
      
      keywords <- selected_keywords()
      if (length(keywords) == 0 || length(new_order) == 0) return()
      
      # Create a map of id to keyword
      keyword_map <- list()
      for (kw in keywords) {
        keyword_map[[kw$id]] <- kw
      }
      
      # Reorder based on new_order (which contains keyword IDs)
      reordered <- list()
      for (id in new_order) {
        if (!is.null(keyword_map[[id]])) {
          reordered[[length(reordered) + 1]] <- keyword_map[[id]]
        }
      }
      
      if (length(reordered) == length(keywords)) {
        selected_keywords(reordered)
        updateKeywordMapping()
        if (isTRUE(getOption("psychds.verbose"))) {
          message("REORDER KEYWORDS: Successfully reordered ", length(reordered), " keywords")
        }
      }
    }, ignoreInit = TRUE)

    # Add a keyword to the current configuration
    addKeyword <- function(name, display) {
      keywords <- selected_keywords()
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("ADD KEYWORD: Adding '", name, "'")
      }

      # Check for duplicates
      if (!any(sapply(keywords, function(k) k$name == name))) {
        keyword_id <- paste0(name, "_", format(Sys.time(), "%Y%m%d%H%M%S"), "_", sample(1000:9999, 1))

        keywords[[length(keywords) + 1]] <- list(
          name = name,
          display = display,
          value = "",
          id = keyword_id
        )
        selected_keywords(keywords)
        updateKeywordMapping()
        if (isTRUE(getOption("psychds.verbose"))) {
          message("ADD KEYWORD: Added successfully. Total keywords: ", length(keywords))
        }
      }
    }

    # Save current keyword configuration to file mapping
    updateKeywordMapping <- function() {
      file_index <- current_file()
      if (is.null(file_index)) return()

      mappings <- file_mappings()
      if (file_index > length(mappings)) return()

      if (isTRUE(getOption("psychds.verbose"))) {
        message("UPDATE MAPPING: Saving keywords for file ", file_index)
      }
      
      mappings[[file_index]]$keywords <- selected_keywords()
      mappings[[file_index]]$partial_keywords <- selected_keywords()
      mappings[[file_index]]$new <- ""  # Clear filename since config changed
      file_mappings(mappings)
    }

    # Generate standardized filename from keywords
    observeEvent(input$generate_filename, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("\n=== GENERATE FILENAME CLICKED ===")
      }

      file_index <- current_file()
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Current file index: ", file_index)
      }
      
      if (is.null(file_index)) {
        showNotification("No file selected", type = "warning")
        return()
      }

      keywords <- selected_keywords()
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Number of keywords: ", length(keywords))
      }
      
      if (length(keywords) == 0) {
        showNotification("Please add at least one keyword", type = "warning")
        return()
      }

      # Validate and collect keyword values
      keyword_values <- list()
      all_filled <- TRUE
      updated_keywords <- keywords
      
      for (i in seq_along(keywords)) {
        keyword <- keywords[[i]]
        input_name <- paste0("keyword_value_", keyword$id)
        value <- input[[input_name]]
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("GEN: Keyword '", keyword$name, "' - Input: ", input_name, " - Value: '", value, "'")
        }
        
        if (is.null(value) || value == "") {
          all_filled <- FALSE
          if (isTRUE(getOption("psychds.verbose"))) {
            message("GEN: Missing value for keyword: ", keyword$name)
          }
        } else {
          # Validate value format
          validation_result <- validateKeywordValue(value)
          if (!validation_result$valid) {
            showNotification(validation_result$message, type = "error")
            return()
          }
          keyword_values[[keyword$name]] <- value
          updated_keywords[[i]]$value <- value
        }
      }
      
      if (!all_filled) {
        showNotification("Please fill in all keyword values", type = "warning")
        return()
      }

      # Build filename from keyword-value pairs
      filename_parts <- character(0)
      for (i in seq_along(updated_keywords)) {
        kw <- updated_keywords[[i]]
        filename_parts <- c(filename_parts, paste0(kw$name, "-", kw$value))
      }
      
      mappings <- isolate(file_mappings())
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Retrieved ", length(mappings), " mappings")
      }
      
      if (file_index > length(mappings)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("GEN ERROR: file_index (", file_index, ") > mappings length (", length(mappings), ")")
        }
        return()
      }
      
      original <- mappings[[file_index]]$original
      ext <- tools::file_ext(original)
      
      filename <- if (ext != "") {
        paste0(paste(filename_parts, collapse = "_"), "_data.", ext)
      } else {
        paste0(paste(filename_parts, collapse = "_"), "_data")
      }
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Generated filename: ", filename)
      }
      
      # Save mapping with keywords and new filename
      mappings[[file_index]]$keywords <- updated_keywords
      mappings[[file_index]]$partial_keywords <- updated_keywords
      mappings[[file_index]]$new <- filename
      
      file_mappings(mappings)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Saved mappings back to reactive")
      }
      
      selected_keywords(updated_keywords)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Updated selected_keywords with values")
      }
      
      # Trigger UI refresh
      old_trigger <- isolate(ui_trigger())
      ui_trigger(old_trigger + 1)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Incremented ui_trigger from ", old_trigger, " to ", old_trigger + 1)
      }
      
      # Verify save
      verification <- isolate(file_mappings())
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN VERIFY: File ", file_index, " new name: '", verification[[file_index]]$new, "'")
      }
      
      showNotification("Filename generated successfully!", type = "message")
      
      # Auto-advance to next unconfigured file
      if (isTRUE(getOption("psychds.verbose"))) {
        message("GEN: Attempting to advance to next file...")
      }
      auto_advance_to_next_file(file_index)
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("=== END GENERATE FILENAME ===\n")
      }
    })

    # Auto-advance to next unconfigured file
    auto_advance_to_next_file <- function(current_index) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-ADVANCE: Starting from index ", current_index)
      }

      mappings <- file_mappings()
      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-ADVANCE: Total files: ", length(mappings))
      }

      if (length(mappings) <= 1 || current_index >= length(mappings)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("AUTO-ADVANCE: Cannot advance - at end or only one file")
        }
        return()
      }

      if (is.null(current_index) || current_index < 1 || current_index > length(mappings)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("AUTO-ADVANCE: Invalid index, resetting to 1")
        }
        current_index <- 1
      }

      total_mappings <- length(mappings)

      # Search for next unconfigured file
      check_sequence <- c(
        seq(current_index + 1, total_mappings),
        seq(1, current_index)
      )

      for (i in check_sequence) {
        if (mappings[[i]]$new == "") {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("AUTO-ADVANCE: Found unconfigured file at index ", i)
          }
          current_file(i)
          session$sendInputMessage("select_file", list(value = i, priority = "event"))
          return()
        }
      }

      if (isTRUE(getOption("psychds.verbose"))) {
        message("AUTO-ADVANCE: All files are configured")
      }
    }

    # UI output components
    output$current_file_text <- renderUI({
      file_index <- current_file()
      mappings <- file_mappings()
      selected <- selected_files()
      num_selected <- length(selected)
      total_files <- length(mappings)

      if (is.null(file_index) || file_index > length(mappings)) {
        return(div(
          p(style = "margin: 0 0 5px 0;", strong("Files: "), paste0(num_selected, " of ", total_files, " selected")),
          p(style = "margin: 0; color: #666;", "Click a file to configure it")
        ))
      }

      file_info <- mappings[[file_index]]
      div(
        p(style = "margin: 0 0 5px 0;", strong("Files: "), paste0(num_selected, " of ", total_files, " selected")),
        p(
          style = "margin: 0;",
          strong("Configuring: "),
          span(basename(file_info$original), style = "font-style: italic;")
        )
      )
    })

    output$selected_keywords <- renderUI({
      keywords <- selected_keywords()

      if (length(keywords) == 0) {
        return(div(
          style = "text-align: center; padding: 10px; color: #666;",
          "Click keywords above to add them here"
        ))
      }

      keyword_labels <- lapply(seq_along(keywords), function(i) {
        keyword <- keywords[[i]]

        tags$div(
          class = "keyword-chip-selected",
          id = paste0("keyword_", keyword$id),
          `data-keyword-id` = keyword$id,
          `data-keyword-name` = keyword$name,
          `data-keyword-index` = i,
          style = "display: inline-block; margin: 3px; padding: 5px 10px; background-color: #3498db; color: white; border-radius: 15px; position: relative; cursor: move;",

          tags$span(
            style = "pointer-events: none;",
            keyword$display
          ),

          tags$button(
            id = paste0("remove_keyword_", i),
            class = "remove-keyword",
            type = "button",
            style = "background: none; border: none; color: white; opacity: 0.8; padding: 2px 5px; font-size: 12px; cursor: pointer; margin-left: 5px;",
            onclick = paste0("event.stopPropagation(); Shiny.setInputValue('", session$ns("remove_keyword"), "', ", i, ", {priority: 'event'}); return false;"),
            HTML("&times;")
          )
        )
      })

      tagList(
        tags$div(
          id = session$ns("sortable_keywords"),
          class = "sortable-keywords",
          keyword_labels
        ),

        tags$script(HTML(paste0("
      $(document).ready(function() {
        if (typeof Sortable !== 'undefined') {
          var sortable = Sortable.create(document.getElementById('", session$ns("sortable_keywords"), "'), {
            animation: 150,
            ghostClass: 'sortable-ghost',
            onEnd: function(evt) {
              var items = evt.to.children;
              var newOrder = [];
              for (var i = 0; i < items.length; i++) {
                newOrder.push($(items[i]).data('keyword-id'));
              }
              Shiny.setInputValue('", session$ns("keyword_order"), "', newOrder);
            }
          });
        }
      });
    ")))
      )
    })

    output$keyword_value_inputs <- renderUI({
      file_index <- current_file()
      mappings <- file_mappings()

      if (is.null(file_index) || file_index > length(mappings)) {
        return(div(
          style = "padding: 15px; text-align: center; color: #666;",
          "Select keywords above to configure values"
        ))
      }

      file_info <- mappings[[file_index]]
      keywords <- selected_keywords()

      if (length(keywords) == 0) {
        return(div(
          style = "padding: 15px; text-align: center; color: #666;",
          "Select keywords above to configure values"
        ))
      }

      inputs <- lapply(seq_along(keywords), function(i) {
        keyword <- keywords[[i]]
        input_id <- paste0("keyword_value_", keyword$id)
        
        initial_value <- if (!is.null(keyword$value) && keyword$value != "") {
          keyword$value
        } else {
          ""
        }
        
        div(
          class = "form-group",
          style = "margin-bottom: 20px;",
          tags$label(
            class = "control-label",
            style = "color: #3498db; font-weight: bold; margin-bottom: 5px; display: block;",
            span(paste0(keyword$display, ":"))
          ),
          textInput(
            session$ns(input_id),
            NULL,
            value = initial_value,
            placeholder = paste("Enter", tolower(keyword$display), "value (letters/numbers only)"),
            width = "100%"
          ),
          div(
            id = session$ns(paste0("validation_", keyword$id)),
            style = "margin-top: 5px; min-height: 20px;"
          )
        )
      })

      do.call(tagList, inputs)
    })
    
    # Live filename preview based on current keyword values
    output$filename_preview <- renderUI({
      file_index <- current_file()
      mappings <- file_mappings()
      keywords <- selected_keywords()
      
      # If no file selected
      if (is.null(file_index) || file_index > length(mappings)) {
        return(div(
          style = "color: #666; font-style: italic;",
          "Select a file to preview filename"
        ))
      }
      
      # If no keywords selected
      if (length(keywords) == 0) {
        return(div(
          style = "color: #666; font-style: italic;",
          "Add keywords above to build filename"
        ))
      }
      
      # Get file extension from original filename
      file_info <- mappings[[file_index]]
      ext <- tools::file_ext(file_info$original)
      
      # Build filename as a single string with HTML for placeholders
      filename_html_parts <- character(0)
      
      for (i in seq_along(keywords)) {
        keyword <- keywords[[i]]
        input_id <- paste0("keyword_value_", keyword$id)
        value <- input[[input_id]]
        
        # Add underscore separator (except for first keyword)
        if (i > 1) {
          filename_html_parts <- c(filename_html_parts, "_")
        }
        
        if (is.null(value) || value == "") {
          # Show placeholder for empty values
          filename_html_parts <- c(filename_html_parts, 
            paste0(keyword$name, "-<span style='color: #999; font-style: italic;'>[", keyword$name, "]</span>"))
        } else {
          # Show actual value
          filename_html_parts <- c(filename_html_parts, paste0(keyword$name, "-", value))
        }
      }
      
      # Add _data.ext suffix
      suffix <- if (ext != "") paste0("_data.", ext) else "_data.csv"
      filename_html_parts <- c(filename_html_parts, suffix)
      
      div(
        style = "font-family: monospace; font-size: 14px;",
        HTML(paste0(filename_html_parts, collapse = ""))
      )
    })

    # Continue to next step
    observeEvent(input$continue, {
      mappings <- file_mappings()
      unmapped_files <- sapply(mappings, function(m) m$new == "")

      if (any(unmapped_files)) {
        showModal(modalDialog(
          title = "All Files Must Be Renamed",
          div(
            p("All files must be assigned standardized filenames before you can continue. Please rename the following files:"),
            tags$ul(
              lapply(which(unmapped_files), function(i) {
                tags$li(mappings[[i]]$original)
              })
            ),
            p(style = "margin-top: 15px; color: #666;", 
              "Select each file, choose keywords, fill in values, and click \"Use Filename\".")
          ),
          easyClose = TRUE,
          footer = modalButton("OK")
        ))
      } else {
        # Check for duplicate destination paths
        dupes <- duplicate_paths()
        if (length(dupes) > 0) {
          showModal(modalDialog(
            title = "Duplicate Filenames Detected",
            div(
              p("Two or more files would be saved to the same destination path. Each file must have a unique name."),
              p(strong("Conflicting paths:")),
              tags$ul(
                lapply(dupes, function(p) tags$li(tags$code(p)))
              ),
              p(style = "margin-top: 15px; color: #666;",
                "Please go back and change the keyword values for these files so that each one produces a unique filename.")
            ),
            easyClose = TRUE,
            footer = modalButton("OK")
          ))
        } else {
          proceedToFinalStep()
        }
      }
    })

    # Create HTML preview of file structure
    create_file_structure_html <- function(project_dir, file_mappings, optional_dirs) {
      
      # Build a nested tree structure from file paths
      build_tree <- function(file_paths) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("\n=== Building tree with ", length(file_paths), " file paths ===")
        }
        
        # Helper function to insert a path into the tree
        # NOTE: Files are marked with TRUE, directories are lists
        insert_path <- function(tree, parts, depth = 0) {
          indent <- paste(rep("  ", depth), collapse = "")
          
          if (length(parts) == 0) {
            if (isTRUE(getOption("psychds.verbose"))) {
              message(indent, "ERROR: No parts to insert!")
            }
            return(tree)
          }
          
          if (length(parts) == 1) {
            # It's a file at this level - mark with TRUE (not NULL which removes it!)
            if (isTRUE(getOption("psychds.verbose"))) {
              if (depth == 0) message(indent, "Adding file: ", parts[1])
            }
            tree[[parts[1]]] <- TRUE  # Changed from NULL to TRUE
            if (isTRUE(getOption("psychds.verbose"))) {
              if (depth == 0) message(indent, "Tree now has ", length(tree), " items")
            }
            return(tree)
          }
          
          # It's a directory - recurse
          dir_name <- parts[1]
          remaining_parts <- parts[-1]
          
          if (is.null(tree[[dir_name]]) || !is.list(tree[[dir_name]])) {
            tree[[dir_name]] <- list()
          }
          
          tree[[dir_name]] <- insert_path(tree[[dir_name]], remaining_parts, depth + 1)
          return(tree)
        }
        
        tree <- list()
        
        for (i in seq_along(file_paths)) {
          path <- file_paths[i]
          if (is.null(path) || path == "") {
            next
          }
          
          parts <- strsplit(path, "/")[[1]]
          tree <- insert_path(tree, parts)
          
          if (i <= 3) {
            if (isTRUE(getOption("psychds.verbose"))) {
              message("After path ", i, ", tree has ", length(tree), " top-level items")
            }
          }
        }
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("\n=== Tree built with ", length(tree), " top-level items ===")
        }
        if (length(tree) > 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("Final top-level items: ", paste(names(tree)[1:min(5, length(tree))], collapse = ", "))
          }
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("")
        }
        return(tree)
      }
      
      # Recursively render tree as HTML with collapsible folders
      render_tree <- function(tree, depth = 0) {
        if (is.null(tree) || length(tree) == 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("render_tree: Empty tree at depth ", depth)
          }
          return(NULL)
        }
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("render_tree: Rendering ", length(tree), " items at depth ", depth)
        }
        
        items <- lapply(names(tree), function(name) {
          node <- tree[[name]]
          
          # Check if it's a file (TRUE) or directory (list)
          if (isTRUE(node)) {
            # It's a file
            if (isTRUE(getOption("psychds.verbose"))) {
              message("  Rendering file: ", name)
            }
            tags$li(
              class = "tree-file",
              tags$span(class = "file-icon", "📄"),
              name
            )
          } else if (is.list(node)) {
            # It's a directory
            if (isTRUE(getOption("psychds.verbose"))) {
              message("  Rendering directory: ", name, " with ", length(node), " children")
            }
            tags$li(
              class = "tree-folder",
              tags$details(
                open = if (depth < 2) NA else NULL,  # Auto-expand first 2 levels
                tags$summary(
                  tags$span(class = "folder-icon", "📁"),
                  paste0(name, "/")
                ),
                tags$ul(
                  class = "tree-children",
                  render_tree(node, depth + 1)
                )
              )
            )
          } else {
            # Unexpected node type
            if (isTRUE(getOption("psychds.verbose"))) {
              message("  WARNING: Unexpected node type for ", name, ": ", class(node))
            }
            NULL
          }
        })
        
        # IMPORTANT: Must use do.call(tagList, ...) to properly combine list of tags
        result <- do.call(tagList, items)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("render_tree: Returning ", length(items), " items")
        }
        return(result)
      }
      
      # Build the complete structure
      create_nested_list <- function() {
        root_items <- list(
          tags$li(
            class = "tree-file",
            tags$span(class = "file-icon", "📄"),
            "dataset_description.json"
          )
        )

        # Add optional directories (empty folders)
        standard_dirs <- c("analysis", "materials", "results", "products", "documentation")
        for (dir in standard_dirs) {
          if (optional_dirs[[dir]]) {
            root_items[[length(root_items) + 1]] <- tags$li(
              class = "tree-folder",
              tags$span(class = "folder-icon", "📁"),
              paste0(dir, "/")
            )
          }
        }

        # Add custom directories (empty folders)
        if (!is.null(optional_dirs$custom)) {
          for (custom_dir in optional_dirs$custom) {
            root_items[[length(root_items) + 1]] <- tags$li(
              class = "tree-folder",
              tags$span(class = "folder-icon", "📁"),
              paste0(custom_dir, "/")
            )
          }
        }

        # Build data directory tree structure
        data_files <- character(0)  # Use character vector, not list
        for (mapping in file_mappings()) {
          if (mapping$new != "") {
            # Parse the original path to preserve subdirectory structure
            file_parts <- strsplit(mapping$original, "/")[[1]]
            
            # Reconstruct path: skip first part (which is typically "data" or root)
            # and use the subdirectory structure with the new filename
            if (length(file_parts) > 2) {
              # Has subdirectories: data/subdir1/subdir2/file.csv (or .tsv)
              subdir_parts <- file_parts[2:(length(file_parts)-1)]
              full_path <- paste(c(subdir_parts, mapping$new), collapse = "/")
            } else {
              # No subdirectories: data/file.csv (or .tsv)
              full_path <- mapping$new
            }
            
            data_files <- c(data_files, full_path)  # Append to character vector
          }
        }
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("\n=== Step 3 Modal: File Processing ===")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("Total files to display: ", length(data_files))
        }
        if (length(data_files) > 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("Sample files: ", paste(head(data_files, 3), collapse = ", "))
          }
        }
        
        # Build tree from file paths
        data_tree <- build_tree(data_files)
        
        # Debug: show tree structure
        if (length(data_tree) > 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("Tree structure keys: ", paste(names(data_tree), collapse = ", "))
          }
          if (isTRUE(getOption("psychds.verbose"))) {
            message("Tree structure (first level):")
          }
          for (name in names(data_tree)) {
            node <- data_tree[[name]]
            if (is.null(node)) {
              if (isTRUE(getOption("psychds.verbose"))) {
                message("  ", name, " (file)")
              }
            } else {
              if (isTRUE(getOption("psychds.verbose"))) {
                message("  ", name, "/ (directory with ", length(node), " items)")
              }
            }
          }
        }
        
        # Check if we have any files
        has_files <- length(data_files) > 0
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("\n=== Creating data folder UI ===")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("has_files: ", has_files)
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("About to call render_tree with data_tree")
        }
        
        # Create the data folder with nested structure
        data_folder <- tags$li(
          class = "tree-folder",
          tags$details(
            open = NA,  # Auto-expand data folder
            tags$summary(
              tags$span(class = "folder-icon", "📁"),
              "data/"
            ),
            if (has_files) {
              tags$ul(
                class = "tree-children",
                render_tree(data_tree, depth = 1)
              )
            } else {
              tags$div(
                style = "padding-left: 20px; color: #999; font-style: italic; font-size: 12px;",
                "No data files"
              )
            }
          )
        )
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("=== Data folder UI created ===\n")
        }

        # Combine everything
        tags$ul(
          class = "file-tree",
          data_folder,
          root_items
        )
      }

      tagList(
        tags$style(HTML("
      .file-tree {
        font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
        line-height: 1.6;
        list-style-type: none;
        padding-left: 0;
        margin: 0;
      }
      
      .file-tree ul {
        list-style-type: none;
        padding-left: 20px;
        margin: 4px 0;
      }
      
      .file-tree li {
        margin: 2px 0;
      }
      
      .file-tree details {
        margin: 2px 0;
      }
      
      .file-tree summary {
        cursor: pointer;
        user-select: none;
        padding: 2px 4px;
        border-radius: 3px;
        transition: background-color 0.15s ease;
      }
      
      .file-tree summary:hover {
        background-color: rgba(0, 0, 0, 0.05);
      }
      
      .file-tree summary::-webkit-details-marker {
        display: none;
      }
      
      .file-tree summary::marker {
        content: '';
      }
      
      .file-tree summary::before {
        content: '▶';
        display: inline-block;
        margin-right: 6px;
        font-size: 10px;
        transition: transform 0.15s ease;
      }
      
      .file-tree details[open] > summary::before {
        transform: rotate(90deg);
      }
      
      .file-icon, .folder-icon {
        margin-right: 6px;
        font-size: 14px;
      }
      
      .tree-file {
        padding: 2px 4px;
      }
      
      .tree-folder > span {
        padding: 2px 4px;
        border-radius: 3px;
      }
      
      .tree-children {
        border-left: 1px dotted #ccc;
        margin-left: 10px;
      }
    ")),
        create_nested_list()
      )
    }

    # The save-location dialog below is a modal, and the shared directory
    # browser is ALSO a modal; Shiny displays only one at a time. So the
    # browser replaces this dialog while open, and must reopen it on
    # commit/cancel. The chosen path is held in save_dir_value() and injected
    # when the dialog is (re)built -- an updateTextInput() sent while the
    # dialog is torn down never reaches the client.
    save_dir_value <- reactiveVal("")

    # Manual edits in the field stay authoritative too
    observeEvent(input$save_dataset_dir, {
      if (!is.null(input$save_dataset_dir)) save_dir_value(input$save_dataset_dir)
    })

    show_save_modal <- function() {
      # Use project_name from step 1 if available, otherwise fall back to dataset_info$name
      base_name <- if (!is.null(state$project_name) && state$project_name != "") {
        state$project_name
      } else if (!is.null(state$dataset_info$name)) {
        state$dataset_info$name
      } else {
        "my_dataset"
      }
      dataset_name <- gsub("[^a-zA-Z0-9_-]", "_", base_name)
      suggested_name <- dataset_name
      prior_name <- isolate(input$save_dataset_name)
      if (is.null(prior_name) || !nzchar(prior_name)) prior_name <- suggested_name
      
      # Create preview HTML with proper subdirectory structure
      preview_html <- tagList(
        div(
          style = "margin-top: 15px; margin-bottom: 15px;",
          tags$label("Preview of Dataset Structure", style = "font-weight: bold; display: block; margin-bottom: 8px;"),
          div(
            style = "max-height: 250px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; background-color: #f8f9fa; border-radius: 4px;",
            create_file_structure_html(NULL, file_mappings(), state$optional_dirs)
          )
        )
      )
      
      showModal(modalDialog(
        title = "Choose Dataset Location",
        size = "m",
        
        div(
          class = "alert alert-info",
          style = "margin-bottom: 20px;",
          icon("info-circle", style = "margin-right: 8px;"),
          "Your new Psych-DS project folder will be created here."
        ),
        
        div(
          style = "margin-bottom: 20px;",
          tags$label("Dataset Name", style = "font-weight: bold; margin-bottom: 8px; display: block;"),
          textInput(
            session$ns("save_dataset_name"),
            label = NULL,
            value = prior_name,
            placeholder = "my_dataset_psychds",
            width = "100%"
          ),
          tags$small(
            style = "color: #6c757d;",
            "Use only letters, numbers, hyphens, and underscores"
          )
        ),
        
        div(
          style = "margin-bottom: 20px;",
          tags$label("Save Location", style = "font-weight: bold; margin-bottom: 8px; display: block;"),
          div(
            class = "directory-input",
            textInput(
              session$ns("save_dataset_dir"),
              label = NULL,
              value = save_dir_value(),
              placeholder = "Choose destination folder",
              width = "100%"
            ),
            actionButton(
              session$ns("save_dataset_dir_select"),
              label = "...",
              title = "Select destination folder",
              class = "browse-btn"
            )
          ),
          tags$small(
            style = "color: #6c757d;",
            "The dataset folder will be created inside this location"
          )
        ),
        
        preview_html,
        
        div(
          class = "alert alert-info",
          style = "margin-top: 20px;",
          icon("info-circle", style = "margin-right: 8px;"),
          tags$strong("Full path: "),
          textOutput(session$ns("save_full_path"), inline = TRUE)
        ),
        
        easyClose = FALSE,
        footer = tagList(
          modalButton("Cancel"),
          actionButton(session$ns("confirm_save_location"), "Create Dataset", class = "btn-primary")
        )
      ))
    }

    proceedToFinalStep <- function() {
      state$file_mappings <- file_mappings()
      show_save_modal()
    }

    #
    # Creates the dataset with proper subdirectory structure preservation
    # This is the FIX for the subdirectory issue
    #
    observeEvent(input$confirm_save_location, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("confirm_save_location triggered")
      }
      
      # Validate inputs
      if (is.null(input$save_dataset_name) || input$save_dataset_name == "") {
        showNotification("Please enter a dataset name", type = "error")
        return()
      }
      
      if (is.null(input$save_dataset_dir) || input$save_dataset_dir == "") {
        showNotification("Please select a save location", type = "error")
        return()
      }
      
      if (!grepl("^[a-zA-Z0-9_-]+$", input$save_dataset_name)) {
        showNotification("Dataset name can only contain letters, numbers, hyphens, and underscores", type = "error")
        return()
      }
      
      new_dataset_dir <- file.path(input$save_dataset_dir, input$save_dataset_name)
      
      if (dir.exists(new_dataset_dir)) {
        showNotification("A folder with this name already exists at this location. Please choose a different name.", type = "error")
        return()
      }
      
      tryCatch({
        # Create main dataset directory
        dir.create(new_dataset_dir, showWarnings = FALSE, recursive = TRUE)
        
        # Create data directory
        data_dir <- file.path(new_dataset_dir, "data")
        dir.create(data_dir, showWarnings = FALSE, recursive = TRUE)
        
        # Copy files with preserved subdirectory structure
        original_project_dir <- state$project_dir
        files_copied <- 0
        
        for (mapping in file_mappings()) {
          if (mapping$new != "") {
            # Parse original path to extract subdirectory
            file_parts <- strsplit(mapping$original, "/")[[1]]
            
            # Determine destination path with subdirectory
            if (length(file_parts) > 2) {
              # File has subdirectories under data/
              subdir_path <- paste(file_parts[2:(length(file_parts)-1)], collapse = "/")
              
              # Create subdirectory in destination
              dest_subdir <- file.path(data_dir, subdir_path)
              if (!dir.exists(dest_subdir)) {
                dir.create(dest_subdir, recursive = TRUE, showWarnings = FALSE)
                if (isTRUE(getOption("psychds.verbose"))) {
                  message("Created subdirectory: ", dest_subdir)
                }
              }
              
              dest_path <- file.path(dest_subdir, mapping$new)
            } else {
              # File goes directly in data/
              dest_path <- file.path(data_dir, mapping$new)
            }
            
            # Copy the file
            src_path <- file.path(original_project_dir, mapping$original)
            
            if (file.exists(src_path)) {
              file.copy(src_path, dest_path, overwrite = FALSE)
              files_copied <- files_copied + 1
              if (isTRUE(getOption("psychds.verbose"))) {
                message("Copied: ", src_path, " -> ", dest_path)
              }
            } else {
              message("Warning: Source file not found: ", src_path)
            }
          }
        }
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("Total files copied: ", files_copied)
        }
        
        # Create optional directories
        optional_dirs <- state$optional_dirs
        standard_dirs <- c("analysis", "materials", "results", "products", "documentation")
        
        for (dir in standard_dirs) {
          if (optional_dirs[[dir]]) {
            dir.create(file.path(new_dataset_dir, dir), showWarnings = FALSE)
          }
        }
        
        # Create custom directories
        if (!is.null(optional_dirs$custom)) {
          for (custom_dir in optional_dirs$custom) {
            dir.create(file.path(new_dataset_dir, custom_dir), showWarnings = FALSE)
          }
        }
        
        # Generate dataset_description.json
        dataset_info <- state$dataset_info
        
        dataset_description <- list(
          "@context" = "https://schema.org/",
          "@type" = "Dataset",
          "name" = dataset_info$name,
          "description" = dataset_info$description,
          "author" = lapply(dataset_info$authors, function(author) {
            list(
              "@type" = "Person",
              "givenName" = author$first_name,
              "familyName" = author$last_name,
              "@id" = if(!is.null(author$orcid) && author$orcid != "") author$orcid else ""
            )
          }),
          "variableMeasured" = do.call(c, lapply(names(state$data_dict), function(file_name) {
            file_dict <- state$data_dict[[file_name]]
            lapply(rownames(file_dict), function(var_name) {
              list(
                "@type" = "PropertyValue",
                "name" = var_name,
                "description" = file_dict[var_name, "description"]
              )
            })
          }))
        )
        
        dataset_description <- dataset_description[!sapply(dataset_description, is.null)]
        
        json_path <- file.path(new_dataset_dir, "dataset_description.json")
        jsonlite::write_json(dataset_description, json_path, pretty = TRUE, auto_unbox = TRUE)
        
        state$created_dataset_dir <- new_dataset_dir
        
        removeModal()
        
        # Show success modal
        file_structure_preview <- create_file_structure_html(new_dataset_dir, file_mappings, optional_dirs)
        
        showModal(modalDialog(
          title = "Dataset Created Successfully!",
          div(
            class = "alert alert-success",
            style = "margin-bottom: 20px;",
            icon("check-circle", style = "margin-right: 8px; font-size: 20px;"),
            tags$strong("Your Psych-DS dataset has been created at:"),
            tags$br(),
            tags$code(new_dataset_dir, style = "font-size: 14px;")
          ),
          div(
            style = "max-height: 300px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; background-color: #f8f9fa;",
            file_structure_preview
          ),
          footer = tagList(
            modalButton("Close"),
            actionButton(session$ns("validate_dataset"), "Validate Dataset", class = "btn-primary")
          ),
          size = "l",
          easyClose = TRUE
        ))
        
      }, error = function(e) {
        showNotification(paste("Error creating dataset:", e$message), type = "error")
      })
    })

    # Validate created dataset
    observeEvent(input$validate_dataset, {
      message("Navigating to validate tab for dataset: ", state$created_dataset_dir)

      destination_dir <- state$created_dataset_dir

      output$dataset_preview <- renderUI({
        div(
          class = "section-box",
          div(class = "section-title", "Dataset Preview"),
          div(
            style = "max-height: 400px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; background-color: #f8f9fa;",
            create_file_structure_html(destination_dir,
                                       lapply(list.files(file.path(destination_dir, "data"), recursive = TRUE, full.names = FALSE),
                                              function(f) list(original = f, new = f)),state$optional_dirs)
          ),
          div(
            class = "section-title",
            style = "margin-top: 15px;",
            "Dataset Description"
          ),
          div(
            style = "border: 1px solid #ddd; padding: 10px; background-color: #f8f9fa; white-space: pre-wrap; font-family: monospace;",
            paste(readLines(file.path(destination_dir, "dataset_description.json")), collapse = "\n")
          )
        )
      })

      removeModal()
      session$sendCustomMessage("changeTab", list(tabName = "validate"))
    })

    # Simplified single-file experience (guided naming for one CSV/TSV)
    step3SingleFileServer(input, output, session, state, file_mappings, proceedToFinalStep)


    # Return reactive file mappings
    return(reactive({ file_mappings() }))
  })
}

check_validator_button <- function(id, session) {
  moduleServer(id, function(input, output, session) {
    observeEvent(input$check_validator, {
      # Insert JavaScript directly into the page
      insertUI(
        selector = "body",
        where = "beforeEnd",
        ui = tags$script(HTML('
          console.log("Manual validator check requested");
          var validatorStatus = "Validator NOT found";

          if (typeof window.psychDSValidator !== "undefined") {
            validatorStatus = "VALIDATOR FOUND! Methods: " + Object.keys(window.psychDSValidator).join(", ");
            console.log(validatorStatus);
          } else {
            console.error(validatorStatus);
          }

          // Show a visible alert that users can see
          alert(validatorStatus);

          // Also try to communicate back to Shiny
          if (typeof Shiny !== "undefined") {
            Shiny.setInputValue("validator_status", validatorStatus);
          } else {
            console.error("Shiny object not available");
          }
        '))
      )

      # Also display a notification directly from R
      showNotification("Check validator executed - look for browser alert", type = "message")
    })

    # This is the handler if Shiny communication works
    observeEvent(input$validator_status, {
      showNotification(paste("Validator status:", input$validator_status),
                       type = "message",
                       duration = 10)
    })
  })
}

check_validator_available <- function() {
  shiny::insertUI(
    selector = "head",
    where = "beforeEnd",
    ui = tags$script(HTML("
      console.log('Checking validator availability...');
      if (typeof window.psychDSValidator !== 'undefined') {
        console.log('Validator is available');
        Shiny.setInputValue('validator_available', true);
      } else {
        console.error('Validator is NOT available');
        Shiny.setInputValue('validator_available', false);
      }
    "))
  )
}

validateServer <- function(id, state, session) {
  if (isTRUE(getOption("psychds.verbose"))) {
    message("\n*** validateServer module starting ***\n")
  }
  moduleServer(id, function(input, output, session) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("*** Inside moduleServer for validateServer ***\n")
    }
    # Track validation status
    validation_status <- reactiveValues(
      is_validating = FALSE,
      is_complete = FALSE,
      is_valid = FALSE,
      step_statuses = list()
    )
    
    # Shared files-free directory browser (see directory_picker.R)
    bindDirectoryBrowser(
      input, output, session,
      browse_button = "validate_dir_select",
      target_input  = "validate_dir",
      title = "Select a dataset directory"
    )
    
    # Define steps for the UI rendering
    validation_steps <- list(
      list(
        key = "start",
        message = list(
          imperative = "Start validation",
          pastTense = "Validation started"
        ),
        subSteps = list()
      ),
      list(
        key = "check-folder",
        message = list(
          imperative = "Find project folder",
          pastTense = "Project folder found"
        ),
        subSteps = list(
          list(
            key = "build-tree",
            message = list(
              imperative = "Crawl project folder and construct file tree",
              pastTense = "Project folder crawled and file tree constructed"
            )
          )
        )
      ),
      list(
        key = "find-metadata",
        message = list(
          imperative = "Find metadata file",
          pastTense = 'Metadata file "dataset_description.json" found in the root folder'
        ),
        subSteps = list()
      ),
      list(
        key = "find-data-dir",
        message = list(
          imperative = 'Find "data" subfolder',
          pastTense = '"data" subfolder found in the root folder'
        ),
        subSteps = list()
      ),
      list(
        key = "parse-metadata",
        message = list(
          imperative = 'Parse "dataset_description.json" metadata file',
          pastTense = 'Successfully parsed "dataset_description.json" metadata file'
        ),
        subSteps = list(
          list(
            key = "metadata-utf8",
            message = list(
              imperative = "Check metadata file for utf-8 encoding",
              pastTense = "Metadata file is utf-8 encoded"
            )
          ),
          list(
            key = "metadata-json",
            message = list(
              imperative = "Parse metadata file as JSON",
              pastTense = "Metadata file parsed successfully"
            )
          ),
          list(
            key = "metadata-jsonld",
            message = list(
              imperative = "Validate metadata file as JSON-LD",
              pastTense = "Metadata file is valid JSON-LD"
            )
          ),
          list(
            key = "metadata-fields",
            message = list(
              imperative = 'Check metadata file for required "name", "description", and "variableMeasured" fields',
              pastTense = 'Metadata file contains required "name", "description", and "variableMeasured" fields.'
            )
          ),
          list(
            key = "metadata-type",
            message = list(
              imperative = 'Check metadata file for field "@type" with value "Dataset"',
              pastTense = 'Metadata file has "@type" field with value "Dataset"'
            )
          )
        )
      ),
      list(
        key = "check-for-csv",
        message = list(
          imperative = 'Check for CSV data files in "data" subfolder',
          pastTense = 'CSV data files found in "data" subfolder'
        ),
        subSteps = list()
      ),
      list(
        key = "validate-csvs",
        message = list(
          imperative = 'Check that all CSV data files are valid',
          pastTense = 'All CSV data files are valid'
        ),
        subSteps = list(
          list(
            key = "csv-keywords",
            message = list(
              imperative = 'Check filename for keyword formatting ',
              pastTense = 'Filename uses valid keyword formatting'
            )
          ),
          list(
            key = "csv-parse",
            message = list(
              imperative = 'Parse data file as CSV',
              pastTense = 'Data file successfully parsed as CSV'
            )
          ),
          list(
            key = "csv-header",
            message = list(
              imperative = 'Check for header line',
              pastTense = 'Header line found'
            )
          ),
          list(
            key = "csv-header-repeat",
            message = list(
              imperative = 'Check for redundant column names',
              pastTense = 'No redundant column names found'
            )
          ),
          list(
            key = "csv-nomismatch",
            message = list(
              imperative = 'Check all lines for equal number of cells',
              pastTense = 'All lines have equal number of cells'
            )
          ),
          list(
            key = "csv-rowid",
            message = list(
              imperative = 'Check for any row_id columns with non-unique values',
              pastTense = 'All row_id columns have unique values'
            )
          )
        )
      ),
      list(
        key = "check-variableMeasured",
        message = list(
          imperative = 'Confirm that all column headers in CSV data files are found in "variableMeasured" metadata field',
          pastTense = 'All column headers in CSV data files were found in "variableMeasured" metadata field'
        ),
        subSteps = list()
      )
    )
    
    # Store steps for UI reference
    validation_status$steps <- validation_steps
    
    #' Custom function to print validation status in a readable format
    #' @param validation_obj The validation status object from JavaScript
    print_validation_status <- function(validation_obj) {
      cat("\n=== VALIDATION STEP UPDATE ===\n")
      
      if (is.null(validation_obj)) {
        cat("Validation object is NULL\n")
        return()
      }
      
      # Print the raw structure first
      cat("Raw object structure:\n")
      cat("- Class:", class(validation_obj), "\n")
      cat("- Length:", length(validation_obj), "\n")
      cat("- Names:", paste(names(validation_obj), collapse = ", "), "\n\n")
      
      # Check if stepStatus exists
      if ("stepStatus" %in% names(validation_obj)) {
        step_status <- validation_obj$stepStatus
        cat("stepStatus found with", length(step_status), "entries\n")
        
        # Process each step
        for (i in seq_along(step_status)) {
          step_entry <- step_status[[i]]
          
          cat("\n--- Step", i, "---\n")
          cat("Entry class:", class(step_entry), "\n")
          cat("Entry length:", length(step_entry), "\n")
          
          if (length(step_entry) >= 2) {
            # Extract step key and status
            step_key <- step_entry[[1]]
            step_info <- step_entry[[2]]
            
            cat("Step Key:", step_key, "\n")
            cat("Step Info Class:", class(step_info), "\n")
            
            # Print step info details
            if (is.list(step_info)) {
              cat("Step Info Contents:\n")
              for (prop_name in names(step_info)) {
                prop_value <- step_info[[prop_name]]
                cat("  ", prop_name, ":", prop_value, "(", class(prop_value), ")\n")
              }
              
              # Check for issue details
              if ("issue" %in% names(step_info) && !is.null(step_info$issue)) {
                cat("  Issue Details:\n")
                issue <- step_info$issue
                for (issue_prop in names(issue)) {
                  cat("    ", issue_prop, ":", issue[[issue_prop]], "\n")
                }
              }
            } else {
              cat("Step Info (non-list):", step_info, "\n")
            }
            
            # Create readable status summary
            if (is.list(step_info) && "complete" %in% names(step_info)) {
              complete <- step_info$complete
              success <- if ("success" %in% names(step_info)) step_info$success else FALSE
              
              status_icon <- if (complete) {
                if (success) "[PASS]" else "[FAIL]"
              } else "[PENDING]"
              
              cat("Summary:", status_icon, step_key, "\n")
              
              # Show any error details
              if (!is.null(step_info$issue)) {
                cat("  ERROR:", step_info$issue$reason %||% "Unknown error", "\n")
              }
            }
          } else {
            cat("Invalid step entry (length < 2)\n")
          }
        }
      } else {
        cat("No stepStatus found in validation object\n")
        cat("Available properties:", paste(names(validation_obj), collapse = ", "), "\n")
      }
      
      cat("===============================\n\n")
    }

    # Replace your existing observeEvent with this:
    observeEvent(input$validation_step_status, {
      if (isTRUE(getOption("psychds.verbose"))) {
        print_validation_status(input$validation_step_status)
      }
      
      # Continue with existing logic for updating validation_status
      if (!is.null(input$validation_step_status) && 
          !is.null(input$validation_step_status$stepStatus)) {
        
        step_updates <- input$validation_step_status$stepStatus
        
        for (i in seq_along(step_updates)) {
          step_entry <- step_updates[[i]]
          
          if (length(step_entry) >= 2) {
            step_key <- step_entry[[1]]
            step_status <- step_entry[[2]]
            
            # Update the step status in our reactive
            validation_status$step_statuses[[step_key]] <- list(
              complete = step_status$complete,
              success = step_status$success,
              issue = step_status$issue
            )
          }
        }
        
        # Start validation mode if first update
        if (!validation_status$is_validating) {
          validation_status$is_validating <- TRUE
        }
      }
    }, ignoreNULL = TRUE)
    
    # Handle validation complete events
    observeEvent(input$validation_complete, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Validation complete event received")
      }
      validation_status$is_complete <- TRUE
      validation_status$is_valid <- TRUE
    })
    
    # Handle validation halted events
    observeEvent(input$validation_halted, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Validation halted event received")
      }
      validation_status$is_complete <- TRUE
      validation_status$is_valid <- FALSE
    })
    
    # Handle validation results
    observeEvent(input$validation_results, {
      if (!is.null(input$validation_results)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("=== Validation results received ===")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  input$validate_dir: '", if (!is.null(input$validate_dir)) input$validate_dir else "NULL", "'")
        }
        
        validation_status$is_complete <- TRUE
        validation_status$is_valid <- input$validation_results$valid
        validation_status$is_validating <- FALSE
        state$validation_results <- input$validation_results
        
        # Store the validated directory path for later use
        if (!is.null(input$validate_dir) && input$validate_dir != "") {
          state$validated_dataset_dir <- input$validate_dir
          if (isTRUE(getOption("psychds.verbose"))) {
            message("  -> Set validated_dataset_dir to: '", state$validated_dataset_dir, "'")
          }
        } else {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("  -> Could NOT set validated_dataset_dir (validate_dir is NULL or empty)")
          }
        }
      }
    })
    
    # Clear old validation results when directory changes
    observeEvent(input$validate_dir, {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("=== validate_dir changed ===")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  New value: '", input$validate_dir, "'")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Has validation results: ", !is.null(state$validation_results))
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Last validated dir: '", if (!is.null(state$validated_dataset_dir)) state$validated_dataset_dir else "NULL", "'")
      }
      
      # Clear results if we have them and directory is different from last validated
      if (!is.null(state$validation_results)) {
        # Check if directory actually changed
        if (is.null(state$validated_dataset_dir) || 
            state$validated_dataset_dir != input$validate_dir) {
          
          if (isTRUE(getOption("psychds.verbose"))) {
            message("  -> CLEARING validation results")
          }
          
          # Clear validation state
          validation_status$is_validating <- FALSE
          validation_status$is_complete <- FALSE
          validation_status$is_valid <- FALSE
          validation_status$step_statuses <- list()
          state$validation_results <- NULL
          
          # Send JavaScript message to reset the validation UI
          session$sendCustomMessage("reset_validation_ui", list(timestamp = Sys.time()))
          if (isTRUE(getOption("psychds.verbose"))) {
            message("  -> Sent reset_validation_ui message to JavaScript")
          }
          
          # Show a brief notification
          showNotification(
            "New dataset selected. Previous validation results cleared.",
            type = "message",
            duration = 3
          )
        } else {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("  -> Same directory, keeping results")
          }
        }
      } else {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  -> No validation results to clear")
        }
      }
    }, ignoreInit = TRUE, ignoreNULL = FALSE)


    # Add this to your validateServer function in server_modules.R
    # Replace the existing observeEvent for validation_step_status with this:

    observeEvent(input$validation_step_status, {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("*** STEP STATUS EVENT RECEIVED ***\n")
      }
      
      if (!is.null(input$validation_step_status)) {
        # Convert the validation data to pretty JSON for display
        json_output <- tryCatch({
          jsonlite::toJSON(input$validation_step_status, pretty = TRUE, auto_unbox = TRUE)
        }, error = function(e) {
          paste("Error converting to JSON:", e$message)
        })
        
        # Show the validation data in a modal popup
        showModal(modalDialog(
          title = "Validation Step Status Debug",
          div(
            h4("Raw Validation Data:"),
            tags$pre(
              style = "background-color: #f8f9fa; padding: 15px; border-radius: 5px; max-height: 400px; overflow-y: auto; font-family: monospace; font-size: 12px;",
              json_output
            ),
            hr(),
            h4("Data Structure Info:"),
            tags$ul(
              tags$li(paste("Class:", paste(class(input$validation_step_status), collapse = ", "))),
              tags$li(paste("Length:", length(input$validation_step_status))),
              tags$li(paste("Names:", paste(names(input$validation_step_status), collapse = ", "))),
              if ("stepStatus" %in% names(input$validation_step_status)) {
                tags$li(paste("stepStatus length:", length(input$validation_step_status$stepStatus)))
              }
            )
          ),
          easyClose = TRUE,
          footer = modalButton("Close")
        ))
        
        # Also call the original processing logic
        if (!is.null(input$validation_step_status$stepStatus)) {
          step_updates <- input$validation_step_status$stepStatus
          
          for (i in seq_along(step_updates)) {
            step_entry <- step_updates[[i]]
            
            if (length(step_entry) >= 2) {
              step_key <- step_entry[[1]]
              step_status <- step_entry[[2]]
              
              # Update the step status in our reactive
              validation_status$step_statuses[[step_key]] <- list(
                complete = step_status$complete,
                success = step_status$success,
                issue = step_status$issue
              )
            }
          }
          
          # Start validation mode if first update
          if (!validation_status$is_validating) {
            validation_status$is_validating <- TRUE
          }
        }
      }
    }, ignoreNULL = TRUE)
    
    
    
    # Handle the validate button click
    observeEvent(input$validate_btn, {
      req(input$validate_dir)
      
      if (dir.exists(input$validate_dir)) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("VALIDATE BUTTON CLICKED - Preparing validation")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  Directory to validate: '", input$validate_dir, "'")
        }
        
        # Store the directory we're about to validate BEFORE starting validation
        state$validated_dataset_dir <- input$validate_dir
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  -> Set validated_dataset_dir to: '", state$validated_dataset_dir, "'")
        }
        
        # Reset validation state
        validation_status$is_validating <- TRUE
        validation_status$is_complete <- FALSE
        validation_status$is_valid <- FALSE
        validation_status$step_statuses <- list()
        state$validation_results <- NULL
        
        # Show a loading notification
        showNotification(
          "Validating dataset... This may take a moment", 
          id = "validate_notif", 
          type = "message",
          duration = NULL
        )
        
        # Try to build the file tree and run validation
        tryCatch({
          # Build the file tree
          fileTree <- buildFileTree(input$validate_dir)
          
          # Send the file tree to the JavaScript validator
          session$sendCustomMessage("run_validation", fileTree)
        }, error = function(e) {
          removeNotification(id = "validate_notif")
          showNotification(paste("Error:", e$message), type = "error")
          validation_status$is_validating <- FALSE
        })
      } else {
        showNotification("Directory does not exist", type = "error")
      }
    })
    
    # Similar implementation for test validation button
    observeEvent(input$test_validation, {
      # Reset validation state
      validation_status$is_validating <- TRUE
      validation_status$is_complete <- FALSE
      validation_status$is_valid <- FALSE
      validation_status$step_statuses <- list()
      state$validation_results <- NULL
      
      # Show a loading notification
      showNotification(
        "Running test validation... This may take a moment", 
        id = "validate_notif", 
        type = "message",
        duration = NULL
      )
      
      # Create a test file tree and run validation
      tryCatch({
        # Create test file tree
        testTree <- createTestFileTree()
        
        # Send the test file tree to the JavaScript validator
        session$sendCustomMessage("run_validation", testTree)
      }, error = function(e) {
        removeNotification(id = "validate_notif")
        showNotification(paste("Error:", e$message), type = "error")
        validation_status$is_validating <- FALSE
      })
    })
    

    # Add debugging for all validation-related events
    observeEvent(input$validation_complete, {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("VALIDATION COMPLETE EVENT - Value:", input$validation_complete, "\n")
      }
    }, ignoreNULL = TRUE)

    observeEvent(input$validation_halted, {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("VALIDATION HALTED EVENT - Value:", input$validation_halted, "\n")
      }
    }, ignoreNULL = TRUE)

    # Add a test message handler to verify communication
    observeEvent(input$test_js_communication, {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("Test JS communication received:", input$test_js_communication, "\n")
      }
    }, ignoreNULL = TRUE)
  })
}

# -------------------------------------------------------------------------
# Helper: Detect identifier fields
# -------------------------------------------------------------------------
detect_identifier <- function(var_name, uniqueness_ratio, n_unique, n_clean) {
  var_lower <- tolower(var_name)
  
  id_patterns <- c(
    "\\bid\\b", "\\bids\\b", "_id$", "^id_",
    "\\buuid\\b", "\\bguid\\b", "\\bkey\\b", "\\bcode$",
    "^subject", "^participant", "^child_?id",
    "^session_?id", "^trial_?id", "^user_?id", "^record_?id"
  )
  
  name_matches_id <- any(sapply(id_patterns, function(p) grepl(p, var_lower)))
  name_matches_id && (uniqueness_ratio > 0.5 || n_unique >= n_clean * 0.5)
}

# -------------------------------------------------------------------------
# Helper: Detect JSON strings
# -------------------------------------------------------------------------
detect_json_strings <- function(col_clean) {
  if (length(col_clean) == 0) return(list(is_json = FALSE, pattern = ""))
  
  sample_vals <- head(col_clean, 100)
  
  is_array <- all(grepl("^\\[.*\\]$", sample_vals))
  is_object <- all(grepl("^\\{.*\\}$", sample_vals))
  
  if (is_array) return(list(is_json = TRUE, pattern = "JSON array"))
  if (is_object) return(list(is_json = TRUE, pattern = "JSON object"))
  list(is_json = FALSE, pattern = "")
}

# -------------------------------------------------------------------------
# Helper: Detect boolean variables  
# -------------------------------------------------------------------------
detect_boolean <- function(col_clean, n_unique, var_name = "") {
  if (n_unique > 2) return(list(is_boolean = FALSE, values = list()))
  
  var_lower <- tolower(var_name)
  unique_vals <- unique(col_clean)
  vals_lower <- tolower(as.character(unique_vals))
  vals_sorted <- sort(vals_lower)
  
  boolean_pairs <- list(
    c("false", "true"), c("f", "t"), c("n", "y"), c("no", "yes")
  )
  
  is_text_boolean <- any(sapply(boolean_pairs, function(pair) identical(vals_sorted, sort(pair))))
  
  boolean_name_patterns <- c(
    "\\bcorrect\\b", "\\bsuccess\\b", "\\bvalid\\b", "\\bcomplete\\b",
    "\\bfinished\\b", "\\bdone\\b", "\\bfailed\\b", "\\berror\\b",
    "\\btimeout\\b", "\\bflag\\b", "\\bis_", "\\bhas_", "\\bwas_"
  )
  response_name_patterns <- c(
    "\\bresponse\\b", "\\bresp\\b", "\\bchoice\\b", "\\bbutton\\b",
    "\\bkey\\b", "\\banswer\\b", "\\bselect"
  )
  
  name_suggests_boolean <- any(sapply(boolean_name_patterns, function(p) grepl(p, var_lower)))
  name_suggests_response <- any(sapply(response_name_patterns, function(p) grepl(p, var_lower)))
  
  is_01_boolean <- identical(vals_sorted, c("0", "1")) && 
                   name_suggests_boolean && !name_suggests_response
  
  single_boolean_vals <- c("true", "false", "yes", "no", "t", "f", "y", "n")
  is_single_boolean <- n_unique == 1 && vals_lower[1] %in% single_boolean_vals
  
  if (is_text_boolean || is_01_boolean || is_single_boolean) {
    values <- lapply(sort(unique_vals), function(v) {
      list(value = as.character(v), label = as.character(v), description = "")
    })
    return(list(is_boolean = TRUE, values = values))
  }
  
  list(is_boolean = FALSE, values = list())
}

# -------------------------------------------------------------------------
# Helper: Try parsing character vector as numeric
# -------------------------------------------------------------------------
try_parse_numeric <- function(col_clean) {
  if (length(col_clean) == 0) return(list(success = FALSE, values = NULL))
  
  col_clean <- trimws(col_clean)
  col_clean <- col_clean[col_clean != "" & !is.na(col_clean)]
  
  if (length(col_clean) == 0) return(list(success = FALSE, values = NULL))
  
  numeric_attempt <- suppressWarnings(as.numeric(col_clean))
  
  n_parsed <- sum(!is.na(numeric_attempt))
  n_total <- length(col_clean)
  parse_ratio <- n_parsed / n_total
  
  if (parse_ratio >= 0.90 && n_parsed >= 1) {
    return(list(
      success = TRUE,
      values = numeric_attempt[!is.na(numeric_attempt)]
    ))
  }
  
  list(success = FALSE, values = NULL)
}

# -------------------------------------------------------------------------
# Helper: Analyze numeric variables
# -------------------------------------------------------------------------
analyze_numeric <- function(col_clean, var_name, n_unique, uniqueness_ratio) {
  var_lower <- tolower(var_name)
  result <- list(categorical_values = list())
  
  unique_vals <- unique(col_clean)
  is_integer <- all(col_clean == floor(col_clean), na.rm = TRUE)
  
  is_categorical <- FALSE
  
  if (n_unique <= 3 && is_integer) {
    sorted_vals <- sort(unique_vals)
    is_code_sequence <- length(sorted_vals) > 1 && 
                       all(diff(sorted_vals) == 1) && 
                       min(sorted_vals) >= 0 && max(sorted_vals) <= 10
    
    cat_patterns <- c("\\bgroup\\b", "\\bcondition\\b", "\\btreatment\\b",
                     "\\bcategory\\b", "\\btype\\b", "\\bclass\\b",
                     "\\blevel\\b", "\\bfactor\\b", "\\barm\\b")
    name_suggests_cat <- any(sapply(cat_patterns, function(p) grepl(p, var_lower)))
    
    if (is_code_sequence && (name_suggests_cat || n_unique == 2)) {
      is_categorical <- TRUE
    }
  }
  
  if (is_categorical) {
    result$type <- "categorical"
    sorted_vals <- sort(unique_vals)
    result$categorical_values <- lapply(sorted_vals, function(v) {
      list(value = as.character(v), label = as.character(v), description = "")
    })
  } else {
    result$type <- if (is_integer) "integer" else "number"
    result$min_value <- as.character(min(col_clean, na.rm = TRUE))
    result$max_value <- as.character(max(col_clean, na.rm = TRUE))
    result$unit <- inferUnit(var_name, mean(col_clean, na.rm = TRUE))
  }
  
  result
}

# -------------------------------------------------------------------------
# Helper: Analyze string variables
# -------------------------------------------------------------------------
analyze_string <- function(col_clean, var_name, n_unique, uniqueness_ratio, n_clean) {
  var_lower <- tolower(var_name)
  result <- list(type = "string", categorical_values = list())
  
  avg_length <- mean(nchar(col_clean))
  max_length <- max(nchar(col_clean))
  
  if (avg_length > 50 || max_length > 200) return(result)
  
  strong_cat_patterns <- c(
    "\\bcondition\\b", "\\bgroup\\b", "\\btreatment\\b", "\\barm\\b",
    "\\bcategory\\b", "\\btype\\b", "\\bclass\\b", "\\blevel\\b",
    "\\bfactor\\b", "\\bstatus\\b", "\\bstate\\b", "\\bphase\\b",
    "\\bwave\\b", "\\bcohort\\b"
  )
  
  name_strongly_suggests_cat <- any(sapply(strong_cat_patterns, function(p) grepl(p, var_lower)))
  
  is_categorical <- FALSE
  
  if (name_strongly_suggests_cat && n_unique <= 20) {
    is_categorical <- TRUE
  }
  
  if (n_unique >= 2 && n_unique <= 20 && uniqueness_ratio < 0.05 && n_clean >= 20) {
    is_categorical <- TRUE
  }
  
  if (n_unique >= 2 && n_unique <= 10) {
    unique_vals <- unique(col_clean)
    if (mean(nchar(unique_vals)) < 30) {
      is_categorical <- TRUE
    }
  }
  
  if (is_categorical) {
    result$type <- "categorical"
    unique_vals <- sort(unique(col_clean))
    result$categorical_values <- lapply(unique_vals, function(v) {
      list(value = as.character(v), label = as.character(v), description = "")
    })
  }
  
  result
}

# -------------------------------------------------------------------------
# Helper: Infer unit from variable name
# -------------------------------------------------------------------------
inferUnit <- function(var_name, mean_val) {
  var_lower <- tolower(var_name)
  
  if (grepl("\\b(rt|reaction_?time|response_?time|latency|duration)\\b", var_lower)) {
    return(if (mean_val > 100) "milliseconds" else "seconds")
  }
  if (grepl("time_?elapsed|elapsed_?time", var_lower)) {
    return(if (mean_val > 1000) "milliseconds" else "seconds")
  }
  if (grepl("\\bage\\b", var_lower)) return("years")
  if (grepl("\\b(score|rating|points)\\b", var_lower)) return("points")
  if (grepl("\\b(percent|pct|proportion)\\b", var_lower)) {
    return(if (mean_val <= 1) "proportion" else "percent")
  }
  ""
}

# =========================================================================
# MAIN FUNCTION: analyzeVariable - PURE BASE R VERSION
# =========================================================================
analyzeVariable <- function(data_file, var_name) {
  result <- list(
    type = "string",
    unit = "",
    min_value = "",
    max_value = "",
    categorical_values = list(),
    required = FALSE,
    unique = FALSE,
    pattern = ""
  )
  
  tryCatch({
    # Use read_data_file to handle both CSV and TSV - read all as character
    data <- read_data_file(
      data_file, 
      stringsAsFactors = FALSE,
      colClasses = "character",  # Force all columns as character
      nrows = 10000,
      na.strings = character(0)  # Don't convert anything to NA automatically
    )
    
    if (!var_name %in% names(data)) return(result)
    
    col_data <- data[[var_name]]
    
    # Comprehensive NA values
    na_values <- c("", "NA", "N/A", "na", "n/a", "null", "NULL", "Null", 
                   "None", "none", "NONE", "undefined", "NaN", "-999", 
                   "missing", ".", "-")
    
    # Trim whitespace
    col_data <- trimws(col_data)
    
    # Filter NA-like values
    is_na <- is.na(col_data) | (col_data %in% na_values)
    col_clean <- col_data[!is_na]
    n_total <- length(col_data)
    n_clean <- length(col_clean)
    
    if (n_clean == 0) return(result)
    
    n_unique <- length(unique(col_clean))
    uniqueness_ratio <- n_unique / n_clean
    completeness <- n_clean / n_total
    
    result$required <- completeness > 0.95
    result$unique <- n_unique == n_clean
    
    # STEP 1: Identifier check
    if (detect_identifier(var_name, uniqueness_ratio, n_unique, n_clean)) {
      result$type <- "string"
      result$unique <- TRUE
      return(result)
    }
    
    # STEP 2: JSON check
    json_check <- detect_json_strings(col_clean)
    if (json_check$is_json) {
      result$type <- "string"
      result$pattern <- json_check$pattern
      return(result)
    }
    
    # STEP 3: Boolean check
    boolean_result <- detect_boolean(col_clean, n_unique, var_name)
    if (boolean_result$is_boolean) {
      result$type <- "boolean"
      result$categorical_values <- boolean_result$values
      return(result)
    }
    
    # STEP 4: Numeric check
    numeric_parsed <- try_parse_numeric(col_clean)
    if (numeric_parsed$success) {
      col_numeric <- numeric_parsed$values
      n_unique_num <- length(unique(col_numeric))
      uniqueness_ratio_num <- n_unique_num / length(col_numeric)
      
      numeric_result <- analyze_numeric(col_numeric, var_name, n_unique_num, uniqueness_ratio_num)
      return(modifyList(result, numeric_result))
    }
    
    # STEP 5: String/categorical check (skip date detection for simplicity)
    string_result <- analyze_string(col_clean, var_name, n_unique, uniqueness_ratio, n_clean)
    return(modifyList(result, string_result))
    
  }, error = function(e) {
    warning(paste("Error analyzing", var_name, ":", e$message))
    result
  })
}

# =========================================================================
# generateDescription
# =========================================================================
generateDescription <- function(var_name, var_analysis) {
  var_lower <- tolower(var_name)
  
  if (grepl("^(participant|subject|sub)_?(id|ID|Id)$|\\bparticipant_?id\\b", var_name)) {
    return("Unique identifier for each participant in the study")
  }
  if (grepl("\\bchild_?id\\b", var_lower)) return("Unique identifier for each child participant")
  if (grepl("\\bsession_?id\\b", var_lower)) return("Unique identifier for each session")
  if (grepl("\\btrial_?id\\b", var_lower)) return("Unique identifier for each trial")
  if (grepl("\\btrial_?type\\b", var_lower)) return("Type of trial or experimental event")
  if (grepl("\\btrial_?index\\b|\\btrial_?num(ber)?\\b", var_lower)) {
    return("Sequential trial number within the experiment")
  }
  if (grepl("\\btrial\\b", var_lower) && !grepl("type|index|id", var_lower)) {
    return("Trial number or trial identifier")
  }
  if (grepl("\\brt\\b|\\breaction_?time\\b|\\bresponse_?time\\b", var_lower)) {
    return("Response time or reaction time measurement")
  }
  if (grepl("\\btime_?elapsed\\b|\\belapsed_?time\\b", var_lower)) {
    return("Total time elapsed since experiment start")
  }
  if (grepl("\\btimestamp\\b", var_lower)) return("Timestamp of the event")
  if (grepl("\\btimeout\\b", var_lower)) return("Whether the trial timed out")
  if (grepl("\\bduration\\b", var_lower)) return("Duration of the event")
  if (grepl("\\bresponse\\b", var_lower) && !grepl("time", var_lower)) {
    return("Participant response or response value")
  }
  if (grepl("\\baccuracy\\b|\\bcorrect\\b|\\bacc\\b", var_lower)) {
    return("Accuracy or correctness of response")
  }
  if (grepl("\\bsuccess\\b", var_lower)) return("Whether the action was successful")
  if (grepl("\\bcondition\\b", var_lower)) return("Experimental condition or group assignment")
  if (grepl("\\bgroup\\b", var_lower)) return("Group assignment")
  if (grepl("\\bblock\\b", var_lower)) return("Block number in the experimental design")
  if (grepl("\\bsession\\b", var_lower) && !grepl("id", var_lower)) {
    return("Session number or session identifier")
  }
  if (grepl("\\bstimulus\\b|\\bstim\\b", var_lower)) {
    return("Stimulus identifier or stimulus information")
  }
  if (grepl("\\bage\\b", var_lower)) return("Age of the participant")
  if (grepl("\\bgender\\b|\\bsex\\b", var_lower)) {
    return("Gender or biological sex of the participant")
  }
  if (grepl("\\bscore\\b|\\brating\\b", var_lower)) return("Score or rating value")
  if (grepl("internal_node_id", var_lower)) {
    return("Internal node identifier from the experiment framework")
  }
  if (grepl("^failed_", var_lower)) {
    resource_type <- sub("^failed_", "", var_lower)
    return(paste0("List of ", resource_type, " resources that failed to load"))
  }
  
  type_desc <- switch(
    var_analysis$type,
    "integer" = "Numeric variable (whole numbers)",
    "number" = "Numeric variable (decimal numbers)",
    "boolean" = "Boolean variable (true/false)",
    "categorical" = "Categorical variable",
    "string" = "Text variable",
    "Unknown variable type"
  )
  
  paste0("Variable: ", var_name, " - ", type_desc)
}

#' Data Dictionary Server Module
#'
#' Server logic for the data dictionary editor
#'
#' @param id The module ID
#' @param state Global state reactive values
#' @param session The current session object
dataDictionaryServer <- function(id, state, session) {
  moduleServer(id, function(input, output, session) {

# Reactive values for dictionary state
dictionary_state <- reactiveValues(
  dataset_path = NULL,
  variables = list(),
  current_variable = NULL,
  variable_data = list(),
  is_modified = FALSE,
  editing_cat_index = NULL,  # Add this for tracking which categorical value is being edited
  missing_values = c("NA", "N/A", "null", "NULL", "-999", "missing", ""),
  sidecar_sources = character(0)  # Track which sidecar files contributed metadata
)

# Initialize categorical values storage for current variable
categorical_values <- reactiveVal(list())

# Function to save current variable state
saveCurrentVariable <- function() {
  if (!is.null(dictionary_state$current_variable)) {
    var_name <- dictionary_state$current_variable
    
    # Update variable data including new fields
    dictionary_state$variables[[var_name]]$description <- input$var_description %||% ""
    dictionary_state$variables[[var_name]]$type <- input$var_type %||% "string"
    dictionary_state$variables[[var_name]]$unit <- input$var_unit %||% ""
    
    # Only save min/max if checkbox is enabled
    if (input$var_use_minmax %||% FALSE) {
      dictionary_state$variables[[var_name]]$min_value <- input$var_min %||% ""
      dictionary_state$variables[[var_name]]$max_value <- input$var_max %||% ""
    } else {
      dictionary_state$variables[[var_name]]$min_value <- ""
      dictionary_state$variables[[var_name]]$max_value <- ""
    }
    
    dictionary_state$variables[[var_name]]$value_reference <- input$var_value_reference %||% ""
    dictionary_state$variables[[var_name]]$default_value <- input$var_default %||% ""
    dictionary_state$variables[[var_name]]$required <- input$var_required %||% FALSE
    dictionary_state$variables[[var_name]]$unique <- input$var_unique %||% FALSE
    dictionary_state$variables[[var_name]]$pattern <- input$var_pattern %||% ""
    dictionary_state$variables[[var_name]]$source <- input$var_source %||% ""
    dictionary_state$variables[[var_name]]$measurement_technique <- input$var_measurement_technique %||% ""
    dictionary_state$variables[[var_name]]$notes <- input$var_notes %||% ""
    
    # Save categorical values
    cat_values <- categorical_values()
    dictionary_state$variables[[var_name]]$categorical_values <- cat_values
    
    dictionary_state$is_modified <- TRUE
  }
}

observeEvent(input$remove_missing_value, {
  index <- input$remove_missing_value
  current_values <- dictionary_state$missing_values
  
  if (!is.null(index) && index > 0 && index <= length(current_values)) {
    dictionary_state$missing_values <- current_values[-index]
    showNotification("Missing value code removed", type = "message")
  }
}, ignoreInit = TRUE)

# Track if dataset is loaded
output$dataset_loaded <- reactive({
  !is.null(dictionary_state$dataset_path)
})
outputOptions(output, "dataset_loaded", suspendWhenHidden = FALSE)

# Track if variable is selected  
output$variable_selected <- reactive({
  !is.null(dictionary_state$current_variable)
})
outputOptions(output, "variable_selected", suspendWhenHidden = FALSE)

output$missing_values_table <- renderUI({
  values <- dictionary_state$missing_values
  
  if (length(values) == 0) {
    return(div(
      style = "padding: 20px; text-align: center; color: #6c757d;",
      "No missing value codes defined. Add common codes like NA, -999, etc."
    ))
  }
  
  rows <- lapply(seq_along(values), function(i) {
    div(
      style = "display: flex; padding: 8px; border-bottom: 1px solid #ced4da; align-items: center;",
      div(style = "flex: 3; padding-right: 10px; font-family: monospace;", values[i]),
      div(
        style = "flex: 0; width: 60px;",
        actionButton(
          session$ns(paste0("remove_missing_", i)),
          label = NULL,
          icon = icon("trash"),
          class = "btn btn-sm btn-danger",
          style = "padding: 2px 6px;",
          onclick = paste0("Shiny.setInputValue('", session$ns("remove_missing_value"), "', ", i, ", {priority: 'event'});")
        )
      )
    )
  })
  
  do.call(tagList, rows)
})

# Add missing value
observeEvent(input$add_missing_value, {
  if (!is.null(input$new_missing_value) && input$new_missing_value != "") {
    current_values <- dictionary_state$missing_values
    
    # Check for duplicates
    if (input$new_missing_value %in% current_values) {
      showNotification("This missing value code already exists", type = "warning")
      return()
    }
    
    # Add new value
    dictionary_state$missing_values <- c(current_values, input$new_missing_value)
    
    # Clear input
    updateTextInput(session, "new_missing_value", value = "")
    
    showNotification("Missing value code added", type = "message")
  } else {
    showNotification("Please enter a value", type = "warning")
  }
})

# Handler for Generate Human-Readable Dictionary button
observeEvent(input$generate_dictionary, {
  req(dictionary_state$dataset_path)
  
  ns <- session$ns
  
  showModal(modalDialog(
    title = "Generate Data Dictionary",
    size = "m",
    
    tags$div(
      tags$p("Generate a professional, printable data dictionary with all variable 
              definitions, descriptions, and metadata."),
      tags$br(),
      tags$div(
        class = "alert alert-info",
        style = "margin-bottom: 15px;",
        icon("file-alt", style = "margin-right: 8px;"),
        tags$strong("Format: "), "HTML Document",
        tags$br(),
        tags$small("Open in any browser • Print to PDF with Ctrl+P / Cmd+P")
      )
    ),
    
    checkboxInput(
      inputId = ns("include_stats"),
      label = "Include summary statistics (if available)",
      value = TRUE
    ),
    
    checkboxInput(
      inputId = ns("include_missing"),
      label = "Include global missing value codes",
      value = TRUE
    ),
    
    checkboxInput(
      inputId = ns("auto_open_browser"),
      label = "Automatically open in browser",
      value = TRUE
    ),
    
    footer = tagList(
      modalButton("Cancel"),
      actionButton(
        inputId = ns("confirm_generate"), 
        label = "Generate Dictionary", 
        class = "btn-primary",
        icon = icon("file-alt")
      )
    )
  ))
})

# Handler for confirming generation
observeEvent(input$confirm_generate, {
  ns <- session$ns
  removeModal()

  saveCurrentVariable()
  
  prog_id <- showNotification(
"Generating data dictionary...", 
duration = NULL, 
type = "message",
closeButton = FALSE
  )
  
  dict_data <- list(
variables = dictionary_state$variables,
missing_values = if(isTRUE(input$include_missing)) dictionary_state$missing_values else NULL
  )
  
  dataset_info <- NULL
  desc_file <- file.path(dictionary_state$dataset_path, "dataset_description.json")
  if (file.exists(desc_file)) {
tryCatch({
  dataset_info <- jsonlite::fromJSON(desc_file, simplifyVector = FALSE)
}, error = function(e) {})
  }
  
  output_file <- file.path(
dictionary_state$dataset_path,
paste0("data_dictionary_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".html")
  )
  
  result <- generate_dictionary_html(
dictionary_data = dict_data,
dataset_info = dataset_info,
output_file = output_file,
include_stats = input$include_stats
  )
  
  removeNotification(prog_id)
  
  if (result$success) {
    # Automatically open the dictionary in browser if user requested it
    if (isTRUE(input$auto_open_browser)) {
      browseURL(result$file)
    }
    
showModal(modalDialog(
  title = "Success!",
  size = "m",
  
  tags$div(
    tags$div(
      style = "text-align: center; margin-bottom: 20px;",
      icon("check-circle", style = "font-size: 48px; color: #28a745;")
    ),
    tags$h4("Dictionary Generated!", style = "text-align: center; color: #28a745;"),
    tags$hr(),
    tags$p("Saved to:"),
    tags$div(
      style = "background-color: #f8f9fa; padding: 12px; border-radius: 6px; 
               margin: 15px 0; word-break: break-all;",
      tags$code(style = "font-size: 12px;", result$file)
    ),
    if (isTRUE(input$auto_open_browser)) {
      tags$div(
        class = "alert alert-success",
        style = "margin-top: 15px;",
        icon("external-link-alt", style = "margin-right: 8px;"),
        tags$strong("Opened in browser!"), 
        tags$br(),
        "Press ", tags$kbd("Ctrl+P"), " / ", tags$kbd("Cmd+P"), " to save as PDF."
      )
    } else {
      tags$div(
        class = "alert alert-info",
        style = "margin-top: 15px;",
        icon("lightbulb", style = "margin-right: 8px;"),
        "Open the file above in your browser, then press ", tags$kbd("Ctrl+P"), " / ", 
        tags$kbd("Cmd+P"), " to save as PDF."
      )
    }
  ),
  
  footer = modalButton("Close"),
  easyClose = TRUE
))
  } else {
showNotification(
  paste("Failed to generate dictionary:", result$error),
  type = "error",
  duration = 10
)
  }
})





# Display dataset info
output$dataset_info <- renderUI({
  if (!is.null(dictionary_state$dataset_path)) {
    dataset_name <- basename(dictionary_state$dataset_path)
    variable_count <- length(dictionary_state$variables)
    sidecar_count <- length(dictionary_state$sidecar_sources)
    
    tagList(
      div(
        icon("check-circle", style = "color: #28a745; margin-right: 8px;"),
        strong("Dataset loaded: "), dataset_name,
        span(style = "margin-left: 15px; color: #6c757d;",
             paste(variable_count, "variables detected"))
      ),
      if (sidecar_count > 0) {
        div(
          style = "margin-top: 8px;",
          icon("file-code", style = "color: #9b59b6; margin-right: 8px;"),
          span(
            paste0("Metadata inherited from ", sidecar_count, " sidecar file",
                   if (sidecar_count > 1) "s" else "", ":"),
            style = "color: #6c757d;"
          ),
          div(
            style = "margin-top: 4px; margin-left: 28px;",
            lapply(dictionary_state$sidecar_sources, function(src) {
              div(
                tags$code(src, style = "font-size: 12px; background-color: #f3e8ff; color: #6b21a8; padding: 1px 6px; border-radius: 3px;"),
                style = "margin-bottom: 2px;"
              )
            })
          )
        )
      }
    )
  }
})

# Shared files-free directory browser (see directory_picker.R)
bindDirectoryBrowser(
  input, output, session,
  browse_button = "dataset_dir_select",
  target_input  = "dataset_dir",
  title = "Select a dataset directory"
)

# Load dataset when button clicked
observeEvent(input$load_dataset_btn, {
  dataset_path <- input$dataset_dir
  
  if (dataset_path == "" || !dir.exists(dataset_path)) {
    showNotification("Please select a valid dataset directory", type = "error")
    return()
  }
  
  # Check if it's a valid Psych-DS dataset
  if (!file.exists(file.path(dataset_path, "dataset_description.json"))) {
    showNotification("Selected directory does not contain dataset_description.json", type = "error")
    return()
  }
  
  if (!dir.exists(file.path(dataset_path, "data"))) {
    showNotification("Selected directory does not contain a 'data' folder", type = "error") 
    return()
  }
  
  # Load variables from data files (CSV/TSV)
  tryCatch({
    variables <- extractVariablesFromDataset(dataset_path)
    
    # Load existing metadata from JSON and merge with auto-detected info
    variables <- loadExistingMetadata(dataset_path, variables)
    
    # Collect unique metadata sources for display
    sources <- unique(unlist(lapply(variables, function(v) v$metadata_source)))
    sources <- sources[!is.na(sources)]
    sidecar_sources <- sources[sources != "dataset_description.json"]
    
    dictionary_state$dataset_path <- dataset_path
    dictionary_state$variables <- variables
    dictionary_state$current_variable <- NULL
    dictionary_state$sidecar_sources <- sidecar_sources
    
    removeModal()
    showNotification("Dataset loaded successfully! Running validation...", type = "message")
    
    # Store current validation result (if any) before starting new validation
    # This lets us ignore it and only process NEW results
    parent_session <- session$rootScope()
    if (!is.null(parent_session) && !is.null(parent_session$input$validation_results)) {
      old_result <- isolate(parent_session$input$validation_results)
      dict_validation$last_validation_result <- old_result
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("=== Data dictionary starting validation ===\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  Stored old validation result to ignore it\n")
      }
    } else {
      dict_validation$last_validation_result <- NULL
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("=== Data dictionary starting validation ===\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  No old validation result to ignore\n")
      }
    }
    
    # Run validation using JavaScript validator
    dict_validation$is_validating <- TRUE
    dict_validation$is_complete <- FALSE
    dict_validation$validation_start_time <- Sys.time()
    if (isTRUE(getOption("psychds.verbose"))) {
      cat("  Set dict_validation$is_validating to TRUE\n")
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      cat("  Dataset path:", dataset_path, "\n")
    }
    
    tryCatch({
      # Use the GLOBAL buildFileTree function (not buildFileTreeForValidation)
      # This function has progress bars and event processing to prevent crashes
      file_tree <- buildFileTree(dataset_path)
      
      # Send to JavaScript validator using the SAME handler as Validate tab
      session$sendCustomMessage("run_validation", file_tree)
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  Sent run_validation message\n")
      }
    }, error = function(e) {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("Error building file tree for validation:", e$message, "\n")
      }
      dict_validation$is_validating <- FALSE
      showNotification(
        paste("Validation failed:", e$message), 
        type = "error"
      )
    })
    
  }, error = function(e) {
    showNotification(paste("Error loading dataset:", e$message), type = "error")
  })
})

# Track validation results for data dictionary
dict_validation <- reactiveValues(
  is_validating = FALSE,
  is_complete = FALSE,
  is_valid = FALSE,
  errors = list(),
  warnings = list(),
  last_validation_result = NULL,  # Store previous result to detect changes
  validation_start_time = NULL     # Track when validation started
)

# Poll for validation results from parent session
validation_check <- reactiveTimer(500)  # Check every 500ms

observe({
  validation_check()  # Trigger on timer
  
  # Only check if we're expecting validation results
  if (!isTRUE(dict_validation$is_validating)) {
    return()
  }
  
  # Access parent session's validation results
  parent_session <- session$rootScope()
  
  if (!is.null(parent_session) && !is.null(parent_session$input$validation_results)) {
    validation_result <- isolate(parent_session$input$validation_results)
    
    # Check if this is a DIFFERENT result from what we had when we started
    # Compare the serialized results to detect any change
    current_result_json <- jsonlite::toJSON(validation_result, auto_unbox = TRUE)
    
    is_different_result <- TRUE
    if (!is.null(dict_validation$last_validation_result)) {
      old_result_json <- jsonlite::toJSON(dict_validation$last_validation_result, auto_unbox = TRUE)
      
      if (identical(current_result_json, old_result_json)) {
        is_different_result <- FALSE
      }
    }
    
    # Check how long we've been waiting
    time_waiting <- as.numeric(difftime(Sys.time(), dict_validation$validation_start_time, units = "secs"))
    
    # Process if result is different OR we've been waiting more than 3 seconds
    # (the timeout handles re-validating the same dataset)
    if (is_different_result) {
      # This is a NEW result - process it!
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("=== Data dictionary validation_results observer triggered ===\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  dict_validation$is_validating:", dict_validation$is_validating, "\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  validation result valid:", validation_result$valid, "\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  Processing NEW validation result (result changed)\n")
      }
      
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("Dictionary validation complete:", validation_result$valid, "\n")
      }
      
      dict_validation$is_validating <- FALSE
      dict_validation$is_complete <- TRUE
      dict_validation$is_valid <- validation_result$valid
      dict_validation$last_validation_result <- validation_result  # Store for next time
      
      if (!is.null(validation_result$issues)) {
        issues <- validation_result$issues
        dict_validation$errors <- issues$errors %||% list()
        dict_validation$warnings <- issues$warnings %||% list()
      }
    } else if (time_waiting > 3) {
      # Result is same, but we've waited long enough - assume it's a re-validation of same dataset
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("=== Data dictionary validation_results observer triggered ===\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  dict_validation$is_validating:", dict_validation$is_validating, "\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  validation result valid:", validation_result$valid, "\n")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("  Processing result after timeout (same dataset re-validated)\n")
      }
      
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("Dictionary validation complete:", validation_result$valid, "\n")
      }
      
      dict_validation$is_validating <- FALSE
      dict_validation$is_complete <- TRUE
      dict_validation$is_valid <- validation_result$valid
      dict_validation$last_validation_result <- validation_result
      
      if (!is.null(validation_result$issues)) {
        issues <- validation_result$issues
        dict_validation$errors <- issues$errors %||% list()
        dict_validation$warnings <- issues$warnings %||% list()
      }
    }
  } else if (!is.null(dict_validation$validation_start_time)) {
    # No result in parent session yet, check if we've been waiting too long
    time_waiting <- as.numeric(difftime(Sys.time(), dict_validation$validation_start_time, units = "secs"))
    if (time_waiting > 5) {
      if (isTRUE(getOption("psychds.verbose"))) {
        cat("=== Validation timeout - no results received ===\n")
      }
      dict_validation$is_validating <- FALSE
      showNotification("Validation timed out. Please try again.", type = "warning")
    }
  }
})

# Render validation status

# Function to load existing metadata from dataset_description.json and sidecar files
# Precedence (highest wins): file-level sidecar > directory-level sidecar > global
loadExistingMetadata <- function(dataset_path, variables) {
  # Normalize to expanded absolute path so tilde paths match list.files() output
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  if (isTRUE(getOption("psychds.verbose"))) {
    message("\n=== LOAD EXISTING METADATA ===")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  dataset_path: ", dataset_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  variables to process: ", length(variables))
  }
  
  # --- 1. Apply global metadata from dataset_description.json ---
  global_json <- file.path(dataset_path, "dataset_description.json")
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  Global JSON path: ", global_json)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  Global JSON exists: ", file.exists(global_json))
  }
  if (file.exists(global_json)) {
    tryCatch({
      json_data <- jsonlite::read_json(global_json, simplifyVector = FALSE)
      vm_count <- length(json_data$variableMeasured)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Global variableMeasured entries: ", vm_count)
      }
      if (vm_count > 0) {
        vm_names <- sapply(json_data$variableMeasured, function(x) {
          if (is.character(x) || is.atomic(x)) as.character(x) else x$name %||% "<unnamed>"
        })
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  Global variableMeasured names: ", paste(head(vm_names, 10), collapse = ", "),
                if (length(vm_names) > 10) paste0("... (", length(vm_names), " total)") else "")
        }
      }
      variables <- applyVariableMeasured(variables, json_data$variableMeasured, "dataset_description.json",
                                         source_path = global_json)
      
      # Load global missing value codes if present
      if (!is.null(json_data$missingValueCodes)) {
        dictionary_state$missing_values <- unlist(json_data$missingValueCodes)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  Loaded ", length(json_data$missingValueCodes), " missing value codes")
        }
      }
    }, error = function(e) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  ERROR loading global metadata: ", e$message)
      }
      warning(paste("Error loading global metadata:", e$message))
    })
  }
  
  # --- 2. Discover and apply sidecar metadata ---
  data_dir <- file.path(dataset_path, "data")
  if (isTRUE(getOption("psychds.verbose"))) {
    message("\n  Data dir: ", data_dir)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  Data dir exists: ", dir.exists(data_dir))
  }
  if (dir.exists(data_dir)) {
    sidecars <- discoverSidecarFiles(dataset_path)
    
    if (isTRUE(getOption("psychds.verbose"))) {
      message("\n  --- Applying directory-level sidecars (", length(sidecars$directory), " found) ---")
    }
    # Apply directory-level sidecars (shallowest first, so deeper dirs override)
    for (sc in sidecars$directory) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Processing dir sidecar: ", sc$label)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Path: ", sc$path)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Applies to dir: ", sc$applies_to_dir)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Depth: ", sc$depth)
      }
      tryCatch({
        json_data <- jsonlite::read_json(sc$path, simplifyVector = FALSE)
        vm_count <- length(json_data$variableMeasured)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    variableMeasured entries in sidecar: ", vm_count)
        }
        if (vm_count > 0) {
          vm_names <- sapply(json_data$variableMeasured, function(x) { if (is.character(x) || is.atomic(x)) as.character(x) else x$name %||% "<unnamed>" })
          if (isTRUE(getOption("psychds.verbose"))) {
            message("    variableMeasured names: ", paste(vm_names, collapse = ", "))
          }
        }
        # Only apply to variables that appear in files under this directory
        applicable_vars <- filterVariablesByDirectory(variables, sc$applies_to_dir, dataset_path)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    Applicable variables (in this dir): ", length(applicable_vars))
        }
        if (length(applicable_vars) > 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("    Applicable var names: ", paste(head(applicable_vars, 10), collapse = ", "),
                  if (length(applicable_vars) > 10) "..." else "")
          }
        }
        variables <- applyVariableMeasured(variables, json_data$variableMeasured, sc$label,
                                           only_vars = applicable_vars, source_path = sc$path)
      }, error = function(e) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    ERROR loading directory sidecar: ", e$message)
        }
        warning(paste("Error loading directory sidecar", sc$path, ":", e$message))
      })
    }
    
    if (isTRUE(getOption("psychds.verbose"))) {
      message("\n  --- Applying file-level sidecars (", length(sidecars$file_level), " found) ---")
    }
    # Apply file-level sidecars (highest precedence)
    for (sc in sidecars$file_level) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Processing file sidecar: ", sc$label)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Path: ", sc$path)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Applies to file: ", sc$applies_to_file)
      }
      tryCatch({
        json_data <- jsonlite::read_json(sc$path, simplifyVector = FALSE)
        vm_count <- length(json_data$variableMeasured)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    variableMeasured entries in sidecar: ", vm_count)
        }
        if (vm_count > 0) {
          vm_names <- sapply(json_data$variableMeasured, function(x) { if (is.character(x) || is.atomic(x)) as.character(x) else x$name %||% "<unnamed>" })
          if (isTRUE(getOption("psychds.verbose"))) {
            message("    variableMeasured names: ", paste(vm_names, collapse = ", "))
          }
        }
        # Only apply to variables that appear in the matched data file
        applicable_vars <- filterVariablesByFile(variables, sc$applies_to_file, dataset_path)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    Applicable variables (in this file): ", length(applicable_vars))
        }
        if (length(applicable_vars) > 0) {
          if (isTRUE(getOption("psychds.verbose"))) {
            message("    Applicable var names: ", paste(head(applicable_vars, 10), collapse = ", "),
                  if (length(applicable_vars) > 10) "..." else "")
          }
        }
        variables <- applyVariableMeasured(variables, json_data$variableMeasured, sc$label,
                                           only_vars = applicable_vars, source_path = sc$path)
      }, error = function(e) {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("    ERROR loading file sidecar: ", e$message)
        }
        warning(paste("Error loading file sidecar", sc$path, ":", e$message))
      })
    }
  }
  
  # Summary of metadata sources
  sources <- unique(unlist(lapply(variables, function(v) v$metadata_source)))
  sources <- sources[!is.na(sources)]
  if (isTRUE(getOption("psychds.verbose"))) {
    message("\n  Final metadata sources: ", paste(sources, collapse = ", "))
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  Variables with metadata_source set: ", 
          sum(sapply(variables, function(v) !is.null(v$metadata_source))))
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("=== END LOAD EXISTING METADATA ===\n")
  }
  
  return(variables)
}

# Apply a variableMeasured list to the variables, optionally restricted to certain variable names
applyVariableMeasured <- function(variables, var_measured_list, source_label, only_vars = NULL, source_path = NULL) {
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    applyVariableMeasured from '", source_label, "'")
  }
  if (is.null(var_measured_list)) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      variableMeasured is NULL — skipping")
    }
    return(variables)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Entries to process: ", length(var_measured_list))
  }
  if (!is.null(only_vars)) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      Restricted to ", length(only_vars), " variable(s)")
    }
  }
  
  applied_count <- 0
  skipped_not_found <- 0
  skipped_not_applicable <- 0
  skipped_string_only <- 0
  
  for (var_meta in var_measured_list) {
    # Handle simple string entries (just a variable name, no metadata to apply)
    if (is.character(var_meta) || is.atomic(var_meta)) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      SKIP string entry: '", var_meta, "' (no metadata to apply)")
      }
      skipped_string_only <- skipped_string_only + 1
      next
    }
    
    var_name <- var_meta$name
    if (is.null(var_name)) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      SKIP: entry has no 'name' field")
      }
      next
    }
    if (!(var_name %in% names(variables))) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      SKIP '", var_name, "': not found in detected variables")
      }
      skipped_not_found <- skipped_not_found + 1
      next
    }
    if (!is.null(only_vars) && !(var_name %in% only_vars)) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      SKIP '", var_name, "': not in applicable vars for this scope")
      }
      skipped_not_applicable <- skipped_not_applicable + 1
      next
    }
    
    # Convert Schema.org valueType to internal type
    schema_type <- var_meta$valueType %||% var_meta$`@type`
    if (!is.null(schema_type) && tolower(schema_type) != "propertyvalue") {
      internal_type <- switch(
        tolower(schema_type),
        "text" = "string",
        "string" = "string",
        "number" = "number",
        "float" = "number",
        "integer" = "integer",
        "boolean" = "boolean",
        "date" = "date",
        "datetime" = "datetime",
        "categorical" = "categorical",
        variables[[var_name]]$type  # default to auto-detected
      )
      variables[[var_name]]$type <- internal_type
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      '", var_name, "': type -> ", internal_type, " (from schema '", schema_type, "')")
      }
    }
    
    # Override auto-detected values — only overwrite if the sidecar actually provides the field
    fields_set <- character(0)
    if (!is.null(var_meta$description))  { variables[[var_name]]$description <- var_meta$description; fields_set <- c(fields_set, "description") }
    if (!is.null(var_meta$unitText))     { variables[[var_name]]$unit <- var_meta$unitText; fields_set <- c(fields_set, "unit") }
    if (!is.null(var_meta$minValue))     { variables[[var_name]]$min_value <- as.character(var_meta$minValue); fields_set <- c(fields_set, "minValue") }
    if (!is.null(var_meta$maxValue))     { variables[[var_name]]$max_value <- as.character(var_meta$maxValue); fields_set <- c(fields_set, "maxValue") }
    if (!is.null(var_meta$required))     { variables[[var_name]]$required <- var_meta$required; fields_set <- c(fields_set, "required") }
    if (!is.null(var_meta$unique))       { variables[[var_name]]$unique <- var_meta$unique; fields_set <- c(fields_set, "unique") }
    if (!is.null(var_meta$pattern))      { variables[[var_name]]$pattern <- var_meta$pattern; fields_set <- c(fields_set, "pattern") }
    if (!is.null(var_meta$measurementTechnique)) { variables[[var_name]]$measurement_technique <- var_meta$measurementTechnique; fields_set <- c(fields_set, "measurementTechnique") }
    # Load categorical values from valueReference
    if (!is.null(var_meta$valueReference) && length(var_meta$valueReference) > 0) {
      cat_vals <- lapply(var_meta$valueReference, function(vr) {
        list(
          value = as.character(vr$value %||% ""),
          label = as.character(vr$label %||% vr$value %||% ""),
          description = as.character(vr$description %||% "")
        )
      })
      variables[[var_name]]$categorical_values <- cat_vals
      fields_set <- c(fields_set, paste0("valueReference(", length(cat_vals), ")"))
    }
    
    # Track the source of this variable's metadata
    variables[[var_name]]$metadata_source <- source_label
    variables[[var_name]]$metadata_source_path <- source_path
    applied_count <- applied_count + 1
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      APPLIED '", var_name, "': fields=[", paste(fields_set, collapse = ", "), "] source='", source_label, "'")
    }
  }
  
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Summary: applied=", applied_count, " skipped_not_found=", skipped_not_found, 
          " skipped_not_applicable=", skipped_not_applicable, " skipped_string_only=", skipped_string_only)
  }
  
  return(variables)
}

# Discover all sidecar metadata files in the data directory
# Returns list with $directory (sorted shallowest-first) and $file_level
discoverSidecarFiles <- function(dataset_path) {
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  data_dir <- file.path(dataset_path, "data")
  result <- list(directory = list(), file_level = list())
  
  if (isTRUE(getOption("psychds.verbose"))) {
    message("\n  === DISCOVER SIDECAR FILES ===")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    data_dir: ", data_dir)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    data_dir exists: ", dir.exists(data_dir))
  }
  
  if (!dir.exists(data_dir)) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("    Data dir missing — returning empty")
    }
    return(result)
  }
  
  # Find all JSON files under data/
  all_json <- list.files(data_dir, pattern = "\\.json$", recursive = TRUE, full.names = TRUE)
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    JSON files found under data/: ", length(all_json))
  }
  for (jf in all_json) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      ", jf)
    }
  }
  
  # Find all data files for matching
  all_data <- list.files(data_dir, pattern = "\\.(csv|tsv)$", recursive = TRUE, full.names = TRUE)
  data_basenames <- tools::file_path_sans_ext(all_data)
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    Data files found: ", length(all_data))
  }
  for (df in all_data) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      ", df)
    }
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    Data basenames (sans ext): ", length(data_basenames))
  }
  for (db in data_basenames) {
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      ", db)
    }
  }
  
  for (json_file in all_json) {
    json_basename <- basename(json_file)
    json_dir <- dirname(json_file)
    if (isTRUE(getOption("psychds.verbose"))) {
      message("\n    Evaluating: ", json_file)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      basename: ", json_basename)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("      dirname: ", json_dir)
    }
    
    if (json_basename == "file_metadata.json") {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      -> DIRECTORY-LEVEL sidecar (file_metadata.json)")
      }
      # Directory-level sidecar
      data_dir_norm <- normalizePath(data_dir, mustWork = FALSE)
      json_dir_norm <- normalizePath(json_dir, mustWork = FALSE)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      data_dir normalized: ", data_dir_norm)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      json_dir normalized: ", json_dir_norm)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      dirs equal: ", json_dir_norm == data_dir_norm)
      }
      
      rel_dir <- if (json_dir_norm == data_dir_norm) {
        "data"
      } else {
        file.path("data", sub(paste0(data_dir_norm, "/"), "", json_dir_norm, fixed = TRUE))
      }
      depth <- length(strsplit(rel_dir, "/")[[1]])
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      rel_dir: ", rel_dir)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      depth: ", depth)
      }
      
      result$directory <- c(result$directory, list(list(
        path = json_file,
        applies_to_dir = json_dir,
        depth = depth,
        label = file.path(rel_dir, "file_metadata.json")
      )))
    } else {
      # Check if this JSON matches a data file (file-level sidecar)
      json_sans_ext <- tools::file_path_sans_ext(json_file)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      json sans ext: ", json_sans_ext)
      }
      match_idx <- which(data_basenames == json_sans_ext)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      match_idx: ", paste(match_idx, collapse = ", "), 
              " (", length(match_idx), " match(es))")
      }
      
      if (length(match_idx) > 0) {
        matched_data_file <- all_data[match_idx[1]]
        rel_path <- sub(paste0(dataset_path, "/"), "",
                         normalizePath(json_file, mustWork = FALSE), fixed = TRUE)
        rel_data <- sub(paste0(dataset_path, "/"), "",
                         normalizePath(matched_data_file, mustWork = FALSE), fixed = TRUE)
        
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      -> FILE-LEVEL sidecar")
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      matched data file: ", matched_data_file)
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      rel json path: ", rel_path)
        }
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      rel data path: ", rel_data)
        }
        
        result$file_level <- c(result$file_level, list(list(
          path = json_file,
          applies_to_file = matched_data_file,
          label = rel_path
        )))
      } else {
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      -> NO MATCH (not a sidecar)")
        }
      }
    }
  }
  
  # Sort directory-level sidecars by depth (shallowest first)
  if (length(result$directory) > 0) {
    depths <- sapply(result$directory, function(x) x$depth)
    result$directory <- result$directory[order(depths)]
    if (isTRUE(getOption("psychds.verbose"))) {
      message("\n    Directory sidecars after sorting by depth:")
    }
    for (sc in result$directory) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("      depth=", sc$depth, " ", sc$label)
      }
    }
  }
  
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    RESULT: ", length(result$directory), " directory sidecar(s), ", 
          length(result$file_level), " file-level sidecar(s)")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("  === END DISCOVER SIDECAR FILES ===\n")
  }
  
  return(result)
}

# Get variable names that appear in files under a given directory
filterVariablesByDirectory <- function(variables, dir_path, dataset_path) {
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  dir_norm <- normalizePath(dir_path, mustWork = FALSE)
  # Ensure trailing separator so /data/exp doesn't match /data/experiment/
  if (!endsWith(dir_norm, .Platform$file.sep)) {
    dir_norm <- paste0(dir_norm, .Platform$file.sep)
  }
  
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    filterVariablesByDirectory:")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      dir_path: ", dir_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      dir_norm (with sep): ", dir_norm)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      dataset_path (normalized): ", dataset_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Total variables to check: ", length(variables))
  }
  
  matching_vars <- character(0)
  for (var_name in names(variables)) {
    var_files <- variables[[var_name]]$files
    for (f in var_files) {
      # f may be relative (data/file.csv) or absolute — handle both
      if (startsWith(f, "/") || startsWith(f, "~")) {
        full_path <- normalizePath(f, mustWork = FALSE)
      } else {
        full_path <- normalizePath(file.path(dataset_path, f), mustWork = FALSE)
      }
      if (startsWith(full_path, dir_norm)) {
        matching_vars <- c(matching_vars, var_name)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      MATCH: '", var_name, "' via file '", f, "' -> '", full_path, "'")
        }
        break
      }
    }
  }
  
  result <- unique(matching_vars)
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Result: ", length(result), " matching variables")
  }
  return(result)
}

# Get variable names that appear in a specific data file
filterVariablesByFile <- function(variables, file_path, dataset_path) {
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  file_norm <- normalizePath(file_path, mustWork = FALSE)
  rel_path <- sub(paste0(dataset_path, "/"), "", file_norm, fixed = TRUE)
  
  if (isTRUE(getOption("psychds.verbose"))) {
    message("    filterVariablesByFile:")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      file_path: ", file_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      file_norm: ", file_norm)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      dataset_path (normalized): ", dataset_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      rel_path: ", rel_path)
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Total variables to check: ", length(variables))
  }
  
  matching_vars <- character(0)
  for (var_name in names(variables)) {
    var_files <- variables[[var_name]]$files
    for (f in var_files) {
      # f may be relative (data/file.csv) or absolute — handle both
      if (startsWith(f, "/") || startsWith(f, "~")) {
        f_norm <- normalizePath(f, mustWork = FALSE)
      } else {
        f_norm <- normalizePath(file.path(dataset_path, f), mustWork = FALSE)
      }
      if (f_norm == file_norm || f == rel_path) {
        matching_vars <- c(matching_vars, var_name)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("      MATCH: '", var_name, "' via stored file '", f, "' (norm: '", f_norm, "')")
        }
        break
      }
    }
  }
  
  result <- unique(matching_vars)
  if (isTRUE(getOption("psychds.verbose"))) {
    message("      Result: ", length(result), " matching variables")
  }
  return(result)
}

# Function to validate dataset
validateDataset <- function(dataset_path) {
  result <- list(
    valid = FALSE,
    errors = character(),
    warnings = character(),
    timed_out = FALSE
  )
  
  tryCatch({
    # Check basic structure
    if (!file.exists(file.path(dataset_path, "dataset_description.json"))) {
      result$errors <- c(result$errors, "Missing dataset_description.json")
      return(result)
    }
    
    if (!dir.exists(file.path(dataset_path, "data"))) {
      result$errors <- c(result$errors, "Missing data directory")
      return(result)
    }
    
    # Try to parse JSON
    json_data <- jsonlite::read_json(
      file.path(dataset_path, "dataset_description.json"),
      simplifyVector = FALSE
    )
    
    # Check required fields
    if (is.null(json_data$name)) {
      result$warnings <- c(result$warnings, "Dataset name not specified")
    }
    
    if (is.null(json_data$description)) {
      result$warnings <- c(result$warnings, "Dataset description not specified")
    }
    
    # If we got here, basic validation passed
    result$valid <- TRUE
    
  }, error = function(e) {
    result$errors <- c(result$errors, paste("Validation error:", e$message))
  })
  
  return(result)
}

# Render validation status
output$validation_status <- renderUI({
  req(dictionary_state$dataset_path)
  
  # Check if validation is in progress
  if (dict_validation$is_validating) {
    return(div(
      class = "alert alert-info",
      style = "margin-bottom: 20px;",
      icon("spinner", class = "fa-spin", style = "margin-right: 8px;"),
      tags$strong("Validating..."),
      " Running Psych-DS validation"
    ))
  }
  
  # Check if validation has completed
  if (!dict_validation$is_complete) {
    return(div(
      class = "alert alert-info",
      style = "margin-bottom: 20px;",
      icon("info-circle", style = "margin-right: 8px;"),
      "Dataset loaded. Validation will run automatically."
    ))
  }
  
  # Show validation results
  if (!dict_validation$is_valid || length(dict_validation$errors) > 0) {
    div(
      class = "alert alert-danger",
      style = "margin-bottom: 20px;",
      icon("times-circle", style = "margin-right: 8px;"),
      tags$strong("Dataset Invalid"),
      " - Does not pass Psych-DS validation",
      if (length(dict_validation$errors) > 0) {
        tags$ul(
          style = "margin-top: 10px; margin-bottom: 0;",
          lapply(dict_validation$errors, function(err) {
            error_text <- if (!is.null(err$reason)) err$reason else if (!is.null(err$message)) err$message else "Unknown error"
            tags$li(error_text)
          })
        )
      },
      tags$div(
        style = "margin-top: 15px; padding: 10px; background-color: #fff3cd; border-left: 4px solid #856404; border-radius: 4px;",
        tags$p(
          style = "margin: 0; color: #856404;",
          icon("exclamation-triangle", style = "margin-right: 5px;"),
          tags$strong("Important: "),
          "The data dictionary editing features may not work properly with an invalid dataset. ",
          "Please go to the ",
          tags$strong("Validate Dataset"),
          " tab to see detailed validation errors and fix them before proceeding."
        ),
        tags$p(
          style = "margin: 5px 0 0 0; color: #856404; font-size: 0.9em;",
          icon("info-circle", style = "margin-right: 3px;"),
          "Validation results are automatically shared across tabs"
        )
      )
    )
  } else if (length(dict_validation$warnings) > 0) {
    div(
      class = "alert alert-warning",
      style = "margin-bottom: 20px; background-color: #fff3cd; border-color: #ffc107;",
      icon("exclamation-triangle", style = "margin-right: 8px;"),
      tags$strong("Dataset Valid with Warnings"),
      tags$ul(
        style = "margin-top: 10px; margin-bottom: 5px;",
        lapply(dict_validation$warnings, function(warn) {
          warn_text <- if (!is.null(warn$reason)) warn$reason else if (!is.null(warn$message)) warn$message else warn
          tags$li(warn_text)
        })
      ),
      tags$small(
        class = "text-muted",
        style = "margin-top: 5px; display: block;",
        icon("info-circle", style = "margin-right: 3px; font-size: 0.9em;"),
        "Full validation details are available in the ",
        tags$strong("Validate Dataset"),
        " tab"
      )
    )
  } else {
    div(
      class = "alert alert-success",
      style = "margin-bottom: 20px;",
      icon("check-circle", style = "margin-right: 8px;"),
      tags$strong("Dataset Valid"),
      " - Passes Psych-DS validation",
      tags$br(),
      tags$small(
        class = "text-muted",
        style = "margin-top: 5px; display: block;",
        icon("info-circle", style = "margin-right: 3px; font-size: 0.9em;"),
        "Validation results are available in the ",
        tags$strong("Validate Dataset"),
        " tab for detailed information"
      )
    )
  }
})

# Function to auto-populate categorical values
autoPopulateCategoricalValues <- function(var_name) {
  if (is.null(dictionary_state$dataset_path) || is.null(var_name)) return()
  
  var_info <- dictionary_state$variables[[var_name]]
  all_values <- character(0)
  
  # Read values from all files containing this variable
  withProgress(message = "Detecting categorical values...", value = 0, {
    data_dir <- file.path(dictionary_state$dataset_path, "data")
    csv_files <- list.files(data_dir, pattern = "\\.(csv|tsv)$", recursive = TRUE, full.names = TRUE)
    
    for (i in seq_along(csv_files)) {
      setProgress(i / length(csv_files))
      tryCatch({
        data <- read_data_file(csv_files[i], stringsAsFactors = FALSE, nrows = 1000)
        if (var_name %in% names(data)) {
          col_values <- unique(data[[var_name]][!is.na(data[[var_name]])])
          all_values <- c(all_values, as.character(col_values))
        }
      }, error = function(e) {})
    }
  })
  
  unique_values <- unique(all_values)
  
  if (length(unique_values) > 0 && length(unique_values) <= 50) {
    # Create categorical values with default labels and descriptions
    cat_values <- lapply(sort(unique_values), function(val) {
      list(
        value = val,
        label = val,  # Default label same as value
        description = ""  # Empty description for user to fill
      )
    })
    categorical_values(cat_values)
    showNotification(paste("Auto-populated", length(unique_values), "categorical values"), type = "message")
  } else if (length(unique_values) > 50) {
    showNotification("Too many unique values (>50) to auto-populate. Add manually.", type = "warning")
  }
}

# Extract variables from dataset with enhanced analysis
extractVariablesFromDataset <- function(dataset_path) {
  # Normalize to expanded absolute path so it matches list.files() output
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  data_dir <- file.path(dataset_path, "data")
  data_files <- list.files(data_dir, pattern = "\\.(csv|tsv)$", recursive = TRUE, full.names = TRUE)
  
  if (length(data_files) == 0) {
    stop("No CSV or TSV files found in data directory")
  }
  
  all_variables <- list()
  
  withProgress(message = "Analyzing dataset variables...", value = 0, {
    for (i in seq_along(data_files)) {
      data_file <- data_files[i]
      # Use fixed = TRUE to avoid regex issues with special chars in paths
      rel_path <- sub(paste0(dataset_path, "/"), "", data_file, fixed = TRUE)
      
      setProgress(i / length(data_files), detail = paste("Processing", basename(data_file)))
      
      tryCatch({
        # Read just the header first
        header <- names(read_data_file(data_file, nrows = 1))
        
        for (var_name in header) {
          if (var_name %in% names(all_variables)) {
            # Variable exists - just add this file
            all_variables[[var_name]]$files <- c(all_variables[[var_name]]$files, rel_path)
          } else {
            # Create new variable entry with enhanced analysis
            var_analysis <- analyzeVariable(data_file, var_name)
            
            all_variables[[var_name]] <- list(
              name = var_name,
              files = rel_path,
              description = generateDescription(var_name, var_analysis),
              type = var_analysis$type,
              unit = var_analysis$unit,
              min_value = var_analysis$min_value,
              max_value = var_analysis$max_value,
              value_reference = var_analysis$value_reference,
              default_value = "",
              source = "",
              notes = "",
              categorical_values = var_analysis$categorical_values,  # Store initial values
              required = FALSE,
              unique = FALSE,
              pattern = "",
              measurement_technique = ""
            )
          }
        }
      }, error = function(e) {
        warning(paste("Could not read file:", data_file, "-", e$message))
      })
    }
  })
  
  # Second pass: For all categorical variables, collect actual values from all files
  categorical_vars <- names(all_variables)[sapply(all_variables, function(v) v$type == "categorical")]
  
  if (length(categorical_vars) > 0) {
    withProgress(message = "Collecting categorical values...", value = 0, {
      for (i in seq_along(categorical_vars)) {
        var_name <- categorical_vars[i]
        setProgress(i / length(categorical_vars), detail = paste("Processing", var_name))
        
        all_values <- character(0)
        
        # Read from all data files
        for (data_file in data_files) {
          tryCatch({
            # Read up to 1000 rows to collect values
            data <- read_data_file(data_file, stringsAsFactors = FALSE, nrows = 1000)
            if (var_name %in% names(data)) {
              # Get non-NA values
              col_values <- data[[var_name]]
              col_values <- col_values[!is.na(col_values) & col_values != ""]
              all_values <- c(all_values, as.character(col_values))
            }
          }, error = function(e) {})
        }
        
        # Get unique values
        unique_values <- unique(all_values)
        
        # Only populate if we have a reasonable number of unique values
        if (length(unique_values) > 0 && length(unique_values) <= 50) {
          cat_values <- lapply(sort(unique_values), function(val) {
            list(
              value = val,
              label = val,
              description = ""
            )
          })
          all_variables[[var_name]]$categorical_values <- cat_values
        }
      }
    })
  }
  
  return(all_variables)
}





# Render variables list
output$variables_list <- renderUI({
  variables <- dictionary_state$variables
  search_term <- input$variable_search
  
  if (length(variables) == 0) {
    return(div(
      style = "text-align: center; padding: 50px 20px; color: #6c757d;",
      p("No variables found. Load a dataset to begin.")
    ))
  }
  
  # Filter variables based on search
  if (!is.null(search_term) && search_term != "") {
    variables <- variables[grepl(search_term, names(variables), ignore.case = TRUE)]
  }
  
  if (length(variables) == 0) {
    return(div(
      style = "text-align: center; padding: 30px 20px; color: #6c757d;",
      p("No variables match your search.")
    ))
  }
  
  # Create variable list items
  variable_items <- lapply(names(variables), function(var_name) {
    var_info <- variables[[var_name]]
    file_count <- length(var_info$files)
    is_selected <- identical(dictionary_state$current_variable, var_name)
    
    div(
      class = if (is_selected) "variable-item selected" else "variable-item",
      style = paste0(
        "padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #f0f0f0; ",
        if (is_selected) "background-color: #3498db; color: white;" else "background-color: white; color: #333;",
        if (match(var_name, names(variables)) %% 2 == 0 && !is_selected) " background-color: #f8f9fa;" else ""
      ),
      onclick = paste0("Shiny.setInputValue('", session$ns("select_variable"), "', '", var_name, "', {priority: 'event'});"),
      
      div(
        style = "display: flex; justify-content: space-between; align-items: center;",
        div(
          style = "display: flex; align-items: center; gap: 6px;",
          span(var_name, style = "font-weight: 500;"),
          if (!is.null(var_info$metadata_source) && var_info$metadata_source != "dataset_description.json") {
            span(
              icon("file-code"),
              title = paste("Metadata from:", var_info$metadata_source),
              style = paste0("font-size: 11px; cursor: help; ",
                           if (is_selected) "color: rgba(255,255,255,0.8);" else "color: #9b59b6;")
            )
          }
        ),
        span(
          paste(file_count, if (file_count == 1) "file" else "files"),
          style = paste0("font-size: 12px; ", if (is_selected) "color: rgba(255,255,255,0.8);" else "color: #6c757d;")
        )
      )
    )
  })
  
  do.call(tagList, variable_items)
})

# Update the variable selection handler to include new fields:
observeEvent(input$select_variable, {
  # Save current variable before switching
  saveCurrentVariable()

  var_name <- input$select_variable
  
  if (!is.null(var_name) && var_name %in% names(dictionary_state$variables)) {
    dictionary_state$current_variable <- var_name
    
    # Load variable data into form
    var_info <- dictionary_state$variables[[var_name]]
    
    updateTextAreaInput(session, "var_description", value = var_info$description %||% "")
    updateSelectInput(session, "var_type", selected = var_info$type %||% "string")
    updateTextInput(session, "var_unit", value = var_info$unit %||% "")
    updateTextInput(session, "var_min", value = var_info$min_value %||% "")
    updateTextInput(session, "var_max", value = var_info$max_value %||% "")
    updateTextAreaInput(session, "var_value_reference", value = var_info$value_reference %||% "")
    updateTextInput(session, "var_default", value = var_info$default_value %||% "")

    has_minmax <- nchar(var_info$min_value %||% "") > 0 || 
                  nchar(var_info$max_value %||% "") > 0
    updateCheckboxInput(session, "var_use_minmax", value = has_minmax)
    # Update new fields
    updateTextInput(session, "var_source", value = var_info$source %||% "")
    updateTextAreaInput(session, "var_notes", value = var_info$notes %||% "")
    updateCheckboxInput(session, "var_required", value = var_info$required %||% FALSE)
    updateCheckboxInput(session, "var_unique", value = var_info$unique %||% FALSE)
    updateTextInput(session, "var_pattern", value = var_info$pattern %||% "")

    # Set categorical values
    if (!is.null(var_info$categorical_values) && length(var_info$categorical_values) > 0) {
      categorical_values(var_info$categorical_values)
    } else if (var_info$type == "categorical" && length(var_info$categorical_values) == 0) {
      # Auto-populate from data if switching to categorical
      autoPopulateCategoricalValues(var_name)
    } else {
      categorical_values(list())
    }
  }
})

# Add observer for when type changes to categorical
observeEvent(input$var_type, {
  if (!is.null(input$var_type) && input$var_type == "categorical") {
    # If switching to categorical and no values exist, auto-populate
    if (length(categorical_values()) == 0 && !is.null(dictionary_state$current_variable)) {
      autoPopulateCategoricalValues(dictionary_state$current_variable)
    }
  }
})

# Render variable name header
output$variable_name_header <- renderUI({
  if (!is.null(dictionary_state$current_variable)) {
    var_info <- dictionary_state$variables[[dictionary_state$current_variable]]
    tagList(
      h3(
        dictionary_state$current_variable,
        style = "color: #333; margin: 0; font-weight: bold;"
      ),
      if (!is.null(var_info$metadata_source)) {
        div(
          style = "margin-top: 4px;",
          span(
            icon("file-code", style = "font-size: 11px; margin-right: 4px;"),
            paste("Metadata from:", var_info$metadata_source),
            style = "font-size: 12px; color: #6c757d; font-style: italic;"
          )
        )
      }
    )
  }
})

output$file_badges_content <- renderUI({
  if (!is.null(dictionary_state$current_variable)) {
    var_info <- dictionary_state$variables[[dictionary_state$current_variable]]
    
    tagList(
      lapply(var_info$files, function(file_path) {
        span(
          basename(file_path),
          class = "badge",
          title = file_path,
          style = "background-color: #3498db; color: white; padding: 4px 8px; border-radius: 12px; font-size: 11px; cursor: help; margin: 2px;"
        )
      }),
      if (length(var_info$files) > 10) {
        div(
          style = "margin-top: 8px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 12px; color: #666; text-align: center;",
          paste("Total:", length(var_info$files), "files")
        )
      }
    )
  }
})

# Update the categorical values table renderer:
output$categorical_values_table <- renderUI({
  values <- categorical_values()
  
  if (length(values) == 0) {
    return(div(
      style = "padding: 20px; text-align: center; color: #6c757d;",
      "No categorical values defined. They will be auto-populated when you select 'Categorical' as the type."
    ))
  }
  
  rows <- lapply(seq_along(values), function(i) {
    value_info <- values[[i]]
    div(
      style = "display: flex; padding: 8px; border-bottom: 1px solid #ced4da; align-items: center;",
      div(style = "flex: 2; padding-right: 10px; font-weight: 500;", value_info$value),
      div(style = "flex: 2; padding-right: 10px;", value_info$label %||% ""),
      div(style = "flex: 3; padding-right: 10px;", value_info$description %||% ""),
      div(
        style = "flex: 0; width: 80px; display: flex; gap: 5px;",
        actionButton(
          session$ns(paste0("edit_cat_", i)),
          label = NULL,
          icon = icon("edit"),
          class = "btn btn-sm btn-info",
          style = "padding: 2px 6px;"
        ),
        actionButton(
          session$ns(paste0("remove_cat_", i)),
          label = NULL,
          icon = icon("trash"),
          class = "btn btn-sm btn-danger",
          style = "padding: 2px 6px;",
          onclick = paste0("Shiny.setInputValue('", session$ns("remove_cat_value"), "', ", i, ", {priority: 'event'});")
        )
      )
    )
  })
  
  do.call(tagList, rows)
})

# Handle editing categorical values
observe({
  values <- categorical_values()
  
  for (i in seq_along(values)) {
    local({
      local_i <- i
      
      # Edit button handler
      observeEvent(input[[paste0("edit_cat_", local_i)]], {
        current_values <- categorical_values()
        if (local_i <= length(current_values)) {
          value_info <- current_values[[local_i]]
          
          showModal(modalDialog(
            title = "Edit Categorical Value",
            div(
              textInput(
                session$ns("edit_cat_value"),
                "Value",
                value = value_info$value,
                placeholder = "The actual value in the data"
              ),
              textInput(
                session$ns("edit_cat_label"),
                "Label",
                value = value_info$label %||% "",
                placeholder = "Human-readable label for this value"
              ),
              textInput(
                session$ns("edit_cat_description"),
                "Description",
                value = value_info$description %||% "",
                placeholder = "Description of what this value means"
              )
            ),
            footer = tagList(
              modalButton("Cancel"),
              actionButton(session$ns("save_edit_cat"), "Save", class = "btn-primary")
            ),
            easyClose = TRUE
          ))
          
          # Store which index we're editing
          dictionary_state$editing_cat_index <- local_i
        }
      }, ignoreInit = TRUE)
    })
  }
})

# Save edited categorical value
observeEvent(input$save_edit_cat, {
  if (!is.null(dictionary_state$editing_cat_index)) {
    current_values <- categorical_values()
    index <- dictionary_state$editing_cat_index
    
    if (index <= length(current_values)) {
      # Update the value at the index
      current_values[[index]] <- list(
        value = input$edit_cat_value,
        label = input$edit_cat_label,
        description = input$edit_cat_description
      )
      
      categorical_values(current_values)
      removeModal()
      showNotification("Categorical value updated", type = "message")
    }
  }
})



# Update the add categorical value handler:
observeEvent(input$add_cat_value, {
  if (!is.null(input$new_cat_value) && input$new_cat_value != "") {
    current_values <- categorical_values()
    
    # Check for duplicates
    existing_values <- sapply(current_values, function(x) x$value)
    if (input$new_cat_value %in% existing_values) {
      showNotification("This value already exists", type = "warning")
      return()
    }
    
    # Add new value with label
    new_value <- list(
      value = input$new_cat_value,
      label = if(is.null(input$new_cat_label) || input$new_cat_label == "") {
        input$new_cat_value  # Default label to value if not provided
      } else {
        input$new_cat_label
      },
      description = input$new_cat_description %||% ""
    )
    
    categorical_values(c(current_values, list(new_value)))
    
    # Clear inputs
    updateTextInput(session, "new_cat_value", value = "")
    updateTextInput(session, "new_cat_label", value = "")
    updateTextInput(session, "new_cat_description", value = "")
    
    showNotification("Categorical value added", type = "message")
  } else {
    showNotification("Please enter a value", type = "warning")
  }
})

# Handle removing categorical values
observeEvent(input$remove_cat_value, {
  index <- input$remove_cat_value
  current_values <- categorical_values()
  
  if (!is.null(index) && index > 0 && index <= length(current_values)) {
    categorical_values(current_values[-index])
    showNotification("Categorical value removed", type = "message")
  }
}, ignoreInit = TRUE)

# Shared helper: build a Psych-DS PropertyValue object from internal variable info
buildPropertyValue <- function(var_name, var_info, global_missing_values = NULL) {
  prop_value <- list(
    `@type` = "PropertyValue",
    name = var_name,
    description = if(nchar(var_info$description) > 0) var_info$description else NULL,
    valueType = var_info$type
  )
  
  if (!is.null(global_missing_values) && length(global_missing_values) > 0) {
    prop_value$missingValueCodes <- global_missing_values
  }
  
  if (nchar(var_info$unit) > 0) prop_value$unitText <- var_info$unit
  if (nchar(var_info$min_value) > 0) prop_value$minValue <- var_info$min_value
  if (nchar(var_info$max_value) > 0) prop_value$maxValue <- var_info$max_value
  
  if (var_info$type == "categorical" && length(var_info$categorical_values) > 0) {
    prop_value$valueReference <- I(lapply(var_info$categorical_values, function(cat) {
      cat_obj <- list(
        value = cat$value,
        label = if(nchar(cat$label) > 0 && cat$label != cat$value) cat$label else NULL,
        description = if(nchar(cat$description) > 0) cat$description else NULL
      )
      cat_obj[!sapply(cat_obj, is.null)]
    }))
  }
  
  prop_value$required <- var_info$required %||% FALSE
  prop_value$unique <- var_info$unique %||% FALSE
  if (nchar(var_info$pattern) > 0) prop_value$pattern <- var_info$pattern
  if (!is.null(var_info$measurement_technique) && nchar(var_info$measurement_technique %||% "") > 0) {
    prop_value$measurementTechnique <- var_info$measurement_technique
  }
  prop_value[!sapply(prop_value, is.null)]
}

# Partition variables into global vs sidecar groups based on metadata_source_path
partitionVariablesBySaveTarget <- function(variables, dataset_path) {
  dataset_path <- normalizePath(dataset_path, mustWork = FALSE)
  global_json <- file.path(dataset_path, "dataset_description.json")
  
  groups <- list()  # keyed by file path
  
  for (var_name in names(variables)) {
    var_info <- variables[[var_name]]
    target <- var_info$metadata_source_path
    
    # If no sidecar source, or source is global, goes to dataset_description.json
    if (is.null(target) || normalizePath(target, mustWork = FALSE) == global_json) {
      target_key <- global_json
    } else {
      target_key <- normalizePath(target, mustWork = FALSE)
    }
    
    if (is.null(groups[[target_key]])) {
      groups[[target_key]] <- character(0)
    }
    groups[[target_key]] <- c(groups[[target_key]], var_name)
  }
  
  groups
}

generateFullDatasetDescriptionPreview <- function() {
  # Read existing dataset_description.json to get name, description, authors
  json_path <- file.path(dictionary_state$dataset_path, "dataset_description.json")
  
  dataset_info <- list(
    name = "Dataset Name",
    description = "Dataset Description",
    authors = list()
  )
  
  if (file.exists(json_path)) {
    tryCatch({
      existing <- jsonlite::fromJSON(json_path)
      if (!is.null(existing$name)) dataset_info$name <- existing$name
      if (!is.null(existing$description)) dataset_info$description <- existing$description
      if (!is.null(existing$author)) dataset_info$authors <- existing$author
    }, error = function(e) {})
  }
  
  # Partition variables by save target
  global_json <- normalizePath(json_path, mustWork = FALSE)
  groups <- partitionVariablesBySaveTarget(dictionary_state$variables, dictionary_state$dataset_path)
  global_var_names <- groups[[global_json]] %||% character(0)
  
  # Build variableMeasured array (only global variables)
  global_missing_values <- dictionary_state$missing_values
  
  variable_measured <- lapply(global_var_names, function(var_name) {
    var_info <- dictionary_state$variables[[var_name]]
    buildPropertyValue(var_name, var_info, global_missing_values)
  })
  
  # Build complete dataset_description
  full_json <- list(
    `@context` = "https://schema.org/",
    `@type` = "Dataset",
    name = dataset_info$name,
    description = dataset_info$description,
    author = dataset_info$authors,
    variableMeasured = variable_measured
  )
  
  # Convert to JSON string
  json_str <- jsonlite::toJSON(full_json, pretty = TRUE, auto_unbox = TRUE)
  
  # Add syntax highlighting
  json_html <- json_str
  json_html <- gsub('"(@?[^"]+)":', '<span style="color: #0969da;">\"\\1\"</span>:', json_html)
  json_html <- gsub(':\\s*"([^"]*)"', ': <span style="color: #0a3069;">\"\\1\"</span>', json_html)
  json_html <- gsub(':\\s*(true|false)', ': <span style="color: #cf222e;">\\1</span>', json_html)
  json_html <- gsub(':\\s*([0-9.]+)', ': <span style="color: #953800;">\\1</span>', json_html)
  
  return(json_html)
}

# Generate a syntax-highlighted JSON preview of what will be saved to a sidecar file
generateSidecarPreview <- function(sc_path, sc_var_names, global_missing_values) {
  # Read existing sidecar to preserve non-variableMeasured fields
  if (file.exists(sc_path)) {
    tryCatch({
      sc_json <- jsonlite::read_json(sc_path, simplifyVector = FALSE)
    }, error = function(e) {
      sc_json <<- list()
    })
  } else {
    sc_json <- list()
  }
  
  # Build updated variableMeasured — same logic as save handler
  existing_vm <- sc_json$variableMeasured %||% list()
  kept_entries <- list()
  for (entry in existing_vm) {
    if (is.character(entry) || is.atomic(entry)) {
      if (!(as.character(entry) %in% sc_var_names)) {
        kept_entries <- c(kept_entries, list(entry))
      }
    } else {
      entry_name <- entry$name
      if (!is.null(entry_name) && !(entry_name %in% sc_var_names)) {
        kept_entries <- c(kept_entries, list(entry))
      }
    }
  }
  
  updated_entries <- lapply(sc_var_names, function(var_name) {
    var_info <- dictionary_state$variables[[var_name]]
    buildPropertyValue(var_name, var_info, global_missing_values)
  })
  
  sc_json$variableMeasured <- c(kept_entries, updated_entries)
  
  # Convert to JSON string
  json_str <- jsonlite::toJSON(sc_json, pretty = TRUE, auto_unbox = TRUE)
  
  # Add syntax highlighting (same as global preview)
  json_html <- json_str
  json_html <- gsub('"(@?[^"]+)":', '<span style="color: #0969da;">\"\\1\"</span>:', json_html)
  json_html <- gsub(':\\s*"([^"]*)"', ': <span style="color: #0a3069;">\"\\1\"</span>', json_html)
  json_html <- gsub(':\\s*(true|false)', ': <span style="color: #cf222e;">\\1</span>', json_html)
  json_html <- gsub(':\\s*([0-9.]+)', ': <span style="color: #953800;">\\1</span>', json_html)
  
  return(json_html)
}


observeEvent(input$continue, {
  
  # Save current variable before proceeding
  saveCurrentVariable()
  
  # Generate the dataset_description.json preview (global vars only)
  full_json_preview <- generateFullDatasetDescriptionPreview()
  
  # Default save path
  default_save_path <- file.path(dictionary_state$dataset_path, "dataset_description.json")
  
  # Compute sidecar save targets for display
  dataset_path_norm <- normalizePath(dictionary_state$dataset_path, mustWork = FALSE)
  global_json_norm <- normalizePath(default_save_path, mustWork = FALSE)
  groups <- partitionVariablesBySaveTarget(dictionary_state$variables, dictionary_state$dataset_path)
  sidecar_groups <- groups[names(groups) != global_json_norm]
  global_var_count <- length(groups[[global_json_norm]] %||% character(0))
  
  # Build sidecar info UI with collapsible JSON previews
  sidecar_info_ui <- NULL
  if (length(sidecar_groups) > 0) {
    global_missing_values <- dictionary_state$missing_values
    
    sidecar_items <- lapply(seq_along(sidecar_groups), function(idx) {
      sc_path <- names(sidecar_groups)[idx]
      sc_vars <- sidecar_groups[[sc_path]]
      rel_path <- sub(paste0(dataset_path_norm, "/"), "", sc_path, fixed = TRUE)
      
      # Generate JSON preview for this sidecar
      sc_preview_json <- generateSidecarPreview(sc_path, sc_vars, global_missing_values)
      
      tags$details(
        style = "margin-bottom: 6px; border: 1px solid #e9d5ff; border-radius: 6px; overflow: hidden;",
        tags$summary(
          style = paste0(
            "padding: 10px 14px; cursor: pointer; background-color: #f3e8ff; ",
            "display: flex; justify-content: space-between; align-items: center; ",
            "list-style: none; user-select: none;"
          ),
          div(
            style = "display: flex; align-items: center; gap: 8px;",
            icon("chevron-right", class = "sidecar-chevron", 
                 style = "color: #9b59b6; font-size: 11px; transition: transform 0.2s;"),
            icon("file-code", style = "color: #9b59b6; font-size: 13px;"),
            tags$code(rel_path, style = "font-size: 12px; color: #6b21a8; background: transparent;")
          ),
          div(
            style = "display: flex; align-items: center; gap: 10px;",
            span(paste0(length(sc_vars), " variable", if(length(sc_vars) > 1) "s" else ""),
                 style = "font-size: 12px; color: #6c757d;"),
            span("Click to preview", style = "font-size: 11px; color: #9b59b6; font-style: italic;")
          )
        ),
        div(
          style = "padding: 0 14px 12px 14px; background-color: #faf5ff;",
          div(
            style = "margin-top: 8px; margin-bottom: 6px; font-size: 11px; color: #6c757d;",
            paste("Variables:", paste(sc_vars, collapse = ", "))
          ),
          tags$pre(
            style = "background-color: #f8f9fa; padding: 12px; border-radius: 4px; max-height: 250px; overflow-y: auto; font-family: monospace; font-size: 11px; margin: 0; border: 1px solid #e2e8f0;",
            HTML(sc_preview_json)
          )
        )
      )
    })
    
    sidecar_info_ui <- div(
      style = "margin-top: 15px;",
      div(
        style = "margin-bottom: 8px; display: flex; align-items: center; gap: 6px;",
        icon("file-code", style = "color: #9b59b6;"),
        tags$strong(paste0("Sidecar files to update (", length(sidecar_groups), "):")),
        span(paste0(sum(sapply(sidecar_groups, length)), " variables total"),
             style = "color: #6c757d; font-size: 12px; margin-left: 4px;")
      ),
      do.call(tagList, sidecar_items),
      tags$small(
        style = "color: #6c757d; display: block; margin-top: 6px;",
        "These variables were loaded from sidecar metadata and will be saved back to their source files."
      )
    )
  }
  
  # Default copy path suggestion
  dataset_basename <- basename(dictionary_state$dataset_path)
  parent_dir <- dirname(normalizePath(dictionary_state$dataset_path, mustWork = FALSE))
  suggested_copy_path <- file.path(parent_dir, paste0(dataset_basename, "_updated"))
  
  # Count files that will be written
  files_to_write <- 1 + length(sidecar_groups)  # dataset_description.json + sidecars
  
  # Build file list for display
  files_list_items <- list(
    tags$li(tags$code("dataset_description.json"),
            span(paste0(" (", global_var_count, " variables)"), style = "color: #6c757d;"))
  )
  for (sc_path_key in names(sidecar_groups)) {
    sc_rel <- sub(paste0(dataset_path_norm, "/"), "", sc_path_key, fixed = TRUE)
    sc_count <- length(sidecar_groups[[sc_path_key]])
    files_list_items <- c(files_list_items, list(
      tags$li(tags$code(sc_rel),
              span(paste0(" (", sc_count, " variables)"), style = "color: #6c757d;"))
    ))
  }
  
  # Unique ID for the copy-path section (for JS toggle)
  copy_section_id <- session$ns("copy_dir_section")
  
  # Show preview modal
  showModal(modalDialog(
    title = "Review & Save Data Dictionary",
    size = "l",
    
    # CSS for collapsible preview chevrons
    tags$style(HTML("
      details[open] > summary .sidecar-chevron {
        transform: rotate(90deg);
      }
      details > summary::-webkit-details-marker { display: none; }
      details > summary::marker { display: none; content: ''; }
    ")),
    
    div(
      # Global dataset_description.json preview (collapsible, open by default)
      tags$details(
        open = "open",
        style = "margin-bottom: 6px; border: 1px solid #bee5eb; border-radius: 6px; overflow: hidden;",
        tags$summary(
          style = paste0(
            "padding: 10px 14px; cursor: pointer; background-color: #e8f4f8; ",
            "display: flex; justify-content: space-between; align-items: center; ",
            "list-style: none; user-select: none;"
          ),
          div(
            style = "display: flex; align-items: center; gap: 8px;",
            icon("chevron-right", class = "sidecar-chevron",
                 style = "color: #3498db; font-size: 11px; transition: transform 0.2s;"),
            icon("file-alt", style = "color: #3498db; font-size: 13px;"),
            tags$code("dataset_description.json", style = "font-size: 12px; color: #0969da; background: transparent;")
          ),
          span(paste0(global_var_count, " variable", if(global_var_count != 1) "s" else ""),
               style = "font-size: 12px; color: #6c757d;")
        ),
        div(
          style = "padding: 0 14px 12px 14px; background-color: #f0f9ff;",
          tags$pre(
            style = "background-color: #f8f9fa; padding: 12px; border-radius: 4px; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 11px; margin: 8px 0 0 0; border: 1px solid #e2e8f0;",
            HTML(full_json_preview)
          )
        )
      ),
      
      # Sidecar info (if any)
      sidecar_info_ui,
      
      # Summary stats
      div(
        style = "margin-top: 15px; padding: 10px; background-color: #e8f4f8; border-radius: 4px;",
        p(strong("Summary:")),
        p(paste("Total variables:", length(dictionary_state$variables))),
        p(paste("Variables with descriptions:", 
                sum(sapply(dictionary_state$variables, function(v) nchar(v$description) > 0)))),
        p(paste("Categorical variables:", 
                sum(sapply(dictionary_state$variables, function(v) v$type == "categorical")))),
        p(paste("Missing value codes defined:", length(dictionary_state$missing_values)))
      ),
      
      # --- Save options ---
      tags$hr(style = "margin: 20px 0;"),
      
      div(
        style = "margin-top: 10px;",
        
        # Info about what will be written
        div(
          class = "alert alert-info",
          style = "margin-bottom: 15px;",
          icon("info-circle", style = "margin-right: 8px;"),
          tags$strong(paste0("Saving will update ", files_to_write, 
                            " file", if(files_to_write > 1) "s" else "", ":")),
          tags$ul(style = "margin: 8px 0 0 0; padding-left: 20px; font-size: 13px;",
                  do.call(tagList, files_list_items))
        ),
        
        # Copy option
        div(
          style = "padding: 14px; border: 1px solid #dee2e6; border-radius: 6px; background-color: #f8f9fa;",
          
          checkboxInput(
            session$ns("save_to_copy"),
            label = tagList(
              icon("copy", style = "margin-right: 6px; color: #6c757d;"),
              tags$strong("Save to a copy of the dataset"),
              span(" — preserves all original files", style = "color: #6c757d;")
            ),
            value = FALSE
          ),
          
          tags$small(
            style = "display: block; color: #6c757d; margin-top: -8px; margin-bottom: 10px; margin-left: 24px;",
            "Creates a full copy of the dataset directory and writes all changes there instead."
          ),
          
          # Copy directory input (hidden by default)
          div(
            id = copy_section_id,
            style = "display: none; margin-top: 12px; padding: 12px; background-color: #fff; border: 1px solid #dee2e6; border-radius: 4px;",
            tags$label("Copy location:", style = "font-weight: bold; margin-bottom: 6px; display: block; font-size: 13px;"),
            textInput(
              session$ns("copy_dir_path"),
              label = NULL,
              value = suggested_copy_path,
              width = "100%"
            ),
            tags$small(
              style = "color: #6c757d; display: block; margin-top: -8px;",
              "The entire dataset will be copied to this directory. A new directory will be created if it doesn't exist."
            )
          )
        )
      )
    ),
    
    # JS to toggle the copy dir section based on checkbox
    tags$script(HTML(paste0("
      $(document).on('change', '#", session$ns("save_to_copy"), "', function() {
        var section = document.getElementById('", copy_section_id, "');
        if (this.checked) {
          section.style.display = 'block';
        } else {
          section.style.display = 'none';
        }
      });
    "))),
    
    footer = tagList(
      modalButton("Back to Editing"),
      actionButton(session$ns("confirm_save_dictionary"), "Save & Continue to Explorer", class = "btn-primary")
    ),
    easyClose = TRUE
  ))
})

observeEvent(input$confirm_save_dictionary, {
  save_to_copy <- isTRUE(input$save_to_copy)
  
  tryCatch({
    dataset_path_norm <- normalizePath(dictionary_state$dataset_path, mustWork = FALSE)
    global_json_norm <- normalizePath(
      file.path(dictionary_state$dataset_path, "dataset_description.json"), mustWork = FALSE
    )
    global_missing_values <- dictionary_state$missing_values
    
    # --- Determine the target directory ---
    if (save_to_copy) {
      copy_dir <- input$copy_dir_path
      if (is.null(copy_dir) || trimws(copy_dir) == "") {
        showNotification("Please specify a directory for the copy.", type = "error")
        return()
      }
      copy_dir <- trimws(copy_dir)
      copy_dir_norm <- normalizePath(copy_dir, mustWork = FALSE)
      
      # Don't allow copying over itself
      if (copy_dir_norm == dataset_path_norm) {
        showNotification("Copy directory cannot be the same as the original dataset.", type = "error")
        return()
      }
      
      # Copy entire dataset directory
      if (isTRUE(getOption("psychds.verbose"))) {
        message("\n=== COPYING DATASET ===")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  From: ", dataset_path_norm)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  To:   ", copy_dir_norm)
      }
      
      if (dir.exists(copy_dir_norm)) {
        # Directory exists — warn but proceed (files will be overwritten)
        if (isTRUE(getOption("psychds.verbose"))) {
          message("  Target directory already exists, files will be merged/overwritten")
        }
      }
      
      dir.create(copy_dir_norm, recursive = TRUE, showWarnings = FALSE)
      
      # Copy all files recursively
      all_files <- list.files(dataset_path_norm, recursive = TRUE, all.files = TRUE, full.names = TRUE)
      for (src_file in all_files) {
        rel <- sub(paste0(dataset_path_norm, "/"), "", src_file, fixed = TRUE)
        dest_file <- file.path(copy_dir_norm, rel)
        dir.create(dirname(dest_file), recursive = TRUE, showWarnings = FALSE)
        file.copy(src_file, dest_file, overwrite = TRUE)
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Copied ", length(all_files), " files")
      }
      if (isTRUE(getOption("psychds.verbose"))) {
        message("=== END COPYING DATASET ===\n")
      }
      
      target_dir <- copy_dir_norm
    } else {
      target_dir <- dataset_path_norm
    }
    
    # --- Remap save paths to target directory ---
    # For copy mode, we need to translate original paths to their counterparts in the copy
    remapPath <- function(original_path) {
      if (!save_to_copy) return(original_path)
      rel <- sub(paste0(dataset_path_norm, "/"), "", 
                 normalizePath(original_path, mustWork = FALSE), fixed = TRUE)
      file.path(target_dir, rel)
    }
    
    json_path <- file.path(target_dir, "dataset_description.json")
    
    # Partition variables by save target
    groups <- partitionVariablesBySaveTarget(dictionary_state$variables, dictionary_state$dataset_path)
    sidecar_groups <- groups[names(groups) != global_json_norm]
    global_var_names <- groups[[global_json_norm]] %||% character(0)
    
    if (isTRUE(getOption("psychds.verbose"))) {
      message("\n=== SAVING DATA DICTIONARY ===")
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Mode: ", if(save_to_copy) "copy" else "overwrite in place")
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Target dir: ", target_dir)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Global save path: ", json_path)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Global variables: ", length(global_var_names))
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Sidecar targets: ", length(sidecar_groups))
    }
    
    # --- 1. Save sidecar-sourced variables back to their sidecar files ---
    sidecar_save_count <- 0
    for (sc_path in names(sidecar_groups)) {
      sc_var_names <- sidecar_groups[[sc_path]]
      sc_target <- remapPath(sc_path)
      rel_path <- sub(paste0(target_dir, "/"), "", sc_target, fixed = TRUE)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("  Saving to sidecar: ", rel_path, " (", length(sc_var_names), " variables)")
      }
      
      # Read from the target (which is the copy if copying, or original if overwriting)
      if (file.exists(sc_target)) {
        sc_json <- jsonlite::read_json(sc_target, simplifyVector = FALSE)
      } else {
        sc_json <- list()
      }
      
      # Keep existing entries that we're NOT updating
      existing_vm <- sc_json$variableMeasured %||% list()
      kept_entries <- list()
      for (entry in existing_vm) {
        if (is.character(entry) || is.atomic(entry)) {
          if (!(as.character(entry) %in% sc_var_names)) {
            kept_entries <- c(kept_entries, list(entry))
          }
        } else {
          entry_name <- entry$name
          if (!is.null(entry_name) && !(entry_name %in% sc_var_names)) {
            kept_entries <- c(kept_entries, list(entry))
          }
        }
      }
      
      # Build PropertyValue entries for our variables
      updated_entries <- lapply(sc_var_names, function(var_name) {
        var_info <- dictionary_state$variables[[var_name]]
        buildPropertyValue(var_name, var_info, global_missing_values)
      })
      
      sc_json$variableMeasured <- c(kept_entries, updated_entries)
      jsonlite::write_json(sc_json, sc_target, pretty = TRUE, auto_unbox = TRUE)
      sidecar_save_count <- sidecar_save_count + 1
      if (isTRUE(getOption("psychds.verbose"))) {
        message("    Saved ", length(sc_var_names), " variables to ", rel_path)
      }
    }
    
    # --- 2. Save global variables to dataset_description.json ---
    original_json_path <- file.path(dictionary_state$dataset_path, "dataset_description.json")
    # Read from original (always has the base metadata like name, authors, etc.)
    if (file.exists(original_json_path)) {
      dataset_desc <- jsonlite::fromJSON(original_json_path)
    } else {
      dataset_desc <- list()
    }
    
    dataset_desc$variableMeasured <- lapply(global_var_names, function(var_name) {
      var_info <- dictionary_state$variables[[var_name]]
      buildPropertyValue(var_name, var_info, global_missing_values)
    })
    
    jsonlite::write_json(dataset_desc, json_path, pretty = TRUE, auto_unbox = TRUE)
    if (isTRUE(getOption("psychds.verbose"))) {
      message("  Saved ", length(global_var_names), " variables to ", json_path)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("=== END SAVING DATA DICTIONARY ===\n")
    }
    
    # Store the dataset path in state for the explorer (point to the copy if we made one)
    state$dictionary_dataset_dir <- target_dir
    
    removeModal()
    
    # Build notification message
    total_files <- 1 + sidecar_save_count
    if (save_to_copy) {
      msg <- paste0("Dataset copied and dictionary saved to: ", basename(target_dir), "/ ",
                     "(", total_files, " metadata file", if(total_files > 1) "s" else "", " updated)")
    } else if (sidecar_save_count > 0) {
      sidecar_var_total <- sum(sapply(sidecar_groups, length))
      msg <- paste0("Data dictionary saved! ", length(global_var_names), 
                     " variables to dataset_description.json, ",
                     sidecar_var_total, " variables to ", sidecar_save_count, 
                     " sidecar file", if(sidecar_save_count > 1) "s" else "", ".")
    } else {
      msg <- paste("Data dictionary saved successfully to:", json_path)
    }
    showNotification(msg, type = "message", duration = 6)
    
    # Navigate to dataset explorer
    session$sendCustomMessage("changeTab", list(tabName = "explorer"))
    
  }, error = function(e) {
    message("  ERROR saving dictionary: ", e$message)
    showNotification(paste("Error saving dictionary:", e$message), type = "error")
  })
})

# Return reactive containing dictionary state
return(reactive({ dictionary_state }))
  })
}

#' Dataset Explorer Server Module
#'
#' @param id The module ID
#' @param state Global state reactive values  
#' @param session The current session object
datasetExplorerServer <- function(id, state, session) {
  moduleServer(id, function(input, output, session) {

# Reactive values for explorer state
explorer_state <- reactiveValues(
  dataset_path = NULL,
  data_files = list(),
  current_file = NULL,
  current_data = NULL,
  keyword_filters = list(),
  column_filters = list(),
  available_keywords = character(0),
  available_columns = character(0)
)

# Track if dataset is loaded
output$dataset_loaded <- reactive({
  !is.null(explorer_state$dataset_path) && length(explorer_state$data_files) > 0
})
outputOptions(output, "dataset_loaded", suspendWhenHidden = FALSE)

# Shared files-free directory browser (see directory_picker.R)
bindDirectoryBrowser(
  input, output, session,
  browse_button = "dataset_dir_select",
  target_input  = "dataset_dir",
  title = "Select a dataset directory"
)

extractKeywordValues <- function(keyword) {
  values <- character(0)
  
  for (file in explorer_state$data_files) {
    filename <- basename(file)
    # Look for pattern like "keyword-value"
    pattern <- paste0(keyword, "-([^_]+)")
    matches <- regmatches(filename, regexec(pattern, filename))
    if (length(matches[[1]]) > 1) {
      values <- c(values, matches[[1]][2])
    }
  }
  
  return(unique(sort(values)))
}

# Display dataset info
output$dataset_info <- renderUI({
  if (!is.null(explorer_state$dataset_path) && length(explorer_state$data_files) > 0) {
    dataset_name <- basename(explorer_state$dataset_path)
    file_count <- length(explorer_state$data_files)
    
    div(
      icon("check-circle", style = "color: #28a745; margin-right: 8px;"),
      strong("Dataset loaded: "), dataset_name,
      span(style = "margin-left: 15px; color: #6c757d;",
           paste(file_count, "data files found"))
    )
  } else if (!is.null(explorer_state$dataset_path)) {
    div(
      icon("exclamation-triangle", style = "color: #ffc107; margin-right: 8px;"),
      strong("Dataset path selected: "), basename(explorer_state$dataset_path),
      span(style = "margin-left: 15px; color: #dc3545;",
           "No data files found in data directory")
    )
  }
})

# Load dataset when button clicked
observeEvent(input$load_dataset_btn, {
  dataset_path <- input$dataset_dir
  
  if (dataset_path == "" || !dir.exists(dataset_path)) {
    showNotification("Please select a valid dataset directory", type = "error")
    return()
  }
  
  # Check if it's a valid Psych-DS dataset
  if (!file.exists(file.path(dataset_path, "dataset_description.json"))) {
    showNotification("Selected directory does not contain dataset_description.json", type = "error")
    return()
  }
  
  if (!dir.exists(file.path(dataset_path, "data"))) {
    showNotification("Selected directory does not contain a 'data' folder", type = "error") 
    return()
  }
  
  # Load data files from data directory
  tryCatch({
    data_dir <- file.path(dataset_path, "data")
    # Use recursive = TRUE to find CSV and TSV files in subdirectories
    data_files <- list.files(data_dir, pattern = "\\.(csv|tsv)$", recursive = TRUE, full.names = TRUE, ignore.case = TRUE)
    
    if (isTRUE(getOption("psychds.verbose"))) {
      message("Looking for data files in: ", data_dir)
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("Found files: ", paste(data_files, collapse = ", "))
    }
    
    if (length(data_files) == 0) {
      # Try looking without the recursive flag first to debug
      all_files <- list.files(data_dir, recursive = TRUE, full.names = TRUE)
      if (isTRUE(getOption("psychds.verbose"))) {
        message("All files in data dir: ", paste(all_files, collapse = ", "))
      }
      
      showNotification("No CSV or TSV files found in data directory", type = "warning")
      explorer_state$dataset_path <- dataset_path
      explorer_state$data_files <- character(0)
      return()
    }
    
    # Extract keywords from filenames
    keywords <- extractKeywordsFromFilenames(basename(data_files))
    
    explorer_state$dataset_path <- dataset_path
    explorer_state$data_files <- data_files
    explorer_state$available_keywords <- keywords
    explorer_state$current_file <- data_files[1]
    
    # Load the first file
    loadCurrentFile()
    
    # Update UI choices
    updateSelectInput(session, "keyword_select", choices = c("Select keyword..." = "", keywords))
    
    showNotification(paste("Dataset loaded successfully!", length(data_files), "data files found"), type = "message")
    
  }, error = function(e) {
    message("Error in loading dataset: ", e$message)
    showNotification(paste("Error loading dataset:", e$message), type = "error")
  })
})

observeEvent(input$keyword_select, {
  if (!is.null(input$keyword_select) && 
      input$keyword_select != "" && 
      input$keyword_select != "(Load dataset first)") {
    
    # Extract all possible values for this keyword
    possible_values <- extractKeywordValues(input$keyword_select)
    
    # Update the selectize input with these values
    updateSelectizeInput(session, "keyword_value", 
                        choices = possible_values,
                        server = TRUE)  # Use server-side for better performance with many options
  } else {
    # Clear choices if no keyword selected
    updateSelectizeInput(session, "keyword_value", choices = character(0))
  }
})

observeEvent(input$column_select, {
  if (!is.null(input$column_select) && 
      input$column_select != "" && 
      input$column_select != "(Load dataset first)" &&
      !is.null(explorer_state$current_data)) {
    
    # Get unique values from the selected column across all loaded files
    all_values <- character(0)
    
    for (file in explorer_state$data_files) {
      tryCatch({
        temp_data <- read_data_file(file, stringsAsFactors = FALSE)
        if (input$column_select %in% names(temp_data)) {
          column_values <- as.character(temp_data[[input$column_select]])
          # Keep blank values explicitly
          all_values <- c(all_values, column_values)
        }
      }, error = function(e) {
        # Skip files that can't be read
      })
    }
    
    # Get unique sorted values - include blanks
    unique_values <- unique(all_values)
    # Separate blanks and non-blanks
    blank_values <- unique_values[unique_values == "" | is.na(unique_values)]
    non_blank_values <- unique_values[unique_values != "" & !is.na(unique_values)]
    
    # Sort non-blank values
    non_blank_values <- sort(non_blank_values)
    
    # Combine with blanks at the top, using a display label
    if (length(blank_values) > 0) {
      # Add a labeled blank option
      unique_values <- c("(blank)" = "", non_blank_values)
    } else {
      unique_values <- non_blank_values
    }
    
    # Limit to reasonable number if there are too many
    if (length(unique_values) > 500) {
      unique_values <- unique_values[1:500]
      showNotification("Showing first 500 unique values. Type to search for specific values.", 
                      type = "info", duration = 5)
    }
    
    # Update the selectize input
    updateSelectizeInput(session, "column_value", 
                        choices = unique_values,
                        server = TRUE)
  } else {
    # Clear choices if no column selected
    updateSelectizeInput(session, "column_value", choices = character(0))
  }
})

# Initialize the search panel UI components even before dataset is loaded
observe({
  # Initialize keyword select with empty choices
  if (length(explorer_state$available_keywords) == 0) {
    updateSelectInput(session, "keyword_select", 
                     choices = c("Select keyword..." = "", "(Load dataset first)" = ""))
  }
  
  # Initialize column select with empty choices
  if (length(explorer_state$available_columns) == 0) {
    updateSelectInput(session, "column_select", 
                     choices = c("Select column..." = "", "(Load dataset first)" = ""))
    updateSelectInput(session, "stats_variable", 
                     choices = c("Select variable..." = "", "(Load dataset first)" = ""))
  }
})

# Extract keywords from filenames
extractKeywordsFromFilenames <- function(filenames) {
  all_keywords <- character(0)
  
  for (filename in filenames) {
    # Remove file extension and split by underscore
    base_name <- gsub("\\.[^.]*$", "", filename)
    parts <- strsplit(base_name, "_")[[1]]
    
    # Extract keyword-value pairs
    for (part in parts) {
      if (grepl("-", part)) {
        keyword <- strsplit(part, "-")[[1]][1]
        all_keywords <- c(all_keywords, keyword)
      }
    }
  }
  
  return(unique(all_keywords))
}

# Load current file data
loadCurrentFile <- function() {
  if (!is.null(explorer_state$current_file) && file.exists(explorer_state$current_file)) {
    tryCatch({
      if (isTRUE(getOption("psychds.verbose"))) {
        message("Loading file: ", explorer_state$current_file)
      }
      data <- read_data_file(explorer_state$current_file, stringsAsFactors = FALSE)
      explorer_state$current_data <- data
      explorer_state$available_columns <- names(data)
      
      # Update column choices
      updateSelectInput(session, "column_select", 
                       choices = c("Select column..." = "", names(data)))
      updateSelectInput(session, "stats_variable", 
                       choices = c("Select variable..." = "", names(data)))
      
      if (isTRUE(getOption("psychds.verbose"))) {
        message("File loaded successfully with ", nrow(data), " rows and ", ncol(data), " columns")
      }
      
    }, error = function(e) {
      message("Error reading file: ", e$message)
      showNotification(paste("Error reading file:", e$message), type = "error")
    })
  }
}

# Add keyword filter
observeEvent(input$add_keyword_filter, {
  if (!is.null(input$keyword_select) && input$keyword_select != "" && 
      input$keyword_select != "(Load dataset first)" &&
      !is.null(input$keyword_value) && input$keyword_value != "") {
    
    filter_key <- paste0(input$keyword_select, ":", input$keyword_value)
    
    # Check if filter already exists
    if (!filter_key %in% names(explorer_state$keyword_filters)) {
      explorer_state$keyword_filters[[filter_key]] <- list(
        keyword = input$keyword_select,
        value = input$keyword_value
      )
      
      # Clear inputs
      updateTextInput(session, "keyword_value", value = "")
      
      # Apply filters
      applyFilters()
      
      showNotification("Keyword filter added", type = "message")
    } else {
      showNotification("This filter already exists", type = "warning")
    }
  } else {
    showNotification("Please select a keyword and enter a value", type = "warning")
  }
})

# Add column filter
observeEvent(input$add_column_filter, {
  if (!is.null(input$column_select) && input$column_select != "" && 
      input$column_select != "(Load dataset first)" &&
      !is.null(input$column_value)) {  # Remove the check for input$column_value != ""
    
    filter_key <- paste0(input$column_select, ":", input$column_value)
    
    # Check if filter already exists
    if (!filter_key %in% names(explorer_state$column_filters)) {
      explorer_state$column_filters[[filter_key]] <- list(
        column = input$column_select,
        value = input$column_value
      )
      
      # Clear inputs
      updateSelectizeInput(session, "column_value", selected = character(0))
      
      showNotification("Column filter added", type = "message")
    } else {
      showNotification("This filter already exists", type = "warning")
    }
  } else {
    showNotification("Please select a column and enter a value", type = "warning")
  }
})

# Apply filters to determine which files to show
applyFilters <- function() {
  if (length(explorer_state$keyword_filters) == 0) {
    return()
  }
  
  # Filter files based on keyword filters
  matching_files <- explorer_state$data_files
  
  for (filter in explorer_state$keyword_filters) {
    pattern <- paste0(filter$keyword, "-", filter$value)
    matching_files <- matching_files[grepl(pattern, basename(matching_files))]
  }
  
  # Load first matching file if different from current
  if (length(matching_files) > 0 && matching_files[1] != explorer_state$current_file) {
    explorer_state$current_file <- matching_files[1]
    loadCurrentFile()
  } else if (length(matching_files) == 0) {
    showNotification("No files match the current filters", type = "warning")
  }
}

# Display keyword filters
output$keyword_filters_display <- renderUI({
  filters <- explorer_state$keyword_filters
  
  if (length(filters) == 0) {
    return(div(
      style = "color: #6c757d; font-style: italic;",
      "No keyword filters active"
    ))
  }
  
  filter_badges <- lapply(names(filters), function(filter_key) {
    filter <- filters[[filter_key]]
    span(
      style = "display: inline-block; margin: 2px; padding: 4px 8px; background-color: #3498db; color: white; border-radius: 12px; font-size: 12px;",
      paste0(filter$keyword, ': "', filter$value, '"'),
      actionButton(
        session$ns(paste0("remove_kw_", gsub("[^A-Za-z0-9]", "_", filter_key))),
        "×",
        style = "background: none; border: none; color: white; padding: 0 0 0 5px; font-size: 14px;",
        onclick = paste0("Shiny.setInputValue('", session$ns("remove_keyword_filter"), "', '", filter_key, "', {priority: 'event'});")
      )
    )
  })
  
  do.call(tagList, filter_badges)
})

# Display column filters
output$column_filters_display <- renderUI({
  filters <- explorer_state$column_filters
  
  if (length(filters) == 0) {
    return(div(
      style = "color: #6c757d; font-style: italic;",
      "No column filters active"
    ))
  }
  
  filter_badges <- lapply(names(filters), function(filter_key) {
    filter <- filters[[filter_key]]
    span(
      style = "display: inline-block; margin: 2px; padding: 4px 8px; background-color: #e74c3c; color: white; border-radius: 12px; font-size: 12px;",
      paste0(filter$column, ': "', filter$value, '"'),
      actionButton(
        session$ns(paste0("remove_col_", gsub("[^A-Za-z0-9]", "_", filter_key))),
        "×",
        style = "background: none; border: none; color: white; padding: 0 0 0 5px; font-size: 14px;",
        onclick = paste0("Shiny.setInputValue('", session$ns("remove_column_filter"), "', '", filter_key, "', {priority: 'event'});")
      )
    )
  })
  
  do.call(tagList, filter_badges)
})

# Remove filters
observeEvent(input$remove_keyword_filter, {
  filter_key <- input$remove_keyword_filter
  explorer_state$keyword_filters[[filter_key]] <- NULL
  applyFilters()
  showNotification("Keyword filter removed", type = "message")
})

observeEvent(input$remove_column_filter, {
  filter_key <- input$remove_column_filter
  explorer_state$column_filters[[filter_key]] <- NULL
  showNotification("Column filter removed", type = "message")
})

# File tabs
output$file_tabs <- renderUI({
  if (length(explorer_state$data_files) == 0) {
    return(div(
      style = "color: #6c757d; font-style: italic; padding: 10px;",
      "No data files loaded. Select and load a dataset above."
    ))
  }
  
  current_file <- explorer_state$current_file
  
  # Apply keyword filters to determine which files to show
  files_to_show <- explorer_state$data_files
  for (filter in explorer_state$keyword_filters) {
    pattern <- paste0(filter$keyword, "-", filter$value)
    files_to_show <- files_to_show[grepl(pattern, basename(files_to_show))]
  }
  
  if (length(files_to_show) == 0) {
    return(div(
      style = "color: #dc3545; font-style: italic; padding: 10px;",
      "No files match current keyword filters"
    ))
  }
  
  tabs <- lapply(files_to_show, function(file) {
    filename <- basename(file)
    is_active <- identical(file, current_file)
    
    actionButton(
      session$ns(paste0("select_file_", gsub("[^A-Za-z0-9]", "_", filename))),
      filename,
      class = if (is_active) "btn btn-primary" else "btn btn-outline-secondary",
      style = "margin-right: 5px; margin-bottom: 5px;",
      onclick = paste0("Shiny.setInputValue('", session$ns("select_file"), "', '", file, "', {priority: 'event'});")
    )
  })
  
  do.call(tagList, tabs)
})

# Handle file selection
observeEvent(input$select_file, {
  explorer_state$current_file <- input$select_file
  loadCurrentFile()
})


# Render variable statistics based on type
output$variable_statistics <- renderUI({
  if (is.null(input$stats_variable) || 
      input$stats_variable == "" ||
      input$stats_variable == "(Load dataset first)") {
    return(div(
      style = "padding: 20px; text-align: center; color: #6c757d;",
      "Select a variable to view statistics"
    ))
  }
  
  # Get all files that match current keyword filters
  files_to_analyze <- explorer_state$data_files
  
  # Apply keyword filters to determine which files to include
  if (length(explorer_state$keyword_filters) > 0) {
    for (filter in explorer_state$keyword_filters) {
      pattern <- paste0(filter$keyword, "-", filter$value)
      files_to_analyze <- files_to_analyze[grepl(pattern, basename(files_to_analyze))]
    }
  }
  
  if (length(files_to_analyze) == 0) {
    return(div(
      style = "padding: 20px; text-align: center; color: #dc3545;",
      "No files match current filters"
    ))
  }
  
  # Aggregate data from all matching files
  all_column_data <- list()
  
  for (file in files_to_analyze) {
    tryCatch({
      file_data <- read_data_file(file, stringsAsFactors = FALSE)
      
      if (input$stats_variable %in% names(file_data)) {
        # Apply column filters to this file's data
        filtered_data <- file_data
        
        for (col_filter in explorer_state$column_filters) {
          col_name <- col_filter$column
          filter_value <- col_filter$value
          
          if (col_name %in% names(filtered_data)) {
            if (is.null(filter_value) || filter_value == "") {
              # Filter for blank/empty values
              filtered_data <- filtered_data[is.na(filtered_data[[col_name]]) | 
                                            filtered_data[[col_name]] == "", , drop = FALSE]
            } else {
              # Filter for matching values
              filtered_data <- filtered_data[grepl(filter_value, 
                                                  as.character(filtered_data[[col_name]]), 
                                                  ignore.case = TRUE), , drop = FALSE]
            }
          }
        }
        
        # Add this file's filtered data to aggregation
        all_column_data <- c(all_column_data, list(filtered_data[[input$stats_variable]]))
      }
    }, error = function(e) {
      # Skip files that can't be read
    })
  }
  
  if (length(all_column_data) == 0) {
    return(div(
      style = "padding: 20px; text-align: center; color: #dc3545;",
      "Selected variable not found in any matching files"
    ))
  }
  
  # Combine all data
  column_data <- unlist(all_column_data)
  
  # Rest of the statistics calculation remains the same...
  # FIXED: Better NA detection - treat empty strings as NA too
  is_missing <- is.na(column_data) | column_data == "" | trimws(as.character(column_data)) == ""
  non_na_data <- column_data[!is_missing]
  na_count <- sum(is_missing)
  total_count <- length(column_data)
  
  # Add check for empty data after removing NAs
  if (length(non_na_data) == 0) {
    return(div(
      style = "padding: 20px; text-align: center; color: #856404; background-color: #fff3cd; border-radius: 4px;",
      icon("exclamation-triangle", style = "margin-right: 8px;"),
      "This variable has no non-missing values"
    ))
  }
  
  # Load metadata if available
  metadata <- NULL
  if (!is.null(explorer_state$dataset_path)) {
    json_path <- file.path(explorer_state$dataset_path, "dataset_description.json")
    if (file.exists(json_path)) {
      tryCatch({
        dataset_desc <- jsonlite::fromJSON(json_path, simplifyVector = FALSE)
        if (!is.null(dataset_desc$variableMeasured)) {
          # Find the matching variable - case-insensitive search as backup
          for (var_meta in dataset_desc$variableMeasured) {
            if (!is.null(var_meta$name) && var_meta$name == input$stats_variable) {
              metadata <- var_meta
              break
            }
          }
        }
      }, error = function(e) {
        message("Error loading metadata: ", e$message)
      })
    }
  }

  # Debug output
  if (isTRUE(getOption("psychds.verbose"))) {
    message("=== DEBUG: Variable '", input$stats_variable, "' ===\n", sep = "")
    message("Metadata found:", !is.null(metadata), "\n")
    if (!is.null(metadata)) {
      if (isTRUE(getOption("psychds.verbose"))) {
        message("valueType in metadata:", metadata$valueType, "\n")
      }
    }
    if (isTRUE(getOption("psychds.verbose"))) {
      message("is.numeric(column_data):", is.numeric(column_data), "\n")
    }
  }

  # Detect variable type from metadata or data
  var_type <- if (!is.null(metadata) && !is.null(metadata$valueType)) {
    metadata$valueType
  } else if (is.numeric(column_data)) {
    "number"
  } else {
    "string"
  }

  if (isTRUE(getOption("psychds.verbose"))) {
    message("Final var_type:", var_type, "\n")
  }
  if (isTRUE(getOption("psychds.verbose"))) {
    message("================\n")
  }
  
  # Base statistics (always shown)
  base_stats <- div(
    style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 15px;",
    
    div(
      style = "padding: 10px; background-color: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px; text-align: center;",
      div("Total", style = "font-size: 12px; color: #6c757d;"),
      div(as.character(total_count), style = "font-weight: bold; font-size: 18px;")
    ),
    
    div(
      style = "padding: 10px; background-color: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px; text-align: center;",
      div("Unique", style = "font-size: 12px; color: #6c757d;"),
      div(as.character(length(unique(non_na_data))), style = "font-weight: bold; font-size: 18px;")
    ),
    
    div(
      style = "padding: 10px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; text-align: center;",
      div("Missing", style = "font-size: 12px; color: #856404;"),
      div(paste0(na_count, " (", round(100 * na_count / total_count, 1), "%)"), 
          style = "font-weight: bold; font-size: 18px; color: #856404;")
    ),
    
    div(
      style = "padding: 10px; background-color: #f8f9fa; border: 1px solid #ced4da; border-radius: 4px; text-align: center;",
      div("Valid", style = "font-size: 12px; color: #6c757d;"),
      div(as.character(length(non_na_data)), style = "font-weight: bold; font-size: 18px;")
    )
  )
  
  # Type-specific statistics - add more safety checks
  type_stats <- if (var_type %in% c("number", "integer") && is.numeric(column_data) && length(non_na_data) > 0) {
    # Numeric statistics
    tryCatch({
      div(
        tags$hr(style = "margin: 15px 0;"),
        div(strong("Numeric Statistics"), style = "margin-bottom: 10px; color: #333;"),
        div(
          style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px;",
          
          div(
            style = "padding: 8px; background-color: #e3f2fd; border-radius: 4px;",
            div("Mean", style = "font-size: 11px; color: #1565c0;"),
            div(round(mean(non_na_data, na.rm = TRUE), 3), style = "font-weight: bold; color: #1565c0;")
          ),
          
          div(
            style = "padding: 8px; background-color: #e3f2fd; border-radius: 4px;",
            div("Median", style = "font-size: 11px; color: #1565c0;"),
            div(round(median(non_na_data, na.rm = TRUE), 3), style = "font-weight: bold; color: #1565c0;")
          ),
          
          div(
            style = "padding: 8px; background-color: #e3f2fd; border-radius: 4px;",
            div("Std Dev", style = "font-size: 11px; color: #1565c0;"),
            div(if(length(non_na_data) > 1) round(sd(non_na_data, na.rm = TRUE), 3) else "N/A", 
                style = "font-weight: bold; color: #1565c0;")
          ),
          
          div(
            style = "padding: 8px; background-color: #e3f2fd; border-radius: 4px;",
            div("Range", style = "font-size: 11px; color: #1565c0;"),
            div(paste(round(min(non_na_data, na.rm = TRUE), 2), "to", 
                      round(max(non_na_data, na.rm = TRUE), 2)), 
                style = "font-weight: bold; color: #1565c0; font-size: 11px;")
          )
        )
      )
    }, error = function(e) {
      div(
        tags$hr(style = "margin: 15px 0;"),
        div(
          style = "padding: 10px; background-color: #ffebee; border-radius: 4px; color: #c62828;",
          "Error calculating numeric statistics: ", e$message
        )
      )
    })
  } else if ((var_type == "categorical" || length(unique(non_na_data)) <= 20) && length(non_na_data) > 0) {
    # Categorical statistics - FIXED: Better handling of sparse data
    tryCatch({
      # Convert to character first to avoid factor issues
      char_data <- as.character(non_na_data)
      # Remove any remaining empty strings
      char_data <- char_data[char_data != "" & !is.na(char_data)]
      
      if (length(char_data) == 0) {
        return(div(
          tags$hr(style = "margin: 15px 0;"),
          div(
            style = "padding: 10px; background-color: #fff3cd; border-radius: 4px; color: #856404;",
            "No valid categorical values to display"
          )
        ))
      }
      
      value_counts <- table(char_data)
      value_counts <- sort(value_counts, decreasing = TRUE)
      
      div(
        tags$hr(style = "margin: 15px 0;"),
        div(strong("Value Counts"), style = "margin-bottom: 10px; color: #333;"),
        div(
          style = "max-height: 200px; overflow-y: auto; background-color: white; border: 1px solid #dee2e6; border-radius: 4px; padding: 5px;",
          lapply(names(value_counts), function(val) {
            count <- value_counts[[val]]
            pct <- round(100 * count / length(char_data), 1)
            
            div(
              style = "padding: 5px 8px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center;",
              span(val, style = "font-family: monospace; font-size: 13px;"),
              span(
                paste0(count, " (", pct, "%)"),
                style = "font-size: 12px; color: #6c757d; font-weight: 500;"
              )
            )
          })
        )
      )
    }, error = function(e) {
      div(
        tags$hr(style = "margin: 15px 0;"),
        div(
          style = "padding: 10px; background-color: #ffebee; border-radius: 4px; color: #c62828;",
          "Error calculating categorical statistics: ", e$message
        )
      )
    })
  } else {
    # String/other types - show sample values
    tryCatch({
      # Get sample values (up to 20 unique examples)
      char_data <- as.character(non_na_data)
      char_data <- char_data[char_data != "" & !is.na(char_data)]
      
      if (length(char_data) > 0) {
        # Get up to 20 unique samples
        unique_samples <- unique(char_data)
        samples_to_show <- head(unique_samples, 20)
        
        div(
          tags$hr(style = "margin: 15px 0;"),
          div(strong("Sample Values"), style = "margin-bottom: 10px; color: #333;"),
          div(
            style = "max-height: 200px; overflow-y: auto; background-color: white; border: 1px solid #dee2e6; border-radius: 4px; padding: 8px;",
            lapply(samples_to_show, function(val) {
              div(
                style = "padding: 6px 8px; margin-bottom: 4px; background-color: #f8f9fa; border-radius: 3px; font-family: monospace; font-size: 13px; word-wrap: break-word;",
                val
              )
            }),
            if (length(unique_samples) > 20) {
              div(
                style = "padding: 8px; margin-top: 8px; text-align: center; color: #6c757d; font-size: 12px; border-top: 1px solid #dee2e6;",
                paste("Showing 20 of", length(unique_samples), "unique values")
              )
            }
          )
        )
      } else {
        div(
          tags$hr(style = "margin: 15px 0;"),
          div(
            style = "padding: 10px; background-color: #f8f9fa; border-radius: 4px; text-align: center; color: #6c757d;",
            "No valid text values to display"
          )
        )
      }
    }, error = function(e) {
      div(
        tags$hr(style = "margin: 15px 0;"),
        div(
          style = "padding: 10px; background-color: #ffebee; border-radius: 4px; color: #c62828;",
          "Error displaying sample values: ", e$message
        )
      )
    })
  }
  
  tagList(
    base_stats,
    type_stats
  )
})

# Data table
output$data_table <- DT::renderDataTable({
  if (is.null(explorer_state$current_data)) {
    return(DT::datatable(
      data.frame(Message = "No data loaded. Select a dataset and load it using the button above."), 
      options = list(dom = 't', searching = FALSE, paging = FALSE)
    ))
  }
  
  data <- explorer_state$current_data
  
  # Apply column filters
  for (filter in explorer_state$column_filters) {
    column_name <- filter$column
    filter_value <- filter$value
    
    if (column_name %in% names(data)) {
      # Handle blank/empty string filters specially
      if (is.null(filter_value) || filter_value == "") {
        # Filter for blank/empty values
        data <- data[is.na(data[[column_name]]) | data[[column_name]] == "", , drop = FALSE]
      } else {
        # Use grepl for partial matching on non-blank values
        data <- data[grepl(filter_value, as.character(data[[column_name]]), ignore.case = TRUE), , drop = FALSE]
      }
    }
  }
  
  DT::datatable(
    data,
    options = list(
      pageLength = 10,
      scrollX = TRUE,
      dom = "ftip"
    ),
    rownames = FALSE
  )
}, server = TRUE)

# Return reactive containing explorer state
return(reactive({ explorer_state }))
  })
}

Try the psychds package in your browser

Any scripts or data that you put into this service are public.

psychds documentation built on Oct. 16, 2026, 1:06 a.m.