plot_sign_grammar <- function(sg,
                              output_file = NULL,
                              width       = 10,
                              height      = 5,
                              sign_names  = FALSE,
                              font_family = NULL,
                              mapping     = NULL) {

  if (!requireNamespace("ggplot2", quietly = TRUE)) {
    stop("Package 'ggplot2' is required. Please install it.")
  }

  # --- Detect input format ---
  has_prob <- "prob" %in% names(sg)
  has_n    <- "n"    %in% names(sg)

  if (has_prob) {
    sg$pct <- sg$prob * 100
  } else if (has_n) {
    pos_totals <- tapply(sg$n, sg$position, sum)
    sg$pct <- vapply(seq_len(nrow(sg)), function(i) {
      total <- pos_totals[as.character(sg$position[i])]
      if (is.na(total) || total == 0L) 0 else 100 * sg$n[i] / total
    }, numeric(1))
  } else {
    stop("Input must have column 'n' (from sign_grammar) or 'prob' (from grammar_probs).")
  }

  # --- Detect whether the graphics device supports Unicode + custom fonts ---
  backend   <- getOption("RStudioGD.backend", default = "default")
  knitr_dev <- if (isTRUE(getOption("knitr.in.progress"))) {
    knitr::opts_chunk$get("dev")
  } else NULL
  uses_ragg <- identical(backend, "ragg") ||
    grepl("^ragg", knitr_dev %||% "") ||
    !is.null(output_file)

  # --- X-axis labels: cuneiform characters or sign names ---
  label_df     <- sg[!duplicated(sg$position), c("position", "cuneiform")]
  label_family <- if (is.null(font_family)) "Segoe UI Historic" else font_family

  # --- Fallback for devices that cannot render Unicode / custom fonts ---
  ascii_fallback <- !uses_ragg & is.null(font_family)
  if (sign_names | ascii_fallback) {
    label_df$cuneiform <- as.sign_name(label_df$cuneiform, mapping = mapping)
    label_df$cuneiform <- str_replace_all(label_df$cuneiform, "\u27E8", "<")
    label_df$cuneiform <- str_replace_all(label_df$cuneiform, "\u27E9", ">")
    label_family <- "sans"
  }

  x_labels <- setNames(label_df$cuneiform, label_df$position)

  # --- Helper: replace Unicode operators with ASCII equivalents ---
  ascii_types <- function(x) {
    x <- gsub("\u2612", "x", x)
    x <- gsub("\u2192", "->", x)
    x
  }

  # --- Fixed colour and order for every grammar type ---
  type_colour_table <- c(
    "\u2612S\u2192V"      = "#C0392B",
    "\u2612V\u2192V"      = "#FF6B6B",
    "S\u2612\u2192V"      = "#D35400",
    "S\u2612V\u2192V"     = "#CB4335",
    "V\u2192V"            = "#A93226",
    "V\u2612\u2192V"      = "#922B21",
    "V"                   = "#E74C3C",
    "Vt"                  = "#E74C3C",
    "SEN"                 = "#95A5A6",
    "\u2612S\u2192S"      = "#BDC3C7",
    "\u2612SEN\u2192SEN"  = "#AEB6BF",
    "S\u2612S\u2192S"     = "#808B96",
    "S\u2612V\u2192S"     = "#B2BABB",
    "SEN\u2612\u2192S"    = "#CCD1D1",
    "A"                   = "#2471A3",
    "A\u2612\u2192A"      = "#2E86C1",
    "\u2612S\u2192A"      = "#3498DB",
    "S\u2612\u2192A"      = "#2980B9",
    "S\u2612S\u2192A"     = "#5DADE2",
    "S\u2612SEN\u2192A"   = "#85C1E9",
    "V\u2612\u2192A"      = "#AED6F1",
    "S\u2612\u2192S"      = "#F39C12",
    "S"                   = "#2ECC71"
  )

  if (ascii_fallback) {
    names(type_colour_table) <- ascii_types(names(type_colour_table))
    sg$type <- ascii_types(sg$type)
  }

  fixed_order  <- names(type_colour_table)
  type_colours <- type_colour_table

  all_types <- unique(sg$type)
  unknown   <- setdiff(all_types, fixed_order)
  if (length(unknown) > 0L) {
    fallback <- "#EAECEE"
    for (u in unknown) type_colours[u] <- fallback
    pos <- which(fixed_order == "V")
    fixed_order <- c(fixed_order[seq_len(pos - 1L)],
                     sort(unknown),
                     fixed_order[pos:length(fixed_order)])
  }

  type_order <- fixed_order[fixed_order %in% all_types]
  sg$type    <- factor(sg$type, levels = type_order)

  # --- Sample size per position ---
  sample_sizes <- tapply(sg$n, sg$position, sum)
  sample_df <- data.frame(
    position = names(sample_sizes),
    N        = as.integer(sample_sizes),
    stringsAsFactors = FALSE
  )

  all_positions <- sort(unique(sg$position))
  sg$position        <- factor(sg$position, levels = all_positions)
  sample_df$position <- factor(sample_df$position, levels = all_positions)

  sg <- sg[sg$pct > 0, ]

  # --- Build plot ---
  p <- ggplot2::ggplot(sg,
                       ggplot2::aes(x = .data$position,
                                    y = .data$pct,
                                    fill = .data$type)) +
    ggplot2::geom_col(width = 0.7) +
    ggplot2::geom_text(data    = sample_df,
                       mapping = ggplot2::aes(x = .data$position,
                                              y = 102,
                                              label = paste0("n=", .data$N)),
                       inherit.aes = FALSE,
                       size    = 3.2,
                       colour  = "grey40") +
    ggplot2::scale_fill_manual(values = type_colours, drop = FALSE) +
    ggplot2::scale_x_discrete(labels = x_labels, drop = FALSE) +
    ggplot2::scale_y_continuous(labels = function(x) paste0(x, "%"),
                                expand = ggplot2::expansion(mult = c(0, 0.08))) +
    ggplot2::labs(x    = NULL,
                  y    = "Relative frequency",
                  fill = "Grammar type") +
    ggplot2::theme_minimal(base_size = 13, base_family = "sans") +
    ggplot2::theme(
      axis.text.x         = ggplot2::element_text(family = label_family, size = 16),
      legend.position      = "top",
      panel.grid.major.x   = ggplot2::element_blank()
    )

  # --- Output ---------------------------------------------------------------
  if (!is.null(output_file)) {
    ext <- tolower(tools::file_ext(output_file))

    if (ext == "png") {
      ragg::agg_png(output_file, width = width, height = height,
                      units = "in", res = 150)
    } else if (ext %in% c("jpg", "jpeg")) {
        ragg::agg_jpeg(output_file, width = width, height = height,
                       units = "in", res = 150, quality = 95)
    } else {
      stop("Unknown file extension: ", ext, " (allowed: .png, .jpg, .jpeg)")
    }

    print(p)
    grDevices::dev.off()
    message("Plot saved: ", output_file)

  } else {
    print(p)
  }

  invisible(p)
}
