translate <- function(x, text = NULL, dic = NULL, mapping = NULL, fill = NULL,
                      min_freq = c(6, 4, 2), sentence_prob = 1.0,
                      viewer = shiny::paneViewer()) {


  # --- Helpers ---
  null_to_empty <- function(val) if (is.null(val)) "" else val

  # ===================================================================
  # Helper: detect sentence boundaries
  # Returns integer vector of cuneiform token indices after which a

  # sentence-ending dot occurs (e.g. c(7, 11, 13)).
  # ===================================================================
  detect_sentence_ends <- function(x_raw, mapping) {
    parts_raw <- split_sumerian(x_raw)
    # Find dots in separators: sep[i+1] contains "." means after raw token i
    sentence_ends_raw <- integer(0)
    for (i in seq_along(parts_raw$signs)) {
      if (grepl("\\.", parts_raw$separators[i + 1])) {
        sentence_ends_raw <- c(sentence_ends_raw, i)
      }
    }
    if (length(sentence_ends_raw) == 0) return(integer(0))

    # Map raw token indices to cuneiform token indices
    raw_to_cunei_end <- integer(length(parts_raw$signs))
    cunei_pos <- 0L
    for (i in seq_along(parts_raw$signs)) {
      cu <- as.cuneiform(parts_raw$signs[i], mapping = mapping)
      n_cunei <- length(split_sumerian(cu)$signs)
      cunei_pos <- cunei_pos + n_cunei
      raw_to_cunei_end[i] <- cunei_pos
    }
    raw_to_cunei_end[sentence_ends_raw]
  }

  # ===================================================================
  # 1. Resolve mapping
  # ===================================================================
  if (is.null(mapping)) {
    path    <- system.file("extdata", "etcsl_mapping.txt", package = "sumer")
    mapping <- read.csv2(path, sep = ";", na.strings = "")
  } else if (is.character(mapping) && length(mapping) == 1) {
    mapping <- read.csv2(mapping, sep = ";", na.strings = "")
  }

  # ===================================================================
  # 2. Resolve text
  # ===================================================================
  if (is.character(text) && length(text) == 1 && file.exists(text)) {
    text <- readLines(text, warn = FALSE)
  }

  # ===================================================================
  # 3. Resolve dic into a list
  # ===================================================================
  if (is.null(dic)) {
    dic_list <- list(read_dictionary())
  } else if (is.character(dic)) {
    dic_list <- lapply(dic, read_dictionary, verbose = FALSE)
  } else if (is.data.frame(dic)) {
    dic_list <- list(dic)
  } else {
    dic_list <- dic
  }

  if (is.character(dic)) {
    dic_names <- tools::file_path_sans_ext(basename(dic))
  } else {
    dic_names <- paste0("Dictionary ", seq_along(dic_list))
  }
  names(dic_list) <- dic_names

  # Remove translation rows with empty or "?" meanings
  dic_list <- lapply(dic_list, function(d) {
    drop <- d$row_type == "trans." &
            (is.na(d$meaning) | d$meaning == "" | d$meaning == "?")
    d[!drop, , drop = FALSE]
  })

  # ===================================================================
  # 4. Resolve x into cuneiform string and determine line_index
  # ===================================================================
  extract_line_number <- function(line) {
    m <- regmatches(line, regexpr("^\\s*\\d+", line))
    if (length(m) > 0) as.integer(m) else NA_integer_
  }

  if (is.numeric(x) && is.null(text))
    stop("When x is an integer line number, text must be provided.")

  line_index <- NA_integer_

  if (is.numeric(x)) {
    line_number <- as.integer(x)
    text_numbers <- vapply(text, extract_line_number, integer(1), USE.NAMES = FALSE)
    match_pos <- which(text_numbers == line_number)
    if (length(match_pos) > 0) {
      line_index <- match_pos[1]
    } else {
      line_index <- line_number
    }
    x_raw <- sub("^\\s*\\d+[.)\\s]\\s*", "", text[line_index])
  } else {
    x_raw <- x
    if (!is.null(text)) {
      x_cunei_test  <- as.cuneiform(x_raw, mapping = mapping)
      x_tokens_test <- split_sumerian(x_cunei_test)$signs
      for (li in seq_along(text)) {
        stripped <- sub("^\\s*\\d+[.)\\s]\\s*", "", text[li])
        line_cunei <- tryCatch(as.cuneiform(stripped, mapping = mapping),
                               error = function(e) NULL)
        if (is.null(line_cunei)) next
        line_tokens <- split_sumerian(line_cunei)$signs
        if (identical(line_tokens, x_tokens_test)) {
          line_index <- li
          break
        }
      }
    }
  }

  x_cunei <- as.cuneiform(x_raw, mapping = mapping)
  token   <- split_sumerian(x_cunei)$signs
  N       <- length(token)

  # ===================================================================
  # 5. Resolve fill
  # ===================================================================
  if (is.null(fill)) {
    fill <- guess_substr_info(x_cunei, dic_list, mapping = mapping)
  }
  fill_tokens <- split_sumerian(fill$expr[1])$signs
  if (!identical(fill_tokens, token) || nrow(fill) != N * (N + 1) / 2) {
    stop("The parameter fill does not match x.")
  }

  # ===================================================================
  # 6. Pre-compute data for the panels
  # ===================================================================
  if (!is.null(text)) {
    ngram_full <- ngram_frequencies(text, min_freq = min_freq, mapping = mapping)
  } else {
    ngram_full <- ngram_frequencies(x_cunei, min_freq = min_freq, mapping = mapping)
  }

  main_dic <- dic_list[[1]]
  sg       <- sign_grammar(x_cunei, main_dic, mapping = mapping)
  prior    <- prior_probs(main_dic, sentence_prob = sentence_prob)
  gp       <- grammar_probs(sg, prior, main_dic)

  line_str <- paste0(token, collapse = "")

  # N-grams in current line (length >= 2) — used for merged table
  ngram_in_line <- ngram_full[ngram_full$length >= 2 &
    vapply(ngram_full$combination, function(c) {
      grepl(c, line_str, fixed = TRUE)
    }, logical(1)), , drop = FALSE]

  # Multi-sign combination summary (for all substrings of length >= 2)
  multi_sign_df <- NULL
  if (N >= 2) {
    summary_rows <- list()
    for (len in N:2) {
      for (start in 1:(N - len + 1)) {
        substr_cunei <- paste0(token[start:(start + len - 1)], collapse = "")
        substr_sn <- as.character(as.sign_name(substr_cunei, mapping = mapping))

        # Collect types and counts from all dictionaries
        all_types  <- character(0)
        all_counts <- integer(0)
        has_entries <- FALSE

        for (di in seq_along(dic_list)) {
          d <- dic_list[[di]]
          rows <- d[d$sign_name == substr_sn & d$row_type == "trans.", , drop = FALSE]
          if (nrow(rows) > 0) {
            has_entries <- TRUE
            all_types  <- c(all_types, rows$type)
            all_counts <- c(all_counts,
              if ("count" %in% names(rows)) rows$count else rep(1L, nrow(rows)))
          }
        }
        if (!has_entries) next

        # Freq: sum of counts in dictionary 1
        d1 <- dic_list[[1]]
        d1_rows <- d1[d1$sign_name == substr_sn & d1$row_type == "trans.", , drop = FALSE]
        freq_val <- if (nrow(d1_rows) > 0 && "count" %in% names(d1_rows))
          sum(d1_rows$count, na.rm = TRUE) else NA_integer_

        # Type suggestions: unique types ordered by total count
        type_df  <- aggregate(list(n = all_counts), list(type = all_types), sum, na.rm = TRUE)
        type_df  <- type_df[order(-type_df$n), ]
        type_str <- paste(type_df$type, collapse = ", ")

        summary_rows[[length(summary_rows) + 1]] <- data.frame(
          freq           = freq_val,
          sign_names     = substr_sn,
          combination    = substr_cunei,
          type_suggestions = type_str,
          stringsAsFactors = FALSE
        )
      }
    }
    if (length(summary_rows) > 0) {
      multi_sign_df <- do.call(rbind, summary_rows)
      rownames(multi_sign_df) <- NULL
    }
  }

  # ===================================================================
  # 7. Validate bracket input
  # ===================================================================
  validate_bracket_input <- function(s, original_tokens, mapping) {
    # --- Check bracket matching and nesting ---
    chars       <- strsplit(s, "")[[1]]
    openers     <- c("(", "<", "{")
    closers     <- c(")", ">", "}")
    closing_map <- c(")" = "(", ">" = "<", "}" = "{")
    opener_for  <- c("(" = ")", "<" = ">", "{" = "}")
    stack       <- character(0)

    for (ch in chars) {
      if (ch %in% openers) {
        stack <- c(stack, ch)
      } else if (ch %in% closers) {
        if (length(stack) == 0) {
          return(paste0("Unmatched closing bracket '", ch, "'."))
        }
        if (stack[length(stack)] != closing_map[ch]) {
          return(paste0("Bracket mismatch: expected '",
                        opener_for[stack[length(stack)]],
                        "' but found '", ch, "'."))
        }
        stack <- stack[-length(stack)]
      }
    }
    if (length(stack) > 0) {
      return(paste0("Unclosed bracket '", stack[length(stack)], "'."))
    }

    # --- Convert to cuneiform and check tokens ---
    s_cunei <- tryCatch(
      as.cuneiform(s, mapping = mapping),
      error = function(e) NULL
    )
    if (is.null(s_cunei)) {
      return("Cannot convert input to cuneiform. Check for invalid signs.")
    }

    s_tokens <- split_sumerian(s_cunei)$signs
    if (!identical(s_tokens, original_tokens)) {
      return("Tokens do not match the original line. Only brackets and dots may be changed.")
    }

    NULL   # input is valid
  }

  # ===================================================================
  # 7b. get_equation helper
  # ===================================================================
  get_equation <- function(expr) {
    xi  <- info(expr, mapping = mapping)
    res <- paste0(c(paste0(xi$reading, collapse = "-"),
                    paste0(xi$name, collapse = "."),
                    paste0(xi$sign, collapse = "")), collapse = "=")
    return(res)
  }

  # ===================================================================
  # 8. Initial skeleton computation
  # ===================================================================
  x_marked_init <- mark_skeleton_entries(x_cunei)
  df_init       <- extract_skeleton_entries(x_marked_init)
  df_init$fill_idx <- substr_position(df_init$start, df_init$n_tokens, N)

  fill_idx_header <- df_init$fill_idx[1]

  # ===================================================================
  # 9. Sentence boundaries for background colours
  # ===================================================================
  sentence_ends <- detect_sentence_ends(x_raw, mapping)

  # Assign sentence index (1-based) to each cuneiform token
  # Tokens 1..sentence_ends[1] -> sentence 1, etc.
  token_sentence <- integer(N)
  sent_idx <- 1L
  se_ptr   <- 1L
  for (ti in seq_len(N)) {
    token_sentence[ti] <- sent_idx
    if (se_ptr <= length(sentence_ends) && ti == sentence_ends[se_ptr]) {
      sent_idx <- sent_idx + 1L
      se_ptr   <- se_ptr + 1L
    }
  }
  n_sentences <- max(token_sentence)

  # Background colours for sentences (clearly alternating)
  sent_colours <- c("#e0e0e0", "#ececec")

  # Function: determine sentence of a skeleton entry (by its start token)
  entry_sentence <- function(start_tok) {
    token_sentence[start_tok]
  }

  # ===================================================================
  # CSS
  # ===================================================================
  custom_css <- shiny::tags$style(shiny::HTML("
    body { font-family: 'Segoe UI Historic', 'Noto Sans', sans-serif; }

    /* --- Navigation menu at top --- */
    .nav-menu { position: sticky; top: 0; z-index: 100;
                background: #f8f8f8; border-bottom: 1px solid #ccc;
                padding: 6px 12px; display: flex; gap: 12px; }
    .nav-menu a { cursor: pointer; padding: 4px 12px; border-radius: 4px;
                  text-decoration: none; color: #333; font-size: 14px; }
    .nav-menu a:hover { background: #e0e0e0; }

    /* --- Collapsible sections --- */
    .section-header { cursor: pointer; user-select: none; padding: 8px 0;
                      font-size: 16px; font-weight: bold; color: #333;
                      border-bottom: 1px solid #ddd; margin-bottom: 8px; }
    .section-header:hover { color: #337ab7; }
    .section-content { overflow: hidden; }
    .section-content.collapsed { display: none; }

    /* --- Skeleton table for aligned columns --- */
    .skel-table { border-collapse: collapse; width: 100%; table-layout: fixed; }
    .skel-table td { padding: 3px 4px; vertical-align: middle; }
    .skel-table .form-group { margin-bottom: 0; padding: 0; }
    .skel-table .control-label { display: none; }
    .skel-btn-cell { width: 80px; white-space: nowrap; }
    .skel-btn-cell + td { padding-left: 12px !important; }
    .skel-btn-cell .btn { display: inline-block; vertical-align: middle; }
    .skel-btn-cell .btn + .btn { margin-left: 4px; }
    .skel-label { font-size: 13px; white-space: pre-wrap;
                  word-break: break-word; overflow: hidden;
                  width: 35%; }
    .type-input { width: 88px; }
    .type-input .form-control { width: 80px; padding: 2px 4px; height: 28px; }
    .trans-input .shiny-input-container { width: 100% !important; }
    .trans-input .form-control { width: 100%; padding: 2px 4px; height: 28px; }
    .trans-input textarea.form-control { height: auto; resize: vertical; }
    .skel-spacer td { height: 8px; padding: 0 !important; }

    /* --- N-gram merged table (light grey) --- */
    .ngram-table { border-collapse: collapse; width: 100%; margin-bottom: 16px;
                   background-color: #f0f0f0; }
    .ngram-table th { padding: 4px 8px; text-align: left; font-size: 13px;
                      border-bottom: 2px solid #c0c0c0; }
    .ngram-table td { padding: 4px 8px; font-size: 13px;
                      border-bottom: 1px solid #d8d8d8; }

    /* --- Dictionary lookup tables --- */
    .dict-table { border-collapse: collapse; width: 100%; margin-bottom: 16px;
                  background-color: #e8f4fd; }
    .dict-table th { padding: 4px 8px; text-align: left; font-size: 13px;
                     border-bottom: 2px solid #b8d4e8; }
    .dict-table td { padding: 4px 8px; font-size: 13px;
                     border-bottom: 1px solid #d0e4f0; }
    .dict-table tr { cursor: pointer; }
    .dict-table tr:hover td { background-color: #d0e8f5; }

    /* --- Buttons: green book --- */
    .btn-skel { padding: 2px 8px; font-size: 12px; min-width: 30px;
                background-color: #4a9e4a; border-color: #3d8b3d; color: white; }
    .btn-skel:hover { background-color: #3a8a3a; border-color: #2e7a2e; color: white; }
    .btn-skel:focus { background-color: #3a8a3a; border-color: #2e7a2e; color: white; }
    .btn-skel:disabled { background-color: #b5c8b5; border-color: #a8bca8; color: #e0e8e0;
                         cursor: not-allowed; opacity: 0.65; }
    .btn-skel-brown { padding: 2px 8px; font-size: 12px; min-width: 30px;
                background-color: #8B5E3C; border-color: #7A4E2C; color: white; }
    .btn-skel-brown:hover { background-color: #7A4E2C; border-color: #693E1C; color: white; }
    .btn-skel-brown:focus { background-color: #7A4E2C; border-color: #693E1C; color: white; }
    .btn-skel-brown:disabled { background-color: #D9C4A8; border-color: #CCB89C; color: #f5f0eb;
                               cursor: not-allowed; opacity: 0.65; }
    .btn-update { background-color: #5cb85c; border-color: #4cae4c; color: white; }
    .btn-update:hover { background-color: #449d44; border-color: #398439; color: white; }

    /* --- Header row highlight --- */
    .skel-header td { background-color: #d9edf7; }
    .skel-header td:first-child { border-radius: 4px 0 0 4px; }
    .skel-header td:last-child { border-radius: 0 4px 4px 0; }

    /* --- Done button centered at bottom --- */
    .done-bar { text-align: center; padding: 16px 0; margin-top: 20px;
                border-top: 1px solid #ddd; }
    .btn-done { padding: 8px 40px; font-size: 15px;
                background-color: #5cb85c; border-color: #4cae4c; color: white; }
    .btn-done:hover { background-color: #449d44; border-color: #398439; color: white; }
    .btn-cancel { padding: 8px 40px; font-size: 15px; margin-left: 12px;
                  background-color: #d9534f; border-color: #d43f3a; color: white; }
    .btn-cancel:hover { background-color: #c9302c; border-color: #ac2925; color: white; }

    /* --- Equation label with line breaks before = --- */
    .eq-text { word-break: break-all; }
    .eq-text .eq-break { display: inline; }
    @media (min-width: 1px) {
      .eq-text .eq-break::before { content: '\\A'; white-space: pre; }
    }
  "))

  # ===================================================================
  # Helper: format equation with line-break hints before '='
  # ===================================================================
  format_equation_html <- function(eq) {
    # Only add breaks for long equations (> 30 chars)
    if (nchar(eq) <= 30) {
      return(shiny::span(class = "eq-text", eq))
    }
    # Split at '=' and rejoin with wbr tags
    parts <- strsplit(eq, "=", fixed = TRUE)[[1]]
    if (length(parts) <= 1) return(shiny::span(class = "eq-text", eq))
    elems <- list(shiny::span(parts[1]))
    for (k in 2:length(parts)) {
      elems <- c(elems, list(shiny::tags$wbr(), shiny::span(paste0("=", parts[k]))))
    }
    do.call(shiny::span, c(list(class = "eq-text"), elems))
  }

  # ===================================================================
  # UI: single scrollable page with anchored sections
  # ===================================================================
  ui <- shiny::fluidPage(
    custom_css,
    shiny::tags$title("Translate"),

    # --- Navigation menu at top ---
    shiny::div(class = "nav-menu",
      shiny::tags$a("N-grams", href = "#sec_ngrams", onclick = "event.preventDefault(); document.getElementById('sec_ngrams').scrollIntoView({behavior:'smooth'});"),
      shiny::tags$a("Combinations", href = "#sec_combinations", onclick = "event.preventDefault(); document.getElementById('sec_combinations').scrollIntoView({behavior:'smooth'});"),
      shiny::tags$a("Context", href = "#sec_context", onclick = "event.preventDefault(); document.getElementById('sec_context').scrollIntoView({behavior:'smooth'});"),
      shiny::tags$a("Grammar", href = "#sec_grammar", onclick = "event.preventDefault(); document.getElementById('sec_grammar').scrollIntoView({behavior:'smooth'});"),
      shiny::tags$a("Translation", href = "#sec_translation", onclick = "event.preventDefault(); document.getElementById('sec_translation').scrollIntoView({behavior:'smooth'});")
    ),

    # JavaScript for collapsible sections
    shiny::tags$script(shiny::HTML("
      function toggleSection(id) {
        var el = document.getElementById(id);
        el.classList.toggle('collapsed');
        var arrow = document.getElementById(id + '_arrow');
        arrow.textContent = el.classList.contains('collapsed') ? '\\u25B6' : '\\u25BC';
      }
      Shiny.addCustomMessageHandler('scrollTo', function(id) {
        var el = document.getElementById(id);
        if (el) el.scrollIntoView({behavior: 'smooth'});
      });
    ")),

    shiny::div(style = "padding: 12px 20px;",

      # =============================================================
      # Chapter 1: N-grams (collapsible)
      # =============================================================
      shiny::tags$div(id = "sec_ngrams"),
      shiny::div(class = "section-header",
        onclick = "toggleSection('content_ngrams')",
        shiny::span(id = "content_ngrams_arrow", "\u25BC"),
        " N-gram Patterns"
      ),
      shiny::div(id = "content_ngrams", class = "section-content",
        shiny::p(style = "font-size: 13px; color: #555; margin-bottom: 8px;",
          "This table shows recurring sign combinations (n-grams) that appear in the",
          "current line. Frequencies refer to the full text.",
          "A checkmark in the Theme column indicates that the combination also occurs",
          "in one of the preceding or following lines."
        ),
        shiny::uiOutput("ngram_merged_table")
      ),

      shiny::hr(),

      # =============================================================
      # Chapter 1b: Sign Combination Suggestions (collapsible)
      # =============================================================
      shiny::tags$div(id = "sec_combinations"),
      shiny::div(class = "section-header",
        onclick = "toggleSection('content_combinations')",
        shiny::span(id = "content_combinations_arrow", "\u25BC"),
        " Sign Combination Suggestions"
      ),
      shiny::div(id = "content_combinations", class = "section-content",
        shiny::p(style = "font-size: 13px; color: #555; margin-bottom: 8px;",
          "The following table shows sign combinations included in the dictionaries.",
          "Column Freq contains the count of the respective combination in the primary",
          "dictionary (Dictionary 1). Keep in mind that sign combinations are often not",
          "to be treated as fixed terms \u2014 their meanings may derive from the meanings",
          "of the individual signs."
        ),
        shiny::uiOutput("combination_table")
      ),

      shiny::hr(),

      # =============================================================
      # Chapter 2: Context (collapsible)
      # =============================================================
      shiny::tags$div(id = "sec_context"),
      shiny::div(class = "section-header",
        onclick = "toggleSection('content_context')",
        shiny::span(id = "content_context_arrow", "\u25BC"),
        " Context"
      ),
      shiny::div(id = "content_context", class = "section-content",
        shiny::p(style = "font-size: 13px; color: #555; margin-bottom: 8px;",
          "Neighbouring lines are shown here (up to 2 before and after).",
          "Curly braces mark sign sequences that have been identified as n-grams.",
          "The current line is highlighted in bold."
        ),
        shiny::uiOutput("context_text")
      ),

      shiny::hr(),

      # =============================================================
      # Chapter 3: Grammar (collapsible)
      # =============================================================
      shiny::tags$div(id = "sec_grammar"),
      shiny::div(class = "section-header",
        onclick = "toggleSection('content_grammar')",
        shiny::span(id = "content_grammar_arrow", "\u25BC"),
        " Grammar Probabilities"
      ),
      shiny::div(id = "content_grammar", class = "section-content",
        shiny::p(style = "font-size: 13px; color: #555; margin-bottom: 8px;",
          "The chart shows grammar probabilities for each sign in the line.",
          "Tall bars indicate a likely grammatical function",
          "(e.g. verb, noun, case marker). The position of a token in the sentence",
          " is currently not taken into account.."
        ),
        shiny::plotOutput("grammar_plot", height = "400px")
      ),

      shiny::hr(),

      # =============================================================
      # Chapter 4: Translation (Dictionary & Skeleton)
      # =============================================================
      shiny::tags$div(id = "sec_translation"),
      shiny::h3("Translation"),
      shiny::p(style = "font-size: 13px; color: #555; margin-bottom: 8px;",
        "Select a skeleton entry with the green book-button (\U{1F4D6}) to display",
        "matching dictionary entries below.",
        "Click a dictionary row to adopt its type and translation,",
        "or enter the translation manually.",
        "Use the brown button (\U{120FB}) to compose an entry from its components.",
		"The composition must be finished by hand (add articles and conjugate verbs).",
        "Use \u201cUpdate Skeleton\u201d after changing the bracket structure",
        "without losing existing translations."
      ),

      # Section 4.1: Bracket input + Update button
      shiny::tags$div(id = "bracket_area"),
      shiny::fluidRow(
        shiny::column(9,
          shiny::textInput("bracket_input", NULL, value = x_raw, width = "100%")
        ),
        shiny::column(3,
          shiny::actionButton("btn_generate", "Update Skeleton",
                              class = "btn-update")
        )
      ),

      # Section 4.3: Interactive Skeleton
      shiny::uiOutput("skeleton_ui"),

      shiny::hr(),

      # Section 4.4: Dictionary Lookup Display
      shiny::uiOutput("dict_section"),

      # --- Done button at bottom ---
      shiny::div(class = "done-bar",
        shiny::actionButton("btn_done", "Done", class = "btn-done"),
        shiny::actionButton("btn_cancel", "Cancel", class = "btn-cancel")
      )
    )
  )

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

    # --- Reactive values ---
    rv <- shiny::reactiveValues(
      selected = 1L,
      df       = df_init,
      rebuild  = 0L
    )

    obs_handles <- list()

    # ---------------------------------------------------------------
    # Chapter 1: Merged N-gram table (patterns + shared)
    # ---------------------------------------------------------------
    output$ngram_merged_table <- shiny::renderUI({
      # 1. Start with ngram_in_line (full-text, length >= 2, in current line)
      merged_combos <- ngram_in_line$combination

      # 2. Compute shared n-grams from context if available
      shared_combos <- character(0)
      if (!is.na(line_index) && !is.null(text)) {
        idx_range       <- max(1, line_index - 2):min(length(text), line_index + 2)
        neighbour_lines <- text[setdiff(idx_range, line_index)]
        local_text      <- c(text[line_index], neighbour_lines)
        ngram_local     <- ngram_frequencies(local_text, min_freq = 2, mapping = mapping)

        current_str    <- paste0(token, collapse = "")
        neighbour_strs <- vapply(neighbour_lines, function(l) {
          paste0(split_sumerian(as.cuneiform(l, mapping = mapping))$signs,
                 collapse = "")
        }, character(1))

        ngram_shared <- ngram_local[vapply(ngram_local$combination, function(c) {
          grepl(c, current_str, fixed = TRUE) &&
            any(vapply(neighbour_strs, function(ns) grepl(c, ns, fixed = TRUE), logical(1)))
        }, logical(1)), , drop = FALSE]

        shared_combos <- ngram_shared$combination
        new_combos    <- setdiff(shared_combos, merged_combos)
        merged_combos <- c(merged_combos, new_combos)
      }

      if (length(merged_combos) == 0) {
        return(shiny::p(style = "color:#999; font-style:italic;",
                        "No n-gram patterns found."))
      }

      # 3. Build merged data frame with frequency from ngram_full
      freq_lookup <- stats::setNames(ngram_full$frequency, ngram_full$combination)
      len_lookup  <- stats::setNames(ngram_full$length, ngram_full$combination)

      merged_df <- data.frame(
        combination = merged_combos,
        frequency   = as.integer(freq_lookup[merged_combos]),
        length      = as.integer(len_lookup[merged_combos]),
        stringsAsFactors = FALSE
      )

      # For shared n-grams not in ngram_full: compute length from tokens
      na_len <- is.na(merged_df$length)
      if (any(na_len)) {
        merged_df$length[na_len] <- vapply(
          merged_df$combination[na_len],
          function(c) length(split_sumerian(c)$signs),
          integer(1))
      }

      # 4. Add sign_names and theme columns
      merged_df$sign_names <- as.character(
        as.sign_name(merged_df$combination, mapping = mapping))
      merged_df$theme <- ifelse(
        merged_df$combination %in% shared_combos, "\u2713", "")

      # 5. Sort by length descending, then frequency descending
      merged_df <- merged_df[order(
        -merged_df$length,
        -ifelse(is.na(merged_df$frequency), -Inf, merged_df$frequency)), ]

      # 6. Build HTML table
      tbl_rows <- lapply(seq_len(nrow(merged_df)), function(r) {
        shiny::tags$tr(
          shiny::tags$td(
            if (!is.na(merged_df$frequency[r]))
              as.character(merged_df$frequency[r]) else ""),
          shiny::tags$td(as.character(merged_df$length[r])),
          shiny::tags$td(merged_df$sign_names[r]),
          shiny::tags$td(merged_df$combination[r]),
          shiny::tags$td(style = "text-align: center;", merged_df$theme[r])
        )
      })

      shiny::tags$table(class = "ngram-table",
        shiny::tags$thead(shiny::tags$tr(
          shiny::tags$th("Freq"),
          shiny::tags$th("Len"),
          shiny::tags$th("Sign names"),
          shiny::tags$th("Combination"),
          shiny::tags$th("Theme")
        )),
        shiny::tags$tbody(tbl_rows)
      )
    })

    # ---------------------------------------------------------------
    # Chapter 1b: Sign Combination Suggestions
    # ---------------------------------------------------------------
    output$combination_table <- shiny::renderUI({
      if (is.null(multi_sign_df) || nrow(multi_sign_df) == 0) {
        return(shiny::p(style = "color:#999; font-style:italic;",
                        "No multi-sign combination suggestions found."))
      }

      tbl_rows <- lapply(seq_len(nrow(multi_sign_df)), function(r) {
        shiny::tags$tr(
          shiny::tags$td(
            if (!is.na(multi_sign_df$freq[r]))
              as.character(multi_sign_df$freq[r]) else ""),
          shiny::tags$td(multi_sign_df$sign_names[r]),
          shiny::tags$td(multi_sign_df$combination[r]),
          shiny::tags$td(multi_sign_df$type_suggestions[r])
        )
      })

      shiny::tags$table(class = "ngram-table",
        shiny::tags$thead(shiny::tags$tr(
          shiny::tags$th("Freq"),
          shiny::tags$th("Sign names"),
          shiny::tags$th("Combination"),
          shiny::tags$th("Type suggestions")
        )),
        shiny::tags$tbody(tbl_rows)
      )
    })

    # ---------------------------------------------------------------
    # Chapter 2: Context
    # ---------------------------------------------------------------
    output$context_text <- shiny::renderUI({
      if (is.na(line_index) || is.null(text)) {
        return(shiny::p("No text context available."))
      }

      idx_range      <- max(1, line_index - 2):min(length(text), line_index + 2)
      context_lines  <- text[idx_range]
      context_marked <- mark_ngrams(context_lines, ngram_full, mapping = mapping)

      rel_pos <- line_index - min(idx_range) + 1
      context_html <- vapply(seq_along(context_marked), function(i) {
        line_esc <- gsub("&", "&amp;", context_marked[i], fixed = TRUE)
        line_esc <- gsub("<", "&lt;", line_esc, fixed = TRUE)
        line_esc <- gsub(">", "&gt;", line_esc, fixed = TRUE)
        if (i == rel_pos) {
          paste0("<b>", line_esc, "</b>")
        } else {
          line_esc
        }
      }, character(1))

      shiny::HTML(paste0(
        "<pre style='font-family: Segoe UI Historic, sans-serif; font-size: 14px;'>",
        paste(context_html, collapse = "\n"), "</pre>"))
    })

    # (shared n-grams are now part of the merged table in Chapter 1)

    # ---------------------------------------------------------------
    # Chapter 3: Grammar
    # ---------------------------------------------------------------
    output$grammar_plot <- shiny::renderPlot({
      plot_sign_grammar(gp, sign_names = FALSE, mapping = mapping)
    })

    # ---------------------------------------------------------------
    # harvest_fill
    # ---------------------------------------------------------------
    harvest_fill <- function() {
      current_df <- rv$df
      for (i in seq_len(nrow(current_df))) {
        fill$type[current_df$fill_idx[i]]        <<- null_to_empty(input[[paste0("type_", i)]])
        fill$translation[current_df$fill_idx[i]] <<- null_to_empty(input[[paste0("trans_", i)]])
      }
    }

    # ---------------------------------------------------------------
    # build_skeleton_ui
    # ---------------------------------------------------------------
    build_skeleton_ui <- function() {
      current_df <- rv$df

      for (h in obs_handles) h$destroy()
      obs_handles <<- list()

      # --- Helper: check if any dictionary has translations for an expression ---
      has_dict_entries <- function(expr) {
        sn <- as.character(as.sign_name(expr, mapping = mapping))
        any(vapply(dic_list, function(d) {
          any(d$sign_name == sn & d$row_type == "trans.")
        }, logical(1)))
      }

      # --- Header row (root = row 1, depth 0): cuneiform tokens ---
      header_type  <- null_to_empty(fill$type[current_df$fill_idx[1]])
      header_trans <- null_to_empty(fill$translation[current_df$fill_idx[1]])
      header_label <- paste0(token, collapse = "")

      # Header has children if there are any depth-1 entries
      header_has_children <- nrow(current_df) > 1 && any(current_df$depth == 1)
      header_has_dict <- has_dict_entries(current_df$expr[1])

      header_row <- shiny::tags$tr(class = "skel-header",
        shiny::tags$td(class = "skel-btn-cell",
          shiny::actionButton("btn_1", label = "\U{1F4D6}",
            class = "btn btn-skel",
            disabled = !header_has_dict),
          shiny::actionButton("comp_1", label = "\U{120FB}",
            class = "btn btn-skel-brown",
            disabled = !header_has_children)
        ),
        shiny::tags$td(class = "skel-label", header_label),
        shiny::tags$td(class = "type-input",
          shiny::textInput("type_1", label = NULL, value = header_type)
        ),
        shiny::tags$td(class = "trans-input",
          shiny::textAreaInput("trans_1", label = NULL, value = header_trans,
                               rows = if (N >= 4) 2 else 1)
        )
      )

      # --- Entry rows (rows 2..n, depth >= 1) ---
      entry_rows <- list()
      if (nrow(current_df) >= 2) for (i in 2:nrow(current_df)) {
        indent_px  <- (current_df$depth[i] - 1) * 30
        fill_type  <- null_to_empty(fill$type[current_df$fill_idx[i]])
        fill_trans <- null_to_empty(fill$translation[current_df$fill_idx[i]])
        eq         <- get_equation(current_df$expr[i])

        # Sentence-based background colour
        sent <- entry_sentence(current_df$start[i])
        bg_col <- sent_colours[((sent - 1) %% length(sent_colours)) + 1]

        # Spacer before depth-1 entries (with sentence background)
        if (current_df$depth[i] == 1) {
          entry_rows <- c(entry_rows, list(
            shiny::tags$tr(class = "skel-spacer",
              style = paste0("background-color:", bg_col, ";"),
              shiny::tags$td(colspan = "4")
            )
          ))
        }

        btn_id   <- paste0("btn_", i)
        comp_id  <- paste0("comp_", i)
        type_id  <- paste0("type_", i)
        trans_id <- paste0("trans_", i)

        # Check if this entry has children (depth+1 entries within its token range)
        e_start <- current_df$start[i]
        e_end   <- e_start + current_df$n_tokens[i] - 1
        e_depth <- current_df$depth[i]
        has_children <- any(current_df$depth == e_depth + 1 &
                            current_df$start >= e_start &
                            current_df$start + current_df$n_tokens - 1 <= e_end)

        entry_has_dict <- has_dict_entries(current_df$expr[i])
        eq_html <- format_equation_html(eq)

        row <- shiny::tags$tr(
          style = paste0("background-color:", bg_col, ";"),
          shiny::tags$td(class = "skel-btn-cell",
            shiny::actionButton(btn_id, label = "\U{1F4D6}",
              class = "btn btn-skel",
              disabled = !entry_has_dict),
            shiny::actionButton(comp_id, label = "\U{120FB}",
              class = "btn btn-skel-brown",
              disabled = !has_children)
          ),
          shiny::tags$td(
            shiny::div(style = paste0("padding-left:", indent_px, "px;"),
              eq_html
            )
          ),
          shiny::tags$td(class = "type-input",
            shiny::textInput(type_id, label = NULL, value = fill_type)
          ),
          shiny::tags$td(class = "trans-input",
            shiny::textAreaInput(trans_id, label = NULL, value = fill_trans,
                                 rows = if (current_df$n_tokens[i] >= 4) 2 else 1)
          )
        )
        entry_rows <- c(entry_rows, list(row))
      }

      # --- Create observers for green buttons (select) ---
      for (i in seq_len(nrow(current_df))) {
        local({
          idx <- i
          h <- shiny::observeEvent(input[[paste0("btn_", idx)]], {
            rv$selected <- idx
            session$sendCustomMessage("scrollTo", "dict_section")
          }, ignoreInit = TRUE)
          obs_handles[[length(obs_handles) + 1]] <<- h
        })
      }

      # --- Create observers for brown buttons (compose) ---
      for (i in seq_len(nrow(current_df))) {
        local({
          idx <- i
          h <- shiny::observeEvent(input[[paste0("comp_", idx)]], {
            harvest_fill()

            # Determine parent entry's token range and depth
            p_start <- current_df$start[idx]
            p_end   <- p_start + current_df$n_tokens[idx] - 1
            p_depth <- current_df$depth[idx]

            # Find direct children (depth+1 within token range)
            child_mask <- current_df$depth == p_depth + 1 &
                          current_df$start >= p_start &
                          current_df$start + current_df$n_tokens - 1 <= p_end
            child_rows <- which(child_mask)

            if (length(child_rows) == 0) return()

            # Build data frame: first row = parent, remaining rows = children
            compose_df <- data.frame(
              expr        = current_df$expr[c(idx, child_rows)],
              n_tokens    = current_df$n_tokens[c(idx, child_rows)],
              start       = current_df$start[c(idx, child_rows)],
              depth       = current_df$depth[c(idx, child_rows)],
              type        = vapply(c(idx, child_rows), function(j)
                null_to_empty(fill$type[current_df$fill_idx[j]]), character(1)),
              translation = vapply(c(idx, child_rows), function(j)
                null_to_empty(fill$translation[current_df$fill_idx[j]]), character(1)),
              stringsAsFactors = FALSE
            )

            # Call helper function
            result <- tryCatch(
              compose_skeleton_entry(compose_df),
              error = function(e) e$message
            )

            if (is.character(result)) {
              shiny::showNotification(result, type = "warning")
              return()
            }

            # Update type and translation for the parent entry (from row 1)
            shiny::updateTextInput(session, paste0("type_", idx), value = result$type[1])
            shiny::updateTextInput(session, paste0("trans_", idx), value = result$translation[1])
          }, ignoreInit = TRUE)
          obs_handles[[length(obs_handles) + 1]] <<- h
        })
      }

      shiny::tags$table(class = "skel-table", header_row, entry_rows)
    }

    # ---------------------------------------------------------------
    # Render skeleton UI
    # ---------------------------------------------------------------
    output$skeleton_ui <- shiny::renderUI({
      rv$rebuild
      build_skeleton_ui()
    })

    # ---------------------------------------------------------------
    # Update Skeleton button
    # ---------------------------------------------------------------
    shiny::observeEvent(input$btn_generate, {
      harvest_fill()

      s <- input$bracket_input

      # Validate brackets and tokens before proceeding
      err <- validate_bracket_input(s, token, mapping)
      if (!is.null(err)) {
        shiny::showNotification(err, type = "error")
        return()
      }

      s_cunei  <- as.cuneiform(s, mapping = mapping)

      s_marked <- tryCatch(
        mark_skeleton_entries(s_cunei),
        error = function(e) {
          shiny::showNotification(
            paste("Skeleton error:", e$message), type = "error")
          return(NULL)
        }
      )
      if (is.null(s_marked)) return()

      new_df   <- extract_skeleton_entries(s_marked)
      new_df$fill_idx <- substr_position(new_df$start, new_df$n_tokens, N)

      # Recompute sentence boundaries from updated input
      sentence_ends <<- detect_sentence_ends(s, mapping)
      sent_idx <- 1L
      se_ptr   <- 1L
      for (ti in seq_len(N)) {
        token_sentence[ti] <<- sent_idx
        if (se_ptr <= length(sentence_ends) && ti == sentence_ends[se_ptr]) {
          sent_idx <- sent_idx + 1L
          se_ptr   <- se_ptr + 1L
        }
      }
      n_sentences <<- max(token_sentence)

      rv$df       <- new_df
      rv$selected <- 1L
      rv$rebuild  <- rv$rebuild + 1L
    })

    # ---------------------------------------------------------------
    # Dictionary lookup: per-dictionary results
    # ---------------------------------------------------------------
    dict_by_dic <- shiny::reactive({
      sel <- rv$selected
      lookup_expr <- rv$df$expr[sel]
      lookup_sign_name <- as.character(as.sign_name(lookup_expr, mapping = mapping))

      ordered_names <- dic_names

      results <- lapply(ordered_names, function(dn) {
        d <- dic_list[[dn]]
        rows <- d[d$sign_name == lookup_sign_name & d$row_type == "trans.", , drop = FALSE]
        if (nrow(rows) > 0) {
          df <- data.frame(
            count   = if ("count" %in% names(rows)) rows$count else NA_integer_,
            type    = rows$type,
            meaning = rows$meaning,
            stringsAsFactors = FALSE
          )
          df[order(-ifelse(is.na(df$count), -Inf, df$count)), ]
        } else {
          data.frame(count = integer(0), type = character(0),
                     meaning = character(0), stringsAsFactors = FALSE)
        }
      })
      names(results) <- ordered_names
      results
    })

    # ---------------------------------------------------------------
    # Render dictionary section (one table per dictionary)
    # ---------------------------------------------------------------
    output$dict_section <- shiny::renderUI({
      results <- dict_by_dic()

      sel <- rv$selected
      expr <- rv$df$expr[sel]
      sn <- as.character(as.sign_name(expr, mapping = mapping))
      expr_clean <- gsub("[(){}]", "", expr)
      header <- shiny::h4(paste0("Dictionary entries for: ", sn, " (", expr_clean, ")"))

      if (length(results) == 0) {
        return(shiny::tagList(header,
          shiny::p(style = "color:#999; font-style:italic;", "No dictionaries selected.")))
      }

      tables <- lapply(seq_along(results), function(d_idx) {
        dn <- names(results)[d_idx]
        df <- results[[d_idx]]

        if (nrow(df) == 0) {
          return(shiny::div(style = "margin-bottom: 12px;",
            shiny::h5(dn),
            shiny::p(style = "color:#999; font-style:italic;", "No entries found.")
          ))
        }

        tbl_rows <- lapply(seq_len(nrow(df)), function(r) {
          click_js <- sprintf(
            "Shiny.setInputValue('dict_click', '%d_%d', {priority: 'event'})",
            d_idx, r)
          shiny::tags$tr(onclick = click_js,
            shiny::tags$td(
              if (!is.na(df$count[r])) as.character(df$count[r]) else ""),
            shiny::tags$td(df$type[r]),
            shiny::tags$td(df$meaning[r])
          )
        })

        tbl <- shiny::tags$table(class = "dict-table",
          shiny::tags$thead(shiny::tags$tr(
            shiny::tags$th("Count"),
            shiny::tags$th("Type"),
            shiny::tags$th("Meaning")
          )),
          shiny::tags$tbody(tbl_rows)
        )

        shiny::div(style = "margin-bottom: 12px;", shiny::h5(dn), tbl)
      })

      shiny::tagList(header, tables)
    })

    # ---------------------------------------------------------------
    # Click-to-adopt from dictionary tables
    # ---------------------------------------------------------------
    shiny::observeEvent(input$dict_click, {
      parts <- strsplit(input$dict_click, "_")[[1]]
      d_idx <- as.integer(parts[1])
      r_idx <- as.integer(parts[2])
      results <- dict_by_dic()
      if (d_idx >= 1 && d_idx <= length(results)) {
        df <- results[[d_idx]]
        if (r_idx >= 1 && r_idx <= nrow(df)) {
          i <- rv$selected
          shiny::updateTextInput(session, paste0("type_", i),
                                 value = null_to_empty(df$type[r_idx]))
          shiny::updateTextInput(session, paste0("trans_", i),
                                 value = null_to_empty(df$meaning[r_idx]))
          session$sendCustomMessage("scrollTo", "bracket_area")
        }
      }
    })

    # ---------------------------------------------------------------
    # Done button
    # ---------------------------------------------------------------
    shiny::observeEvent(input$btn_done, {
      harvest_fill()
      s_final    <- input$bracket_input
      fill_final <- fill
      result <- skeleton(s_final, mapping = mapping, fill = fill_final, space = TRUE)
      shiny::stopApp(result)
    })

    # ---------------------------------------------------------------
    # Cancel button
    # ---------------------------------------------------------------
    shiny::observeEvent(input$btn_cancel, {
      shiny::stopApp(NULL)
    })
  }

  # ===================================================================
  # Run the gadget
  # ===================================================================

  result <- shiny::runGadget(ui, server, viewer = viewer)


  if (is.null(result)) {
    message("Translation cancelled.")
    return(invisible(NULL))
  }

  result
}
