.radar.plotly <- function(
  x.call, by.call=NULL, facet.call=NULL, y.call=NULL,
  x.name, by.name, facet.name, y.name, main=NULL,
  stat,
  fill, border, opacity,
  digits_d = 2,
  facet_size, facet_gap_x, facet_gap_y,
  facet_title_y_base, facet_title_row_shift
) {

  # ---- 1) Build data.frame ----
  n <- length(x.call)
  if (n == 0L) stop("radar(): x.call has length 0.")

  x_fac      <- factor(x.call, levels = sort(unique(x.call)))
  by_fac_in  <- if (is.null(by.call)) factor(rep("(All)", n), levels="(All)")
                else factor(by.call, levels = unique(by.call))
  fac_fac_in <- if (is.null(facet.call)) factor(rep("(All)", n), levels="(All)")
                else factor(facet.call, levels = unique(facet.call))

  # keys for detecting pre-aggregated data
  key_df  <- data.frame(
    facet = fac_fac_in,
    by    = by_fac_in,
    x     = x_fac
  )
  has_dups <- any(duplicated(key_df))

  # ---- 2) Value vector + stat ----
  if (is.null(y.call)) {
    # classic count case
    val      <- rep(1, n)
    STAT     <- "sum"
    fun      <- function(z) sum(z, na.rm = TRUE)
    y_label  <- "Count"
  }
  else {
    # y provided
    if (is.null(stat) || !nzchar(stat)) {
      ## No stat provided: allow *pre-aggregated* input
      if (has_dups) {
        stop(
          "radar(): when y.call is provided and there are multiple rows ",
          "per facet/by/x combination, 'stat' must be one of: ",
          "mean, sum, median, min, max, sd."
        )
      }

      ## Pre-aggregated case: carry values through with identity
      STAT <- "identity"
      fun  <- function(z) z[1]

      val <- suppressWarnings(as.numeric(y.call))
      val[!is.finite(val)] <- NA_real_

      y_label <- y.name

    }
    else {
      ## Stat explicitly provided: aggregate as before
      STAT <- tolower(stat)
      fun <- switch(
        STAT,
        mean   = function(z) mean(z,   na.rm = TRUE),
        sum    = function(z) sum(z,    na.rm = TRUE),
        median = function(z) stats::median(z, na.rm = TRUE),
        min    = function(z) min(z,    na.rm = TRUE),
        max    = function(z) max(z,    na.rm = TRUE),
        sd     = function(z) stats::sd(z,     na.rm = TRUE),
        stop("Unsupported stat: ", stat)
      )

      val <- suppressWarnings(as.numeric(y.call))
      val[!is.finite(val)] <- NA_real_

      StatName <- switch(
        STAT,
        mean = "Mean", sum = "Sum", median = "Median",
        min  = "Min",  max = "Max", sd = "SD", toupper(stat)
      )
      y_label <- paste(StatName, y.name)
    }
  }

  df <- data.frame(
    facet = fac_fac_in,
    by    = by_fac_in,
    x     = x_fac,
    val   = val
  )

  # ---- 3) Aggregate to 3-way array ----
  agg <- stats::aggregate(val ~ facet + by + x, data=df, FUN=fun, drop=FALSE)
  names(agg)[names(agg) == "val"] <- "value"

  r_tab <- xtabs(value ~ facet + by + x, data = agg, drop.unused.levels=FALSE)

  facet_fac <- factor(dimnames(r_tab)[[1]], levels = dimnames(r_tab)[[1]])
  by_fac    <- factor(dimnames(r_tab)[[2]], levels = dimnames(r_tab)[[2]])
  x_fac_out <- factor(dimnames(r_tab)[[3]], levels = dimnames(r_tab)[[3]])

  if (!length(x_fac_out)) stop("radar(): x has no levels to plot.")
  if (!length(by_fac))   stop("radar(): by has no levels to plot.")
  if (!length(facet_fac))stop("radar(): facet has no levels to plot.")

  rmax  <- max(r_tab, na.rm = TRUE)
  if (!is.finite(rmax)) stop("radar(): all aggregated values are NA; nothing to plot.")
  n_fac <- length(facet_fac)
  n_by  <- length(by_fac)

  # ---- 4) Facet gaps from facet_size ----
  base_gap_x <- 0.04
  base_gap_y <- 0.11
  facet_gap_x <- facet_gap_x %||% (base_gap_x / (facet_size %||% 1))
  facet_gap_y <- facet_gap_y %||% (base_gap_y / (facet_size %||% 1))

  # ---- 5) Facet domains ----
  if (n_fac == 1L) {
    # single panel: use most of the available space but leave breathing room
    domains <- list(list(x = c(0.08, 0.95), y = c(0.05, 0.88)))
  } else {
    # shrink panels slightly and shift down to create space below main title
    domains_raw <- .plotly_make_domains_grid(
      n_fac     = n_fac,
      n_col_max = 3L,
      gap_x     = facet_gap_x,
      gap_y     = facet_gap_y,
      facet_size= facet_size
    )$domains
    # compress panels: shift down and shrink so main title has clear space
    domains <- lapply(domains_raw, function(d) {
      y0 <- d$y[1]; y1 <- d$y[2]
      span    <- (y1 - y0) * 0.72     # shrink to 72% of computed height
      new_y1  <- min(0.78, y1 - 0.10) # shift down more
      new_y0  <- max(0, new_y1 - span)
      list(x = d$x, y = c(new_y0, new_y1))
    })
  }

  # ---- 6) Angular setup ----
  theta_deg    <- .plotly_theta_from_levels(levels(x_fac_out))
  theta_closed <- c(theta_deg, theta_deg[1])
  text_labels  <- c(levels(x_fac_out), levels(x_fac_out)[1])

  # ---- 7) Colors + opacity ----
  theme <- getOption("theme")

  if (is.null(fill)) {
    fill_vec <- .color_range(.get_fill(theme), n_by)
  } else {
    fill_vec <- .color_range(fill, n_by)
  }

  border_vec <- border %||% "transparent"
  border_vec <- rep_len(border_vec, n_by)

  cols_hex   <- .to_hex(fill_vec)
  fills_rgba <- .maketrans_plotly(cols_hex, alpha = opacity)
  border_hex <- .to_hex(border_vec)

  # ---- 8) Build traces ----
  p <- plotly::plot_ly()
  val_spec <- paste0(":.", if (is.null(digits_d)) 0L
                           else max(0L, as.integer(digits_d)), "f")

  for (i in seq_along(facet_fac)) {
    fac <- facet_fac[i]
    subplot_name <- if (i == 1) "polar" else paste0("polar", i)
    show_leg <- (i == 1 && n_by > 1)

    for (j in seq_along(by_fac)) {
      r_j <- as.numeric(r_tab[fac, by_fac[j], ])
      r_closed <- c(r_j, r_j[1])

      p <- plotly::add_trace(
        p,
        type  = "scatterpolar",
        theta = theta_closed,
        thetaunit = "degrees",
        r     = r_closed,
        mode  = "lines+markers",
        line  = list(width = 2, color = border_hex[j]),
        marker = list(size = 4, color = cols_hex[j]),
        fill  = "toself",
        fillcolor = fills_rgba[j],
        name  = as.character(by_fac[j]),
        text  = text_labels,
        hovertemplate = paste0(
          x.name, ": %{text}<br>",
          if (n_by  > 1) paste0(by.name,    ": ", by_fac[j], "<br>") else "",
          if (n_fac > 1) paste0(facet.name, ": ", fac,       "<br>") else "",
          y_label, ": %{r", val_spec, "}<extra></extra>"
        ),
        subplot = subplot_name,
        showlegend = show_leg
      )
    }
  }

  # ---- 9) Polar layout ----
  p <- .plotly_apply_polar_layout(
    p,
    domains   = domains,
    theta_deg = theta_deg,
    x_labels  = levels(x_fac_out),
    rmax      = rmax
  )

  ann <- if (n_fac > 1) {
    raw_ann <- .plotly_facet_annotations(
      domains,
      facet_levels = levels(facet_fac),
      facet_name   = facet.name,
      y_base       = facet_title_y_base,
      y_row_shift  = facet_title_row_shift,
      font_size    = 14
    )
    # reposition each annotation just above its panel's top edge
    lapply(seq_along(raw_ann), function(i) {
      top_y <- domains[[i]]$y[2]
      raw_ann[[i]]$y       <- min(top_y + 0.12, 0.97)
      raw_ann[[i]]$yanchor <- "bottom"
      raw_ann[[i]]
    })
  } else
    list()

  # ---- 10) Final layout ----
  # When a legend is shown (n_by > 1) place it OUTSIDE the paper (x > 1) so
  # Plotly never auto-shifts the polar domain to avoid legend overlap.
  has_legend <- n_by > 1
  r_margin   <- if (has_legend) 110L else 20L
  l_margin   <- if (n_fac == 1L) 20L else r_margin  # left never needs legend space

p <- plotly::layout(
  p,
  title = list(
    text = main,
    x = 0.5, xanchor = "center",
    y = 0.97, yanchor = "top"
  ),
  margin = list(t = 45, b = 10, l = l_margin, r = r_margin),
  showlegend = has_legend,
  annotations = ann,
  legend = list(
    x = 1.02,
    xanchor = "left",
    y = 1.0,
    yanchor = "top",
    bordercolor = .to_hex(getOption("panel_border", "gray70")),
    borderwidth = 1
  )
)

  # Finalize to ensure htmlwidget form + sizingPolicy + unique id
  p <- .finalize_plotly_widget(
    p,
    kind         = "radar",
    x.name       = x.name,
    by.name      = by.name,
    add_title    = FALSE,
    nudge_viewer = (.allow.interactive() && !isTRUE(getOption("knitr.in.progress"))),
    unique_element = TRUE
  )

  return(invisible(p))
}
