.bc.plotly <-
function(x, x_name, y_name, by_name, x_lab, y_lab,
         fill, border, opacity = NULL,
         beside, horiz, ax, gridT, digits_d, main=NULL,
         rotate_x=0, gap=NULL, break_x=FALSE, counts=NULL,
         labels_decimals=NULL,
         legend_title=NULL, legend_position=NULL,
         legend_labels=NULL, legend_horiz=FALSE,
         legend_size=NULL, legend_abbrev=NULL, legend_adjust=0,
         labels=c("%", "input", "prop", "off"),
         labels_size = 12,
         labels_color = NULL,
         labels_autocontrast = TRUE,
         style_opts = .plotly_style()) {

  # did the caller actually supply labels= ?
  missing.labels <- missing(labels)

  labels <- match.arg(labels)
  title.size <- round(16 * getOption("main_size", 1))
  alpha.fill <- if (is.null(opacity)) 0.92 else as.numeric(opacity)

  # labels_size arrives as a Base R cex value (e.g. 0.75); convert to pixels
  # axis_size is already folded into labels_size upstream (Chart.R), so do not
  # multiply by it here — that double-scales when font_size is in effect.
  labels_size <- max(8, round(labels_size * 14))

  # Show % lines in hover only when data are counts (i.e., user did NOT
  # explicitly pass labels="input", which signals means / deviations).
  # missing.labels=TRUE covers the 1-D default path that overrides to "input"
  # for tabulated counts — percentages are still meaningful there.
  show.pct.hover <- missing.labels || labels != "input"

  .row_customdata <- function(a, b = NULL) {
    a <- as.numeric(a)
    if (is.null(b)) unname(lapply(a, function(z) c(z)))
    else {
      b <- as.numeric(b)
      unname(Map(function(z1, z2) c(z1, z2), a, b))
    }
  }

  .pick <- function(v, nm, i) {
    v   <- as.character(v)
    nms <- names(v)
    if (!is.null(nms) && nm %in% nms) return(v[[nm]])
    if (length(v) >= i) return(v[[i]])
    if (!length(v)) return("black")
    v[[ ((i - 1L) %% length(v)) + 1L ]]
  }

  .fmt_val <- function(v, d) .fmt(v, d = d)

  # where a named position puts the legend: x, y, xanchor, yanchor.
  # "right_margin" is the default, outside the panel; the rest are
  # the base legend() keywords, placed inside the panel as there.
  .legend_pos <- list(
    right_margin = list(1.02, 0.5,  "left",   "middle"),
    topleft      = list(0.02, 0.98, "left",   "top"),
    top          = list(0.50, 0.98, "center", "top"),
    topright     = list(0.98, 0.98, "right",  "top"),
    left         = list(0.02, 0.50, "left",   "middle"),
    center       = list(0.50, 0.50, "center", "middle"),
    right        = list(0.98, 0.50, "right",  "middle"),
    bottomleft   = list(0.02, 0.02, "left",   "bottom"),
    bottom       = list(0.50, 0.02, "center", "bottom"),
    bottomright  = list(0.98, 0.02, "right",  "bottom"))

  .abbrev <- function(txt) {
    if (is.null(legend_abbrev) || is.null(txt)) return(txt)
    abbreviate(txt, minlength=as.integer(legend_abbrev))
  }

  # decimals of the label text. NULL keeps each mode's own default,
  # which is what a direct call to .bc.plotly gets; Chart() supplies
  # a value whenever the user set labels_decimals.
  .lbl.d <- function(mode, fallback) {
    if (!is.null(labels_decimals)) as.integer(labels_decimals)
    else if (identical(mode, "%")) 0L
    else if (identical(mode, "prop")) 2L
    else fallback
  }

  # category labels for the categorical axis: a space becomes a line
  # break when break_x, and "~" is the non-breaking space that
  # survives it, the same rule as .get.val.ln() for base graphics
  .break_labels <- function(cats) {
    if (break_x) cats <- gsub(" ", "<br>", cats, fixed=TRUE)
    gsub("~", " ", cats, fixed=TRUE)
  }

  # barplot space= (gaps in multiples of bar width) as plotly's
  # bargap, the fraction of a category slot left empty. A slot holds
  # k bars, the gaps between them, and one between-group gap.
  # NULL leaves plotly's own default in place.
  # The within-group gap of a beside chart is plotly's bargroupgap,
  # which the R plotly package's schema does not recognize: setting
  # it warns on every build. So gap[1] is not applied here, and the
  # bars of a group keep plotly's own spacing.
  .bar_gaps <- function(gap, beside, k) {
    if (is.null(gap)) return(NULL)
    within  <- as.numeric(gap[1L])
    between <- as.numeric(gap[length(gap)])
    if (any(c(within, between) < 0)) {
      cat("\n"); stop(call.=FALSE, "\n","------\n",
      "Parameter  gap  cannot be negative\n\n")
    }
    if (!beside)
      return(list(bargap = between / (1 + between)))
    k <- max(1L, k)
    list(bargap = between / (k + (k - 1) * within + between))
  }

  # we'll capture the category order from the incoming data
  cat.vals <- NULL

  # ------------------ 1-D ------------------
  if (length(dim(x)) == 1L) {
    df <- setNames(data.frame(names(x), as.numeric(x), check.names = FALSE),
                   c(x_name, y_name))

    # the order of names(x) here already reflects any sort done upstream
    cat.vals <- as.character(df[[x_name]])

    # percentages come from the counts when the heights have already
    # been rescaled to proportions
    cnt.1d  <- if (is.null(counts)) df[[y_name]] else as.numeric(counts)
    total.n <- sum(cnt.1d, na.rm = TRUE)
    share   <- if (total.n > 0) cnt.1d / total.n else rep(NA_real_, nrow(df))

    # only force "input" when the user did NOT specify labels=
    if (missing.labels) {
      labels <- "input"
    }

    tick.fmt <- .get.tick.fmt(df[[y_name]], digits_d)

    pct.line <- if (show.pct.hover) "<br>% of total: %{customdata:.1%}" else ""

    hover <- if (!horiz)
      paste0(x_name, ": %{x}<br>",
             y_name, ": %{y:", tick.fmt, "}",
             pct.line, "<extra></extra>")
    else
      paste0(x_name, ": %{y}<br>",
             y_name, ": %{x:", tick.fmt, "}",
             pct.line, "<extra></extra>")

    fill.vec   <- .to_hex(rep_len(fill, nrow(df)))
    border.hex <- .to_hex(rep_len(border, nrow(df)))

    if (labels == "off") {
      txt <- rep("", nrow(df))
    } else if (labels == "input") {
      # over proportions, show the counts, as base graphics does
      txt <- .fmt_val(cnt.1d, .lbl.d("input",
               if (isTRUE(all(cnt.1d == round(cnt.1d)))) 0
               else digits_d))
    } else if (labels == "%") {
      txt <- sprintf(paste0("%.", .lbl.d("%", 0L), "f%%"), 100 * share)
    } else {  # "prop"
      txt <- sprintf(paste0("%.", .lbl.d("prop", 2L), "f"), share)
    }

    if (length(labels_color)) {
      txt.col <- rep_len(.to_hex(labels_color), nrow(df))
    } else if (isTRUE(labels_autocontrast)) {
      txt.col <- vapply(fill.vec, .contrast_text_for_hex, "")
    } else {
      txt.col <- NULL
    }

    plt <- plotly::plot_ly(
      type        = "bar",
      x           = if (!horiz) df[[x_name]] else df[[y_name]],
      y           = if (!horiz) df[[y_name]] else df[[x_name]],
      orientation = if (!horiz) NULL else "h",
      marker = list(
        color   = fill.vec,
        opacity = alpha.fill,
        line    = list(color = border.hex, width = 1)
      ),
      customdata       = .row_customdata(share),
      hovertemplate    = hover,
      text             = txt,
      textposition     = "inside",
      insidetextanchor = "middle",
      textfont         = list(size = labels_size, color = txt.col),
      cliponaxis       = FALSE
    )

  } else {
    # ------------------ 2-D ------------------
    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 input, x must be a table or matrix.")

    names(df) <- c(by_name, x_name, y_name)

    # preserve row/column order of the incoming table/matrix,
    # which upstream code may already have sorted by totals
    df[[by_name]] <- factor(df[[by_name]], levels = rownames(x))
    df[[x_name]]  <- factor(df[[x_name]],  levels = colnames(x))

    cat.vals <- levels(df[[x_name]])

    # mirror 1-D behaviour: default to showing input values, not percentages,
    # when the caller did not explicitly specify labels= (e.g. means/deviations)
    if (missing.labels) labels <- "input"

    # percentages come from the counts when the heights have already
    # been rescaled to proportions (stack100)
    df$cnt <- if (is.null(counts)) df[[y_name]]
              else as.numeric(as.data.frame(as.table(counts))[[3L]])

    total.n <- sum(df$cnt, na.rm = TRUE)
    df$share_total <- if (total.n > 0) df$cnt / total.n
                      else rep(NA_real_, nrow(df))

    x.totals <- tapply(df$cnt, df[[x_name]], sum, na.rm = TRUE)
    x.denom  <- as.numeric(x.totals[as.character(df[[x_name]])])
    df$share_within_x <- ifelse(x.denom != 0, df$cnt / x.denom, NA_real_)

    grp    <- df[[by_name]]
    groups <- levels(grp)

    # the key text: legend_labels renames the by levels, and
    # legend_abbrev shortens them, as in the base graphics legend
    key.names <- if (!is.null(legend_labels)) {
      if (length(legend_labels) != length(groups)) {
        cat("\n"); stop(call.=FALSE, "\n","------\n",
        "legend_labels has ", length(legend_labels), " entries for ",
        length(groups), " levels of the  by  variable\n\n")
      }
      as.character(legend_labels)
    } else as.character(groups)
    key.names <- unname(.abbrev(key.names))

    tick.fmt <- .get.tick.fmt(df[[y_name]], digits_d)

    pct.lines_2d <- if (show.pct.hover)
      paste0("<br>% of ", x_name, ": %{customdata[1]:.1%}",
             "<br>% of total: %{customdata[0]:.1%}")
    else ""

    hover <- if (!horiz)
      paste0(
        x_name, ": %{x}<br>",
        by_name, ": %{fullData.name}<br>",
        y_name, ": %{y:", tick.fmt, "}",
        pct.lines_2d, "<extra></extra>"
      )
    else
      paste0(
        x_name, ": %{y}<br>",
        by_name, ": %{fullData.name}<br>",
        y_name, ": %{x:", tick.fmt, "}",
        pct.lines_2d, "<extra></extra>"
      )

    plt <- plotly::plot_ly()

    for (i in seq_along(groups)) {
      gname <- groups[i]
      rows  <- which(grp == gname)
      if (!length(rows)) next

      fill.i   <- .to_hex(.pick(fill, gname, i))
      border.i <- .to_hex(.pick(border, gname, i))

      cd.list <- .row_customdata(df$share_total[rows], df$share_within_x[rows])

      xv <- if (!horiz) df[[x_name]][rows] else df[[y_name]][rows]
      yv <- if (!horiz) df[[y_name]][rows] else df[[x_name]][rows]

      # a stack100 bar IS its within-x percentage, so that is what
      # "%" / "prop" report there, and "input" shows the counts
      shr <- if (is.null(counts)) df$share_total[rows]
             else df$share_within_x[rows]

      if (labels == "off") {
        txt <- rep("", length(rows))
      } else if (labels == "input") {
        cnt.i <- df$cnt[rows]
        txt <- .fmt_val(cnt.i, .lbl.d("input",
                 if (isTRUE(all(cnt.i == round(cnt.i)))) 0
                 else digits_d))
      } else if (labels == "%") {
        txt <- sprintf(paste0("%.", .lbl.d("%", 0L), "f%%"), 100 * shr)
      } else {  # "prop"
        txt <- sprintf(paste0("%.", .lbl.d("prop", 2L), "f"), shr)
      }

      if (length(labels_color)) {
        txt.col <- rep_len(.to_hex(labels_color), length(rows))
      } else if (isTRUE(labels_autocontrast)) {
        txt.col <- rep_len(.contrast_text_for_hex(fill.i), length(rows))
      } else {
        txt.col <- NULL
      }

      plt <- plotly::add_trace(
        plt,
        type        = "bar",
        x           = xv,
        y           = yv,
        orientation = if (!horiz) NULL else "h",
        name        = key.names[i],
        legendgroup = gname,
        marker = list(
          color   = fill.i,
          opacity = alpha.fill,
          line    = list(color = border.i, width = 1)
        ),
        customdata       = cd.list,
        hovertemplate    = hover,
        text             = txt,
        textposition     = "inside",
        insidetextanchor = "middle",
        textfont         = list(size = labels_size, color = txt.col),
        cliponaxis       = FALSE
      )
    }

    plt <- plotly::layout(
      plt,
      barmode = if (beside) "group" else "stack"
    )
  }

  # --- axes & background ------------------------------------------------
  border.shapes <- plot_border()

  # unique category order taken from incoming (potentially sorted) table
  cat.array <- unique(as.character(cat.vals))

  # tick labels wrap at their spaces when break_x; the bar
  # coordinates stay the raw category values
  cat.ticks <- list(
    tickmode = "array",
    tickvals = seq_along(cat.array) - 1L,
    ticktext = .break_labels(cat.array)
  )

  n.grp <- if (length(dim(x)) == 1L) 1L else nrow(x)
  bar.gaps <- .bar_gaps(gap, beside && length(dim(x)) > 1L, n.grp)

  if (horiz) {
    # categories along y-axis
    y.axis.cat <- c(
      axis_cat(x_lab),
      list(
        categoryorder = "array",
        categoryarray = cat.array
      ),
      cat.ticks
    )

    plt <- plotly::layout(
      plt,
      xaxis  = axis_num(y_lab, ax$axT2, ax$axL2),
      yaxis  = y.axis.cat,
      shapes = c(x_grid(gridT), border.shapes),
      template = NULL
    )
  } else {
    # categories along x-axis
    x.axis.cat <- c(
      axis_cat(x_lab),
      list(
        categoryorder = "array",
        categoryarray = cat.array,
        tickangle = -rotate_x
      ),
      cat.ticks
    )

    plt <- plotly::layout(
      plt,
      xaxis  = x.axis.cat,
      yaxis  = axis_num(y_lab, ax$axT1, ax$axL1),
      shapes = c(y_grid(gridT), border.shapes),
      template = NULL
    )
  }

  if (!is.null(bar.gaps))
    plt <- do.call(plotly::layout, c(list(plt), bar.gaps))

  plt <- plotly::layout(
    plt,
    plot_bgcolor  = .to_hex(style_opts$panel_fill),
    paper_bgcolor = .to_hex(style_opts$window_fill)
  )

  if (!is.null(by_name) && nzchar(by_name)) {
    leg.mult <- if (is.null(legend_size)) 1 else as.numeric(legend_size)
    leg.ttl  <- if (is.null(legend_title)) by_name else legend_title
    leg <- list(
      title = list(
        text = unname(.abbrev(leg.ttl)),
        font = list(size = round(
          15 * getOption("lab_size", 1) * leg.mult))),
      bgcolor     = "rgba(255,255,255,0.9)",
      bordercolor = "#CCCCCC",
      borderwidth = 1,
      font = list(size = round(
        16 * getOption("axis_size", 0.9) * leg.mult))
    )
    if (isTRUE(legend_horiz)) leg$orientation <- "h"
    if (!is.null(legend_position) &&
        !(legend_position %in% names(.legend_pos))) {
      cat("\n"); stop(call.=FALSE, "\n","------\n",
      "legend_position must be one of:\n  ",
      paste(names(.legend_pos), collapse=", "), "\n\n")
    }
    # "right_margin" is plotly's own outside-right placement, so
    # leave the layout alone there unless the legend is nudged
    if ((!is.null(legend_position) &&
         legend_position != "right_margin") ||
        (!is.null(legend_adjust) && legend_adjust != 0)) {
      pk <- if (is.null(legend_position)) "right_margin"
            else legend_position
      pv <- .legend_pos[[pk]]
      leg$x <- pv[[1]] + as.numeric(legend_adjust)
      leg$y <- pv[[2]]
      leg$xanchor <- pv[[3]]
      leg$yanchor <- pv[[4]]
    }
    plt <- plotly::layout(plt, legend = leg)
  }

  if (!is.null(main) && nzchar(main)) {
    plt <- plotly::layout(plt,
      title  = list(text = main, x = 0.5,
                    xanchor = "center", y = 0.99, yanchor = "top",
                    font = list(size = title.size)),
      margin = list(t = round(title.size * 2.2))
    )
  }

  plt <- .finalize_plotly_widget(
    plt,
    kind         = "barchart",
    x_name       = x_name,
    by_name      = by_name,
    add_title    = FALSE,
    nudge_viewer = TRUE
  )

  invisible(plt)
}
