#' Insert images and inline plots into tinytable objects
#'
#' The `plot_tt()` function allows for the insertion of images and inline plots into
#' tinytable objects. This function can handle both local and web-based images.
#'
#' @param x A tinytable object.
#' @param i Integer vector, the row indices where images are to be inserted. If `NULL`,
#'    images will be inserted in all rows.
#' @param j Integer vector, the column indices where images are to be inserted. If `NULL`,
#'    images will be inserted in all columns.
#' @param height Numeric, the height of the images in the table in em units.
#' @param height_plot Numeric, the height of generated plot images in pixels (default: 400).
#' @param width_plot Numeric, the width of generated plot images in pixels (default: 1200).
#' @param color string Name of color to use for inline plots (passed to the `col` argument base `graphics` plots in `R`). For bar plots in static output formats (PNG, PDF, etc.), can be a vector of length 2: c(bar_color, background_color) to show progress against a maximum. Note: Tabulator format only uses the first color.
#' @param xlim Numeric vector of length 2. Controls the range of bar plots.
#' @param fun  String or function to generate inline plots.
#' - Built-in plot types (strings):
#'   - `"histogram"`: Creates histograms from numeric vectors. Accepts `color` argument.
#'   - `"density"`: Creates density plots from numeric vectors. Accepts `color` argument.
#'   - `"bar"`: Creates horizontal bar charts from single numeric values. Accepts `color` (single value, or length-2 vector for bar and background colors in static formats) and `xlim` arguments.
#'   - `"line"`: Creates line plots from data frames with `x` and `y` columns. Accepts `color` and `xlim` arguments.
#' - Custom functions:
#'   - Functions that return `ggplot2` objects.
#'   - Functions that return another function which generates a base `R` plot, ex: `function(x) {function() hist(x)}`
#'   - Note: When using custom ggplot2 functions that return plots with text elements, the text size will normally need to be adjusted because the plot is inserted as a very small image in the table. Text sizes of 1 or smaller often work well (e.g., `theme(text = element_text(size = 1))`).
#' - See the tutorial on the `tinytable` website for more information.
#' @param data a list of data frames or vectors to be used by the plotting functions in `fun`.
#' @param images Character vector, the paths to the images to be inserted. Paths are relative to the main table file or Quarto (Rmarkdown) document.
#' @param sprintf Character string, a sprintf format string to format the generated cell content. Default is "%s" which displays the content as-is. Use this to wrap images or plots in custom markup.
#' @param assets Path to the directory where generated assets are stored. This path is relative to the location where a table is saved.
#' @param output Apply plot insertion only when the table is rendered in the specified format. A character vector of one or more of "latex", "html", "typst", or "markdown". If `NULL` (default), plots are inserted regardless of the output format.
#' @param ... Extra arguments are passed to the function in `fun`. Important: Custom plotting functions must always have `...` as an argument.
#'
#' @return A modified tinytable object with images or plots inserted.
#'
#' @details The `plot_tt()` can insert images and inline plots into tables.
#'
#' @examples
#' \dontrun{
#' # Bar plots with single and dual colors
#' dat <- data.frame(
#'   Metric = c("Sales", "Conversion", "Growth", "Efficiency"),
#'   Value = c(75, 45, 92, 38),
#'   Percentage = c(0.75, 0.45, 0.92, 0.38)
#' )
#'
#' tt(dat) |>
#'   plot_tt(j = 2, fun = "bar", data = as.list(dat$Value), color = "darkorange") |>
#'   plot_tt(j = 3, fun = "bar", data = as.list(dat$Percentage),
#'           color = c("steelblue", "lightgrey"), xlim = c(0, 1))
#'
#' # Built-in plot types
#' plot_data <- list(mtcars$mpg, mtcars$hp, mtcars$qsec)
#'
#' dat <- data.frame(
#'   Variables = c("mpg", "hp", "qsec"),
#'   Histogram = "",
#'   Density = "",
#'   Line = ""
#' )
#'
#' # Random data for sparklines
#' lines <- lapply(1:3, \(x) data.frame(x = 1:10, y = rnorm(10)))
#'
#' tt(dat) |>
#'   plot_tt(j = 2, fun = "histogram", data = plot_data) |>
#'   plot_tt(j = 3, fun = "density", data = plot_data, color = "darkgreen") |>
#'   plot_tt(j = 4, fun = "line", data = lines, color = "blue") |>
#'   style_tt(j = 2:4, align = "c")
#'
#' # Custom function example (must have ... argument)
#' custom_hist <- function(d, ...) {
#'   function() hist(d, axes = FALSE, ann = FALSE, col = "lightblue")
#' }
#'
#' tt(data.frame(Variables = "mpg", Histogram = "")) |>
#'   plot_tt(j = 2, fun = custom_hist, data = list(mtcars$mpg))
#' }
#'
#' @export
plot_tt <- function(
    x,
    i = NULL,
    j = NULL,
    fun = NULL,
    data = NULL,
    color = "black",
    xlim = NULL,
    height = 1,
    height_plot = 400,
    width_plot = 1200,
    images = NULL,
    sprintf = "%s",
    assets = "tinytable_assets",
    output = NULL,
    ...) {
  assert_subset(output, c("latex", "html", "typst", "markdown"), null.ok = TRUE)

  # non-standard evaluation before anything else
  tmp <- nse_i_j(x, i_expr = substitute(i), j_expr = substitute(j), pf = parent.frame())
  list2env(tmp, environment())

  jval <- sanitize_j(j, x)
  ival <- sanitize_i(i, x, calling_function = "plot_tt")
  assert_numeric(height, len = 1, lower = 0)
  assert_class(x, "tinytable")

  # Calculate actual length considering NULL i values
  ival_length <- if (isTRUE(attr(ival, "null"))) {
    length(attr(ival, "body"))
  } else {
    length(ival)
  }
  len <- ival_length * length(jval)

  assert_list(data, len = len, null.ok = TRUE)
  assert_character(images, len = len, null.ok = TRUE)

  if (!is.null(fun) && !is.null(images)) {
    stop("`fun` and `images` cannot be used together.", call. = FALSE)
  }

  if (!is.null(fun) && is.null(data)) {
    stop("Please specify `data`.", call. = FALSE)
  }

  if (is.character(fun)) {
    assert_choice(fun, c("histogram", "density", "bar", "barpct", "line"))
  } else {
    assert_function(fun, null.ok = TRUE)
  }

  # built-in plots
  if (identical(fun, "histogram")) {
    fun <- rep(list(tiny_histogram), length(data))
  } else if (identical(fun, "density")) {
    fun <- rep(list(tiny_density), length(data))
  } else if (identical(fun, "line")) {
    fun <- rep(list(tiny_line), length(data))
  } else if (identical(fun, "bar")) {
    for (idx in seq_along(data)) {
      assert_numeric(data[[idx]], len = 1, name = base::sprintf("data[[%d]]", idx))
    }
    if (is.null(xlim)) {
      xlim <- c(0, max(unlist(data)))
    }
    fun <- rep(list(tiny_bar), length(data))
  } else if (identical(fun, "barpct")) {
    for (idx in seq_along(data)) {
      assert_numeric(data[[idx]], len = 1, name = base::sprintf("data[[%d]]", idx))
      if (!all(data[[idx]] >= 0 & data[[idx]] <= 1, na.rm = TRUE)) {
        stop("Data for 'barpct' must be between 0 and 1 (percentages).", call. = FALSE)
      }
    }
    # Always set xlim to c(0, 1) for barpct
    xlim <- c(0, 1)
    # Default to lightgrey background if color is single value
    if (length(color) == 1) {
      color <- c(color, "lightgrey")
    }
    fun <- rep(list(tiny_bar), length(data))
  } else {
    fun <- rep(list(fun), length(data))
  }

  # needed when rendering in tempdir()
  cal <- list(
    "plot_tt_lazy",
    x = x,
    i = ival,
    j = jval,
    data = data,
    fun = fun,
    color = color,
    xlim = xlim,
    height = height,
    height_plot = height_plot,
    width_plot = width_plot,
    images = images,
    sprintf = sprintf,
    assets = assets
  )
  cal <- c(cal, list(...))
  cal <- do.call(call, cal)
  attr(cal, "output") <- output

  x@lazy_plot <- c(x@lazy_plot, list(cal))

  return(x)
}

