bubble.plotly <- function(
  x, x_name, y_name, by_name, x_lab, y_lab, main = NULL,
  fill, border, opacity,
  power, radius,
  digits_d = 0,
  labels = c("%", "input", "prop", "off"),
  labels_position = "in", labels_color, labels_size,
  labels_decimals = NULL,
  label_min_px = 26,
  label_autocontrast = TRUE,
  facet_tbls = NULL,     # named list of per-facet 1-D tables (faceted path)
  facet_name = NULL,     # display name for the facet variable
  facet_opts = .plotly_facet_opts(),
  style_opts = .plotly_style()
) {

  labels <- match.arg(labels)
  title.size <- round(16 * getOption("main_size", 1))

  # ---------- helpers ----------
  .pick_col <- function(v, i, key) {
    if (!length(v)) return("black")
    nms <- names(v)
    if (!is.null(nms) && length(nms) && key %in% nms) return(v[[key]])
    if (length(v) >= i) return(v[[i]])
    v[[ ((i - 1L) %% length(v)) + 1L ]]
  }

  dpi <- style_opts$plotly_dpi
  bubble.diam.builder <- function(all_values, power, radius, dpi) {
    v <- pmax(0, as.numeric(all_values))
    max.ru <- max(v^power, na.rm = TRUE)
    max.px.diam <- 2 * radius * dpi
    function(vals) {
      vals <- pmax(0, as.numeric(vals))
      if (!is.finite(max.ru) || max.ru <= 0) return(rep(0, length(vals)))
      max.px.diam * (vals^power) / max.ru
    }
  }

  alpha.fill <- if (is.null(opacity)) 0.85 else as.numeric(opacity)
  if (!is.finite(alpha.fill)) alpha.fill <- 0.85
  alpha.fill <- max(0, min(1, alpha.fill))

  # one palette color per category, keyed by category name so the same
  # category keeps the same hue across faceted panels
  build_1d_col_map <- function(cats) {
    n <- length(cats)
    ff <- if (is.null(fill)) .color_range(.get_fill(style_opts$theme), n)
          else              .color_range(fill, n)
    setNames(.to_hex(rep_len(as.character(ff), n)), cats)
  }

  # Render one 1-D bubble panel (categorical x, no by) from a named-numeric
  # table. diam_fun / col_map / max_diam are built once by the caller so
  # bubble sizes, colors, and stem heights stay comparable across panels.
  # Returns a plotly object with traces, stems, and axes (no title).
  render_1d_panel <- function(x, diam_fun, col_map, max_diam,
                              show_x_title = TRUE) {
    xv <- names(x)
    yv <- suppressWarnings(as.numeric(x))
    if (is.null(xv) || !length(xv)) xv <- as.character(seq_along(yv))
    df <- data.frame(..x = factor(xv, levels = xv), ..val = yv)

    total     <- sum(df$..val, na.rm = TRUE)
    share.tot <- if (total > 0) df$..val / total else rep(0, nrow(df))

    custom.rows <- Map(function(xc, pt)
        list(xcat = as.character(xc), bycat = "",
             pct_x = as.numeric(pt), pct_tot = as.numeric(pt)),
      df$..x, share.tot)

    diam.px    <- diam_fun(df$..val)
    border.hex <- .to_hex(border)
    fmt.val    <- .fmt(df$..val, d = digits_d)

    cat.hex          <- unname(col_map[as.character(df$..x)])
    labels.color.vec <- vapply(cat.hex, .contrast_text_for_hex, "",
                               USE.NAMES = FALSE)
    fill.rgba        <- .maketrans_plotly(cat.hex, alpha = alpha.fill)

    if (length(labels_color)) {
      labels.color.vec <- rep_len(.to_hex(labels_color),
                                  length(labels.color.vec))
    } else if (!isTRUE(label_autocontrast)) {
      labels.color.vec <- rep(NA_character_, length(labels.color.vec))
    }
    labels.color.vec[is.na(labels.color.vec)] <- "black"

    # "prop" plots proportions, which digits_d (0 for counts)
    # would flatten to "0"
    lbl.d <- if (!is.null(labels_decimals)) as.integer(labels_decimals)
             else if (identical(labels, "prop")) 2L
             else                           as.integer(digits_d)
    if (!identical(labels, "off")) {
      label.txt <- if (identical(labels, "input")) fmt.val
        else if (identical(labels, "%"))
          sprintf(paste0("%.", lbl.d, "f%%"), 100 * share.tot)
        else sprintf(paste0("%.", lbl.d, "f"), share.tot)
      label.show <- ifelse(is.finite(diam.px) & (diam.px >= label_min_px),
                           label.txt, "")
    } else {
      label.show <- rep("", length(fmt.val))
    }

    trace.mode <- if (identical(labels, "off")) "markers" else "markers+text"
    txt.pos    <- if (identical(tolower(as.character(labels_position[1])),
                                "in")) "middle center" else "top center"
    txt.size   <- round(14 *
                    (if (length(labels_size) && is.numeric(labels_size))
                       labels_size[1] else 1) *
                    getOption("axis_size", 0.9), 0)

    hover <- paste0(
      x_name, ": %{customdata.xcat}",
      "<br>", y_name, ": %{hovertext}",
      "<br>% of ", x_name, ": %{customdata.pct_x:.1%}",
      "<br>% of total: %{customdata.pct_tot:.1%}<extra></extra>"
    )

    plt <- plotly::plot_ly(hoverlabel = list(align = "left"))
    plt <- plotly::add_trace(
      plt,
      type = "scatter", mode = trace.mode,
      x = df$..x,
      y = rep(0, nrow(df)),                 # numeric (avoid category warnings)
      hovertext     = fmt.val,
      hovertemplate = hover,
      customdata    = custom.rows,
      marker = list(
        symbol   = "circle",
        size     = diam.px,
        sizemode = "diameter",
        color    = fill.rgba,
        line     = list(color = border.hex, width = 1),
        sizemin  = 12
      ),
      text         = label.show,
      textposition = txt.pos,
      textfont     = list(size = txt.size, color = labels.color.vec),
      cliponaxis   = FALSE,
      showlegend   = FALSE
    )

    # vertical stems from the plot bottom up to the base of each bubble.
    # Heights use the GLOBAL max diameter so panels stay comparable.
    target.r    <- 0.35
    md          <- if (is.finite(max_diam) && max_diam > 0) max_diam else 1
    radius.data <- (diam.px / md) * target.r
    y.top       <- target.r * 1.20
    seg.color   <- .to_hex(style_opts$grid_color)
    stem.shapes <- lapply(seq_len(nrow(df)), function(i)
      list(type  = "line",
           x0    = i - 1L, x1 = i - 1L, xref = "x",
           y0    = -1, y1 = -radius.data[i], yref = "y",
           line  = list(color = seg.color, width = 1),
           layer = "below"))

    plt <- plotly::layout(
      plt,
      xaxis = list(
        type     = "category",
        title    = list(
          text     = if (show_x_title) x_lab else "",
          standoff = 10,
          font     = list(size = round(15 * getOption("lab_size", 1)))
        ),
        showgrid = FALSE,
        zeroline = FALSE,
        showline = TRUE,
        ticks    = "outside",
        tickfont = list(size = round(16 * getOption("axis_size", 0.9)))
      ),
      yaxis = list(visible = FALSE, fixedrange = TRUE, range = c(-1, y.top)),
      shapes   = stem.shapes,
      template = NULL
    )

    plt$x$layout$plot_bgcolor  <- .to_hex(style_opts$panel_fill)
    plt$x$layout$paper_bgcolor <- .to_hex(style_opts$window_fill)
    plt
  }  # end render_1d_panel()

  # ---------- faceted 1-D path ----------
  # Grid of single-category bubble panels, one per facet level. Sizes and
  # colors are normalized globally; panels are assembled with subplot().
  if (!is.null(facet_tbls)) {
    fac.levels <- names(facet_tbls)
    n.fac      <- length(fac.levels)

    all.vals   <- unlist(lapply(facet_tbls, as.numeric), use.names = FALSE)
    diam.fun   <- bubble.diam.builder(all.vals, power, radius, dpi)
    max.diam   <- {
      m <- suppressWarnings(max(diam.fun(all.vals), na.rm = TRUE))
      if (is.finite(m) && m > 0) m else 1
    }
    # all panels share the same category levels (built with the global set)
    col.map    <- build_1d_col_map(names(facet_tbls[[1]]))

    panels <- lapply(facet_tbls, render_1d_panel,
                     diam_fun = diam.fun, col_map = col.map,
                     max_diam = max.diam, show_x_title = TRUE)

    n.col.use <- facet_opts$n_col %||% min(n.fac, facet_opts$n_col_max)
    n.row.use <- ceiling(n.fac / n.col.use)

    plt <- plotly::subplot(panels,
      nrows  = n.row.use, shareX = FALSE, shareY = FALSE,
      titleX = TRUE, titleY = FALSE, margin = 0.06)

    # subplot() assigns domains automatically; extract them and hand to the
    # shared facet-layout helper so annotation formatting matches hier/radar.
    sub.domains <- lapply(seq_along(fac.levels), function(i) {
      sfx <- if (i == 1L) "" else as.character(i)
      xax <- plt$x$layout[[paste0("xaxis", sfx)]]
      yax <- plt$x$layout[[paste0("yaxis", sfx)]]
      list(x = xax$domain %||% c(0, 1), y = yax$domain %||% c(0, 1))
    })
    ann <- .plotly_facet_layout(
      facet_levels = fac.levels,
      facet_name   = if (!is.null(facet_name) && nzchar(facet_name))
                       facet_name else "",
      domains      = sub.domains,
      yanchor      = "bottom",
      ann_adjust   = function(i, a, d) { a$y <- min(d$y[2] + 0.04, 0.99); a }
    )$annotations
    if (is.null(facet_name) || !nzchar(facet_name))
      ann <- lapply(seq_along(ann), function(i) {
        ann[[i]]$text <- fac.levels[i]; ann[[i]]
      })

    has.title <- !is.null(main) && length(main) && nzchar(main[1])
    plt <- plotly::layout(plt,
      annotations   = ann,
      margin        = list(
                        t = if (has.title) round(title.size * 2.4) else 40,
                        b = 8, l = 20, r = 20),
      showlegend    = FALSE,
      title         = if (has.title)
                        list(text = main[1], x = 0.5, xanchor = "center",
                             y = 0.98, yanchor = "top",
                             font = list(size = title.size))
                      else NULL,
      paper_bgcolor = .to_hex(style_opts$window_fill)
    )

    return(invisible(plt))
  }

  # ---------- single 1-D chart ----------
  one.d <- (length(dim(x)) == 0L) || (length(dim(x)) == 1L)
  if (one.d) {
    cats <- names(x)
    if (is.null(cats) || !length(cats)) cats <- as.character(seq_along(x))
    diam.fun <- bubble.diam.builder(as.numeric(x), power, radius, dpi)
    col.map  <- build_1d_col_map(cats)
    max.diam <- {
      m <- suppressWarnings(max(diam.fun(as.numeric(x)), na.rm = TRUE))
      if (is.finite(m) && m > 0) m else 1
    }

    plt <- render_1d_panel(x, diam.fun, col.map, max.diam, show_x_title = TRUE)

    if (!is.null(main) && length(main) && nzchar(main[1])) {
      plt <- plotly::layout(
        plt,
        title  = list(text = main[1], x = 0.5, xanchor = "center",
                      y = 0.98, yanchor = "top",
                      font = list(size = title.size)),
        margin = list(t = round(title.size * 2.2), r = 25, b = 30, l = 25)
      )
    }

    return(invisible(plt))
  }

  # ---------- 2-D data prep (x is a table or matrix) ----------
  if (is.table(x)) df <- as.data.frame(x)
  else if (is.matrix(x)) df <- as.data.frame(as.table(x))
  else stop("For 2-D bubble plot, x must be a table or matrix.")
  names(df)[1:3] <- c("..by", "..x", "..val")

  if (!is.null(rownames(x))) df$..by <- factor(df$..by, levels = rownames(x))
  else df$..by <- factor(df$..by)

  if (!is.null(colnames(x))) df$..x  <- factor(df$..x,  levels = colnames(x))
  else df$..x <- factor(df$..x)

  n.g <- nlevels(df$..by)
  if (is.null(fill)) {
    theme <- style_opts$theme
    fill <- .color_range(.get_fill(theme), n.g)
  } else {
    fill <- .color_range(fill, n.g)
  }

  # ---------- shares ----------
  total <- sum(df$..val, na.rm = TRUE)
  share.tot <- if (total > 0) df$..val / total else rep(0, nrow(df))
  col.tot <- tapply(df$..val, df$..x, sum, na.rm = TRUE)
  share.x <- df$..val / as.numeric(col.tot[as.character(df$..x)])
  share.x[!is.finite(share.x)] <- 0

  custom.rows <- Map(function(xc, yc, px, pt) {
    list(xcat = as.character(xc), bycat = as.character(yc),
         pct_x = as.numeric(px), pct_tot = as.numeric(pt))
  },
  df$..x, df$..by, share.x, share.tot)

  # ---------- sizes & colors ----------
  diam.fun   <- bubble.diam.builder(df$..val, power, radius, dpi)
  diam.px    <- diam.fun(df$..val)
  border.hex <- .to_hex(border)
  fmt.val    <- .fmt(df$..val, d = digits_d)

  col.map <- setNames(rep_len(as.character(fill), n.g), levels(df$..by))
  grp.hex <- .to_hex(unname(col.map))
  names(grp.hex) <- names(col.map)
  grp.text.col <- vapply(grp.hex, .contrast_text_for_hex, "", USE.NAMES = TRUE)
  labels.color.vec <- unname(grp.text.col[as.character(df$..by)])
  fill.rgba <- .maketrans_plotly(unname(col.map[as.character(df$..by)]),
                                 alpha = alpha.fill)

  if (length(labels_color)) {
    labels.color.vec <- rep_len(.to_hex(labels_color), length(labels.color.vec))
  } else if (!isTRUE(label_autocontrast)) {
    labels.color.vec <- rep(NA_character_, length(labels.color.vec))
  }

  # resolve decimal places for label text
  lbl.d <- if (!is.null(labels_decimals)) as.integer(labels_decimals)
           else                           as.integer(digits_d)

  if (!identical(labels, "off")) {
    if (identical(labels, "input")) {
      label.txt <- fmt.val
    } else if (identical(labels, "%")) {
      label.txt <- sprintf(paste0("%.", lbl.d, "f%%"), 100 * share.tot)
    } else {
      label.txt <- sprintf(paste0("%.", lbl.d, "f"), share.tot)
    }
    label.show <- ifelse(is.finite(diam.px) & (diam.px >= label_min_px),
                         label.txt, "")
  } else {
    label.show <- rep("", length(fmt.val))
  }

  trace.mode <- if (identical(labels, "off")) "markers" else "markers+text"

  txt.pos  <- if (identical(tolower(as.character(labels_position[1])), "in"))
                "middle center" else "top center"
  base.size <- 14
  txt.size  <- round(base.size *
                 (if (length(labels_size) && is.numeric(labels_size))
                    labels_size[1] else 1) *
                 getOption("axis_size", 0.9), 0)

  # replace any NA label colors with a safe default
  labels.color.vec[is.na(labels.color.vec)] <- "black"
  # IMPORTANT: do NOT create an empty trace up front (axis-type warnings)
  plt <- plotly::plot_ly(
    hoverlabel = list(align = "left")
  )

  hover <- paste0(
    x_name, ": %{customdata.xcat}",
    "<br>", by_name, ": %{customdata.bycat}",
    "<br>", y_name, ": %{hovertext}",
    "<br>% of ", x_name, ": %{customdata.pct_x:.1%}",
    "<br>% of total: %{customdata.pct_tot:.1%}<extra></extra>"
  )

  by.levels <- levels(df$..by)

  for (i in seq_along(by.levels)) {
    g <- by.levels[i]
    sel <- df$..by == g
    if (!any(sel)) next

    plt <- plotly::add_trace(
      plt,
      type = "scatter",
      mode = trace.mode,
      x = df$..x[sel],
      y = df$..by[sel],
      hovertext     = fmt.val[sel],
      hovertemplate = hover,
      customdata    = custom.rows[sel],
      marker = list(
        symbol   = "circle",
        size     = diam.px[sel],
        sizemode = "diameter",
        color    = fill.rgba[sel][1],
        line     = list(color = .to_hex(.pick_col(border, i, g)), width = 1)
      ),
      text         = label.show[sel],
      textposition = txt.pos,
      textfont     = list(size = txt.size, color = labels.color.vec[sel]),
      cliponaxis   = FALSE,
      showlegend   = FALSE
    )
  }

  plt <- plotly::layout(
    plt,
    xaxis = list(
      type      = "category",
      title     = list(
        text = x_lab,
        font = list(size = round(15 * getOption("lab_size", 1)))),
      showgrid  = TRUE,
      gridcolor = .to_hex(style_opts$grid_color),
      gridwidth = 1,
      zeroline  = FALSE,
      tickfont  = list(size = round(16 * getOption("axis_size", 0.9)))
    ),
    yaxis = list(
      type = "category",
      title = list(
        text = y_lab,
        font = list(size = round(15 * getOption("lab_size", 1)))),
      showgrid = FALSE,
      zeroline = FALSE,
      categoryorder = "array",
      categoryarray = rev(levels(df$..by)),
      tickfont = list(size = round(16 * getOption("axis_size", 0.9)))
    ),
    template = NULL,
    shapes = list(list(
      type = "rect",
      xref = "paper", yref = "paper",
      x0 = 0, x1 = 1, y0 = 0, y1 = 1,
      line = list(color = .to_hex(style_opts$panel_border), width = 1),
      layer = "below"
    ))
  )

  # backgrounds
  plt$x$layout$plot_bgcolor  <- .to_hex(style_opts$panel_fill)
  plt$x$layout$paper_bgcolor <- .to_hex(style_opts$window_fill)

  # title: only if main= is explicitly provided (default is none)
  if (!is.null(main) && length(main) && nzchar(main[1])) {
    plt <- plotly::layout(
      plt,
      title  = list(text = main[1], x = 0.5, xanchor = "center",
                    y = 0.98, yanchor = "top",
                    font = list(size = title.size)),
      margin = list(t = round(title.size * 2.2),
                    r = 25, b = 30, l = 25)
    )
  }

  invisible(plt)
}