plot_tt_lazy <- function(
    x,
    i = NULL,
    j = NULL,
    height = 1,
    height_plot = 400,
    width_plot = 1200,
    fun = NULL,
    color = NULL,
    data = NULL,
    xlim = NULL,
    images = NULL,
    sprintf = "%s",
    assets = "tinytable_assets",
    ...) {
  # Row indices derived from an NSE predicate were computed eagerly in
  # plot_tt() against the pre-group data rows (the marker set by nse_i_j()
  # survives sanitize_i()). The @lazy_plot loop runs after
  # rbind_body_groupi(), so @data_body below is the final table and numeric
  # `i` must be in final-table coordinates: remap through @index_body.
  i <- nse_remap_i(i, x)

  out <- x@data_body

  # Handle Tabulator plots with JavaScript formatters
  # Note: images use the standard HTML path below, which works for tabulator too
  is_tabulator <- isTRUE(x@output == "html" && x@html_engine == "tabulator")
  if (is_tabulator && !is.null(data)) {
    result <- plot_tt_tabulator(
      x, i = i, j = j, fun = fun, data = data,
      color = color, xlim = xlim, ...
    )
    # If plot_tt_tabulator returns NULL, it means we should fall back to PNG rendering
    # (e.g., for custom functions). Otherwise, return the result.
    if (!is.null(result)) {
      return(result)
    }
    # Fall through to PNG rendering below
  }

  is_html <- isTRUE(x@output %in% c("html", "bootstrap", "tabulator"))
  is_typst_portable <- isTRUE(x@output == "typst" && x@typst_portable)
  is_quarto <- isTRUE(check_dependency("knitr")) && !is.null(knitr::pandoc_to())

  # paths are tricky in Quarto HTML (website vs single file)
  is_portable <- is_html && (isTRUE(x@html_portable) || is_quarto)
  if (is_portable || is_typst_portable) assert_dependency("base64enc")

  # Normalize user-provided image paths to full paths
  if (!is.null(images)) {
    # quarto requires relative links or url
    # print("html") must be run from a tempdir on linux, so we need absolute paths
    # LaTeX requires relative paths with forward slashes
    if (isTRUE(x@output == "latex")) {
      # For LaTeX: convert backslashes to forward slashes for cross-platform compatibility
      images <- gsub("\\\\", "/", images)
    } else if (!is_quarto) {
      # URLs are not file paths: normalizePath() would prepend the working
      # directory and flip the slashes on Windows.
      url <- grepl("^http", trimws(images))
      images[!url] <- normalizePath(images[!url], mustWork = FALSE)
    }
  }

  if (!is.null(data)) {
    assert_dependency("ggplot2")
    images <- NULL

    # path_assets directory stores dynamically generated plots
    if (is_portable || is_typst_portable) {
      path_assets <- tempdir()
      # quarto and LaTeX require relative paths from the project folder
    } else if (is_quarto || isTRUE(x@output == "latex")) {
      path_assets <- assets
    } else {
      path_assets <- file.path(x@output_dir, assets)
    }
    if (!dir.exists(path_assets)) {
      dir.create(path_assets, recursive = TRUE, showWarnings = FALSE)
      if (!dir.exists(path_assets)) {
        stop(
          base::sprintf("Unable to create the assets directory: %s", path_assets),
          call. = FALSE
        )
      }
    }

    # Rank hack: prepend zero-padded rank to filename to allow sorting based on
    # file names in interactive tables like tabulator
    last_values <- sapply(data, plot_data_rank)
    ranks <- rank(last_values, ties.method = "first")
    n_digits <- nchar(as.character(length(data)))
    zero_padded_ranks <- sprintf(paste0("%0", n_digits, "d"), ranks)

    for (idx in seq_along(data)) {
      fn <- paste0("tinytable_", zero_padded_ranks[idx], "_", get_id(), ".png")
      fn_full <- file.path(path_assets, fn)

      # For LaTeX and Typst output: use relative paths with forward slashes (portable across all platforms)
      # For other outputs: use normalized absolute paths
      if (isTRUE(x@output == "latex" | x@output == "typst")) {
        # Keep relative path and convert backslashes to forward slashes for LaTeX
        # fn_full is used for saving the file (OS-native path)
        # images[idx] is used in \includegraphics (needs forward slashes)
        images[idx] <- gsub("\\\\", "/", fn_full)
      } else {
        # For HTML/other formats, normalize to absolute paths for proper file access
        fn_full <- normalizePath(fn_full, mustWork = FALSE)
        images[idx] <- fn_full
      }

      plot_fun <- fun[[idx]]
      if (!"..." %in% names(formals(plot_fun))) {
        stop(
          "Inline plotting function must have `...` as argument. See tutorial on the `tinytable` website for examples.",
          call. = FALSE
        )
      }

      p <- plot_fun(data[[idx]], xlim = xlim, color = color, ...)

      # ggplot2
      if (inherits(p, "ggplot")) {
        assert_dependency("ggplot2")
        suppressMessages(
          ggplot2::ggsave(
            p,
            filename = fn_full,
            width = width_plot,
            height = height_plot,
            units = "px"
          )
        )

        # base R
      } else if (is.function(p)) {
        render_base_plot(p, fn_full, width_plot, height_plot)

        # sanity check
      } else {
        msg <- "The functions in the `fun` list must return a function or a `ggplot2` object. See the tutorial online for examples: https://vincentarelbundock.github.io/tinytable"
        stop(msg, call. = FALSE)
      }
    }
  }

  if (isTRUE(x@output == "latex")) {
    cell <- "\\includegraphics[height=%sem]{%s}"
    cell <- base::sprintf(cell, format_markup_num(height), images)
  } else if (is_portable) {
    http <- grepl("^http", trimws(images))
    images[!http] <- encode(images[!http])
    cell <- base::sprintf('<img src="%s" style="height: %sem;">', images, format_markup_num(height))
  } else if (is_html) {
    # Convert relative paths to absolute paths for save_tt/print
    http <- grepl("^http", trimws(images))
    for (img_idx in seq_along(images)) {
      if (!http[img_idx]) {
        # Convert relative paths to absolute paths
        if (!grepl("^/", trimws(images[img_idx])) && !grepl("^[A-Za-z]:", trimws(images[img_idx]))) {
          images[img_idx] <- file.path(x@output_dir, images[img_idx])
        }
      }
    }
    cell <- base::sprintf('<img src="%s" style="height: %sem;">', images, format_markup_num(height))
  } else if (isTRUE(x@output == "markdown")) {
    cell <- "![](%s){ height=%s }"
    cell <- base::sprintf(cell, images, format_markup_num(height * 16))
  } else if (isTRUE(x@output == "typst")) {
    if (is_typst_portable) {
      cell <- encode_typst(images, height, width_plot, height_plot)
    } else {
      cell <- '#image("%s", height: %sem)'
      cell <- base::sprintf(cell, images, format_markup_num(height))
    }
  } else if (isTRUE(x@output == "dataframe")) {
    cell <- "%s"
    cell <- base::sprintf(cell, images)
  } else {
    stop("here be dragons")
  }

  cell <- base::sprintf(sprintf, cell)

  # Handle column header insertions (i=0)
  if (0 %in% i) {
    # Insert into header (column names)
    if (is.null(x@names) || length(x@names) == 0) {
      stop("Cannot insert images into header: table has no column names.", call. = FALSE)
    }

    # Fill column-major, matching the body-only path (`out[i, j] <- cell`):
    # the same `data`/`images` vector maps to the same cells whether or not
    # `i` includes the header row (0).
    cell_idx <- 1
    for (j_val in j) {
      for (i_val in i) {
        if (i_val == 0) {
          if (j_val <= length(x@names)) {
            x@names[j_val] <- cell[cell_idx]
          }
        } else {
          out[i_val, j_val] <- cell[cell_idx]
        }
        cell_idx <- cell_idx + 1
      }
    }
  } else {
    # Original behavior: insert into data body
    out[resolve_i_body(i), j] <- cell
  }

  x@data_body <- out

  # Mark columns with HTML content for HTML formatter in Tabulator
  # For custom functions with PNG images, also add rank fields for sorting
  if (isTRUE(x@html_engine == "tabulator")) {
    i_body <- resolve_i_body(i)

    for (col_idx in j) {
      col_name <- x@names[col_idx]
      if (!is.null(col_name) && !(col_name %in% names(x@tabulator_column_formatters))) {
        x@tabulator_column_formatters[[col_name]] <- list(formatter = "html")

        # Add rank fields for sorting (custom functions use PNG, but still need sorting)
        if (!is.null(data)) {
          x <- tabulator_add_rank_fields(x, col_name, col_idx, j, i_body, data)
        }
      }
    }
  }

  return(x)
}

#' Resolve body row indices from a sanitized `i`
#'
#' `sanitize_i()` returns `NA` with a "null" attribute when `i` was `NULL`;
#' in that case the body rows are stored in the "body" attribute.
#' @keywords internal
#' @noRd
resolve_i_body <- function(i) {
  if (all(is.na(i)) && isTRUE(attr(i, "null"))) {
    attr(i, "body")
  } else {
    i
  }
}

#' Write hidden rank fields and sorter configuration for a Tabulator plot column
#'
#' The `data` list is ordered column-major over the `i_body` x `j` grid;
#' `plot_data_rank()` extracts a sortable scalar per cell. The rank values are
#' stored in a hidden `rank_<column>` field (no leading underscore: R converts
#' it to `X_rank_`) used by the custom `tinytable_rank_sorter`.
#' @keywords internal
#' @noRd
tabulator_add_rank_fields <- function(x, col_name, col_idx, j, i_body, data) {
  rank_col_name <- paste0("rank_", col_name)

  # Get data for this column
  col_data_idx <- 1
  if (length(j) > 1) {
    col_data_idx <- which(j == col_idx)
  }

  for (row_idx in seq_along(i_body)) {
    data_idx <- (col_data_idx - 1) * length(i_body) + row_idx
    sort_value <- plot_data_rank(data[[data_idx]])
    x@data_body[i_body[row_idx], rank_col_name] <- sort_value
  }

  # Configure sorter to use rank field
  x@tabulator_column_formatters[[col_name]]$sorter <- "tinytable_rank_sorter"
  x@tabulator_column_formatters[[col_name]]$sorterParams <- list(rankField = rank_col_name)

  return(x)
}

#' Render a base R plot function to a PNG file
#'
#' The device is closed via `on.exit()` so an error in the user's plot
#' function does not leak an open graphics device.
#' @keywords internal
#' @noRd
render_base_plot <- function(p, path, width, height) {
  grDevices::png(path, width = width, height = height)
  on.exit(grDevices::dev.off(), add = TRUE)
  graphics::par(mar = c(0, 0, 0, 0))
  p()
  invisible(NULL)
}

plot_data_rank <- function(x) {
  if (is.list(x) || is.data.frame(x)) {
    if (is.data.frame(x) && "y" %in% names(x)) {
      utils::tail(x$y, n = 1)
    } else if (is.list(x)) {
      utils::tail(unlist(x), n = 1)
    } else {
      utils::tail(x, n = 1)
    }
  } else {
    utils::tail(x, n = 1)
  }
}

tiny_histogram <- function(d, color = "black", ...) {
  function() graphics::hist(d, col = color, axes = FALSE, ann = FALSE)
}

tiny_density <- function(d, color = "black", ...) {
  function() {
    d <- stats::density(stats::na.omit(d))
    graphics::plot(d, axes = FALSE, ann = FALSE, col = color)
    graphics::polygon(d, col = color)
  }
}

tiny_bar <- function(d, color = "black", xlim = 0:1, ...) {
  function() {
    if (length(color) == 2) {
      # Two colors: stacked bar with background
      bar_col <- standardize_colors(color[1])
      bg_col <- standardize_colors(color[2])

      # Calculate the remaining portion based on xlim
      max_val <- xlim[2]
      comp <- max_val - d
      mat <- rbind(d, comp)

      graphics::barplot(
        mat,
        horiz  = TRUE,
        col    = c(bar_col, bg_col),
        xlim   = xlim,
        space  = 0,
        beside = FALSE,
        axes   = FALSE,
        ...
      )
    } else {
      # Single color: simple bar without background
      graphics::barplot(d, horiz = TRUE, col = color, xlim = xlim)
    }
  }
}

tiny_line <- function(d, xlim = 0:1, color = "black", ...) {
  function() {
    if (
      !inherits(d, "data.frame") || !"x" %in% names(d) || !"y" %in% names(d)
    ) {
      stop(
        "The data to plot a `line` must be a data frame with columns `x` and `y`.",
        call. = FALSE
      )
    }
    plot(d$x, d$y, type = "l", col = color, axes = FALSE, ann = FALSE, lwd = 50)
  }
}

encode <- function(images) {
  assert_dependency("base64enc")
  ext <- tools::file_ext(images)

  if (any(ext == "")) {
    stop("Empty image extensions are not allowed", call. = FALSE)
  }

  encoded <- sapply(images, base64enc::base64encode)
  base::sprintf("data:image/%s;base64, %s", ext, encoded)
}


#' Handle plot_tt for Tabulator tables
#' @keywords internal
#' @noRd
plot_tt_tabulator <- function(
    x,
    i = NULL,
    j = NULL,
    fun = NULL,
    data = NULL,
    color = "black",
    xlim = NULL,
    ...) {

  # Determine plot type from fun
  plot_type <- NULL
  if (is.list(fun) && length(fun) > 0) {
    # Extract plot type from function name
    fun_obj <- fun[[1]]
    if (identical(fun_obj, tiny_histogram)) {
      plot_type <- "histogram"
    } else if (identical(fun_obj, tiny_density)) {
      plot_type <- "density"
    } else if (identical(fun_obj, tiny_bar)) {
      plot_type <- "bar"
    } else if (identical(fun_obj, tiny_line)) {
      plot_type <- "line"
    }
  }

  if (is.null(plot_type)) {
    # For custom functions, we cannot use JavaScript formatters
    # Signal to continue with standard PNG rendering by returning NULL
    # This will cause plot_tt_lazy to skip the tabulator path and use PNG
    return(NULL)
  }

  # Handle the case where i is NA (from sanitize_i when i was NULL)
  i_body <- resolve_i_body(i)

  # Track which custom JS has been marked as needed
  needs_histogram <- FALSE
  needs_sparkline <- FALSE

  # Process each column
  for (col_idx in j) {
    col_name <- x@names[col_idx]

    # Get data for this column
    col_data_idx <- 1
    if (length(j) > 1) {
      col_data_idx <- which(j == col_idx)
    }

    for (row_idx in seq_along(i_body)) {
      data_idx <- (col_data_idx - 1) * length(i_body) + row_idx
      plot_data <- data[[data_idx]]

      # Create formatter configuration
      formatter_info <- tabulator_plot_formatter(
        plot_type = plot_type,
        data = plot_data,
        color = color,
        xlim = xlim
      )

      # Store the formatted data in the cell
      if (plot_type %in% c("line", "density", "histogram")) {
        # For sparkline and histogram, store as JSON array string
        # (non-finite values are not valid JS literals -> null)
        vals <- ifelse(is.finite(formatter_info$data), formatter_info$data, "null")
        json_array <- paste0("[", paste(vals, collapse = ","), "]")
        x@data_body[i_body[row_idx], col_idx] <- json_array
      } else {
        # For progress/bar, store the numeric value
        x@data_body[i_body[row_idx], col_idx] <- formatter_info$data
      }

      # Store the formatter configuration in tabulator_column_formatters (once per column)
      if (is.null(x@tabulator_column_formatters[[col_name]])) {
        x@tabulator_column_formatters[[col_name]] <- formatter_info$config
      }

      # Track which custom JS is needed (once per plot type)
      if (isTRUE(formatter_info$requires_custom_js)) {
        if (plot_type == "histogram") {
          needs_histogram <- TRUE
        } else if (plot_type %in% c("line", "density")) {
          needs_sparkline <- TRUE
        }
      }
    }

    # Hidden rank fields + sorter configuration for this column
    x <- tabulator_add_rank_fields(x, col_name, col_idx, j, i_body, data)
  }

  # Add markers for needed custom JS (once total)
  if (needs_histogram && !grepl("NEEDS_HISTOGRAM_JS", x@tabulator_options, fixed = TRUE)) {
    x@tabulator_options <- paste0(x@tabulator_options, "\n// NEEDS_HISTOGRAM_JS")
  }
  if (needs_sparkline && !grepl("NEEDS_SPARKLINE_JS", x@tabulator_options, fixed = TRUE)) {
    x@tabulator_options <- paste0(x@tabulator_options, "\n// NEEDS_SPARKLINE_JS")
  }

  return(x)
}


#' Wrap inline plot images so `alignv` works in LaTeX
#'
#' In tabularray, `\includegraphics` sits on the row baseline and defines the
#' row height, so `valign=` on the neighboring cells is a no-op (#673). To
#' honor `alignv`, the image itself must be lowered with `\raisebox`. The
#' effective `alignv` for an image cell is the one styled on that cell, or
#' failing that, on any other cell of the same row.
#'
#' Called in `build_tt()` after `@style_other` is resolved but before
#' `build_eval()` draws the body.
#'
#' @keywords internal
#' @noRd
plot_alignv_latex <- function(x) {
  if (!isTRUE(x@output == "latex")) {
    return(x)
  }
  so <- x@style_other
  if (is.null(so) || nrow(so) == 0L || !"alignv" %in% names(so)) {
    return(x)
  }
  so <- so[!is.na(so$alignv) & so$i >= 1, , drop = FALSE]
  if (nrow(so) == 0L) {
    return(x)
  }
  # baseline shift: middle of image (m) or top of image at text ascender (t)
  lift <- c(
    m = "-0.5\\height",
    t = "\\dimexpr-\\height+\\ht\\strutbox\\relax"
  )
  for (col in seq_len(ncol(x@data_body))) {
    cells <- x@data_body[[col]]
    for (row in which(startsWith(cells, "\\includegraphics"))) {
      a <- so$alignv[so$i == row & so$j == col] # cell-level style wins
      if (length(a) == 0L) a <- so$alignv[so$i == row] # else row-level
      if (length(a) == 0L || !a[1] %in% names(lift)) next # b: keep as-is
      cells[row] <- base::sprintf("\\raisebox{%s}{%s}", lift[[a[1]]], cells[row])
    }
    x@data_body[[col]] <- cells
  }
  return(x)
}
