# Module 1: Raw data import and pre-QC ---------------------------------------

# Canonicalise a well-type vector against the configured control labels.
.canon_well_type <- function(x, nc_label, pc_label, blank_label) {
  x <- as.character(x)
  lx <- tolower(trimws(x))
  out <- rep("compound", length(x))
  out[lx %in% tolower(nc_label)] <- "NC"
  out[lx %in% tolower(pc_label)] <- "PC"
  out[lx %in% tolower(blank_label)] <- "Blank"
  out
}

#' Import a plate readout file into a standard HTS object
#'
#' Reads an instrument export (csv, txt/tsv or Excel) or an in-memory data frame
#' of well-level readouts and standardises it into an `hts_raw` object with a
#' fixed set of columns, ready for the rest of the pipeline. Well coordinates are
#' parsed from the well identifier and control wells are canonicalised to the
#' labels `"NC"`, `"PC"` and `"Blank"`.
#'
#' @param file A path to a `.csv`, `.txt`/`.tsv` or `.xlsx`/`.xls` file, or an
#'   existing data frame of well-level readings.
#' @param plate_id Optional plate identifier used when the data has no plate
#'   column.
#' @param well_col,signal_col,compound_col,conc_col,type_col,plate_col Names of
#'   the well, signal, compound, concentration, well-type and plate columns in
#'   `file`. Missing optional columns are filled with `NA`.
#' @param nc_label,pc_label,blank_label Character vectors of the labels that
#'   mark negative controls, positive controls and blanks (matched
#'   case-insensitively in `type_col`, or in `compound_col` when `type_col` is
#'   absent).
#' @param sep Field separator for `.txt`/`.tsv` files. Default tab.
#' @param sheet Worksheet passed to \pkg{readxl} for Excel files. Default `1`.
#'
#' @return An `hts_raw` object (a data frame subclass) with columns `plate_id`,
#'   `well`, `row`, `col`, `well_type`, `compound_id`, `concentration` and
#'   `signal`.
#'
#' @details Reading Excel files requires the suggested package \pkg{readxl}.
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' table(raw$well_type)
#'
#' @seealso [baseline_subtract()], [calc_z_prime()], [norm_by_control()]
#' @export
read_hts_plate <- function(file, plate_id = NULL,
                           well_col = "well", signal_col = "signal",
                           compound_col = "compound_id",
                           conc_col = "concentration",
                           type_col = "well_type", plate_col = "plate_id",
                           nc_label = "NC", pc_label = "PC",
                           blank_label = "Blank", sep = "\t", sheet = 1) {
  if (is.data.frame(file)) {
    df <- as.data.frame(file, stringsAsFactors = FALSE)
  } else if (is.character(file) && length(file) == 1L) {
    ext <- tolower(tools::file_ext(file))
    df <- if (ext %in% c("xls", "xlsx")) {
      if (!requireNamespace("readxl", quietly = TRUE)) {
        stop("Reading Excel files requires the 'readxl' package.",
          call. = FALSE
        )
      }
      as.data.frame(readxl::read_excel(file, sheet = sheet))
    } else if (ext == "csv") {
      utils::read.csv(file, stringsAsFactors = FALSE, check.names = FALSE)
    } else {
      utils::read.table(file, header = TRUE, sep = sep,
        stringsAsFactors = FALSE, check.names = FALSE)
    }
  } else {
    stop("`file` must be a data frame or a single file path.", call. = FALSE)
  }

  if (!well_col %in% names(df)) {
    stop("Well column '", well_col, "' not found.", call. = FALSE)
  }
  if (!signal_col %in% names(df)) {
    stop("Signal column '", signal_col, "' not found.", call. = FALSE)
  }

  wells <- as.character(df[[well_col]])
  rows <- toupper(sub("^([A-Za-z]+).*$", "\\1", wells))
  cols <- as.integer(sub("^[A-Za-z]+0*([0-9]+)$", "\\1", wells))

  pid <- if (plate_col %in% names(df)) {
    as.character(df[[plate_col]])
  } else if (!is.null(plate_id)) {
    rep(as.character(plate_id), nrow(df))
  } else {
    rep("plate1", nrow(df))
  }
  compound <- if (compound_col %in% names(df)) {
    as.character(df[[compound_col]])
  } else {
    NA_character_
  }
  concentration <- if (conc_col %in% names(df)) {
    .as_numeric(df[[conc_col]])
  } else {
    NA_real_
  }
  well_type <- if (type_col %in% names(df)) {
    .canon_well_type(df[[type_col]], nc_label, pc_label, blank_label)
  } else {
    .canon_well_type(compound, nc_label, pc_label, blank_label)
  }

  out <- data.frame(
    plate_id = pid, well = wells, row = rows, col = cols,
    well_type = well_type, compound_id = compound,
    concentration = concentration,
    signal = .as_numeric(df[[signal_col]]),
    stringsAsFactors = FALSE
  )
  .as_hts(out, "hts_raw")
}

#' Read a user-defined plate-map layout file
#'
#' Reads a plate map that **you author yourself** in Excel or CSV to declare, for
#' every well, whether it holds a negative control, a positive control, a blank
#' (empty/edge) well or a test compound. Nothing about the layout is fixed by the
#' package: you are free to place the controls, blanks and compounds in whichever
#' wells your assay uses, and [apply_plate_layout()] then stamps those roles onto
#' the measured readings.
#'
#' The expected file is a **grid** that mirrors the physical plate: the first
#' column holds the row letters (`A`, `B`, ...), the remaining column headers are
#' the plate column numbers (`1`, `2`, ... `24`), and each cell contains the
#' label for that well. Labels are matched case-insensitively against
#' `nc_labels`, `pc_labels` and `blank_labels`; any other non-empty label is
#' treated as a compound identifier. An optional second grid of the same shape
#' supplies per-well concentrations.
#'
#' @param file Path to the plate-map grid (`.csv`, `.txt`/`.tsv` or
#'   `.xlsx`/`.xls`).
#' @param plate_id Optional plate identifier attached to every well of the
#'   layout, so the same map can be matched to a specific plate.
#' @param nc_labels,pc_labels,blank_labels Character vectors of the cell labels
#'   that mark negative controls, positive controls and blank/empty wells. All
#'   other non-empty labels become compound identifiers.
#' @param concentration_file Optional path to a second grid (same layout) giving
#'   the concentration of each well.
#' @param sheet,sep Worksheet (Excel) and field separator (`.txt`/`.tsv`) passed
#'   to the reader.
#'
#' @return An `hts_layout` data frame with one row per well and the columns
#'   `plate_id`, `well`, `row`, `col`, `well_type` (`"NC"`, `"PC"`, `"Blank"` or
#'   `"compound"`), `compound_id` and `concentration`.
#'
#' @examples
#' f <- system.file("extdata", "plate_layout_example.csv", package = "diffHTS")
#' layout <- read_plate_layout(f, plate_id = "P01")
#' table(layout$well_type)
#'
#' @seealso [apply_plate_layout()], [read_hts_plate()], [check_control_label()]
#' @export
read_plate_layout <- function(file, plate_id = NULL,
                              nc_labels = c("NC", "DMSO", "vehicle",
                                "negative_ctrl", "neg_ctrl"),
                              pc_labels = c("PC", "kill_ctrl",
                                "positive_ctrl", "pos_ctrl"),
                              blank_labels = c("Blank", "edgewell", "empty",
                                "blank", "na", ""),
                              concentration_file = NULL, sheet = 1,
                              sep = "\t") {
  grid <- if (is.data.frame(file)) file else .read_hts_file(file, sep, sheet)
  long <- .grid_to_long(grid, "content")

  content <- long$content
  lc <- tolower(trimws(content))
  well_type <- rep("compound", length(content))
  well_type[lc %in% tolower(nc_labels)] <- "NC"
  well_type[lc %in% tolower(pc_labels)] <- "PC"
  is_blank <- lc %in% tolower(blank_labels) | is.na(content) | content == ""
  well_type[is_blank] <- "Blank"

  compound_id <- content
  compound_id[is_blank] <- NA_character_

  layout <- data.frame(
    plate_id = if (!is.null(plate_id)) as.character(plate_id) else NA_character_,
    well = long$well, row = long$row, col = long$col,
    well_type = well_type, compound_id = compound_id,
    concentration = NA_real_, stringsAsFactors = FALSE
  )

  if (!is.null(concentration_file)) {
    cg <- if (is.data.frame(concentration_file)) {
      concentration_file
    } else {
      .read_hts_file(concentration_file, sep, sheet)
    }
    clong <- .grid_to_long(cg, "concentration")
    conc <- .as_numeric(clong$concentration)
    layout$concentration <- conc[match(layout$well, clong$well)]
  }

  .as_hts(layout, "hts_layout")
}

#' Stamp a plate-map layout onto measured readings
#'
#' Joins a user-defined layout from [read_plate_layout()] onto a table of raw
#' signal readings, matching by well address (and plate identifier when both
#' sides carry one), and returns a standard `hts_raw` object. This keeps the
#' *what was measured* (signal) and the *what each well is* (role, compound,
#' concentration) in separate files, exactly as a screening lab records them,
#' and lets the same signal export be re-interpreted against different layouts.
#'
#' @param readings A data frame (or `hts_raw`) of readings with at least a well
#'   column and a signal column.
#' @param layout An `hts_layout` from [read_plate_layout()], or a data frame with
#'   `well`, `well_type`, `compound_id` and (optionally) `concentration`.
#' @param well_col,signal_col Names of the well and signal columns in
#'   `readings`.
#' @param plate_col Name of the plate column. When present in both `readings`
#'   and a multi-plate `layout`, wells are matched within each plate.
#' @param plate_id Optional plate identifier used when `readings` has no plate
#'   column.
#'
#' @return An `hts_raw` object with columns `plate_id`, `well`, `row`, `col`,
#'   `well_type`, `compound_id`, `concentration` and `signal`, ready for the rest
#'   of the pipeline.
#'
#' @examples
#' f <- system.file("extdata", "plate_layout_example.csv", package = "diffHTS")
#' layout <- read_plate_layout(f, plate_id = "P01")
#' # Simulate a signal export for the same wells.
#' readings <- data.frame(well = layout$well,
#'                         signal = runif(nrow(layout), 1e4, 1e5))
#' raw <- apply_plate_layout(readings, layout, plate_id = "P01")
#' table(raw$well_type)
#'
#' @seealso [read_plate_layout()], [read_hts_plate()]
#' @export
apply_plate_layout <- function(readings, layout, well_col = "well",
                               signal_col = "signal", plate_col = "plate_id",
                               plate_id = NULL) {
  rd <- as.data.frame(readings, stringsAsFactors = FALSE)
  lay <- as.data.frame(layout, stringsAsFactors = FALSE)
  if (!well_col %in% names(rd)) {
    stop("Well column '", well_col, "' not found in `readings`.", call. = FALSE)
  }
  if (!signal_col %in% names(rd)) {
    stop("Signal column '", signal_col, "' not found in `readings`.",
      call. = FALSE)
  }
  if (!"well" %in% names(lay)) {
    stop("`layout` must contain a 'well' column.", call. = FALSE)
  }

  rd_well <- as.character(rd[[well_col]])
  rd_plate <- if (plate_col %in% names(rd)) {
    as.character(rd[[plate_col]])
  } else if (!is.null(plate_id)) {
    rep(as.character(plate_id), nrow(rd))
  } else {
    rep("plate1", nrow(rd))
  }

  layout_multi <- plate_col %in% names(lay) &&
    !all(is.na(lay[[plate_col]])) && length(unique(lay[[plate_col]])) > 1L
  key_rd <- if (layout_multi) paste(rd_plate, rd_well, sep = "\r") else rd_well
  key_lay <- if (layout_multi) {
    paste(as.character(lay[[plate_col]]), lay$well, sep = "\r")
  } else {
    lay$well
  }
  idx <- match(key_rd, key_lay)
  if (anyNA(idx)) {
    stop(sum(is.na(idx)), " reading(s) have no matching well in `layout`.",
      call. = FALSE)
  }

  well_type <- if ("well_type" %in% names(lay)) lay$well_type[idx] else "compound"
  compound <- if ("compound_id" %in% names(lay)) {
    as.character(lay$compound_id[idx])
  } else {
    NA_character_
  }
  concentration <- if ("concentration" %in% names(lay)) {
    .as_numeric(lay$concentration[idx])
  } else {
    NA_real_
  }

  rows <- toupper(sub("^([A-Za-z]+).*$", "\\1", rd_well))
  cols <- as.integer(sub("^[A-Za-z]+0*([0-9]+)$", "\\1", rd_well))

  out <- data.frame(
    plate_id = rd_plate, well = rd_well, row = rows, col = cols,
    well_type = well_type, compound_id = compound,
    concentration = concentration, signal = .as_numeric(rd[[signal_col]]),
    stringsAsFactors = FALSE
  )
  .as_hts(out, "hts_raw")
}

.infer_plate_format <- function(max_row, max_col) {
  for (f in c(6L, 12L, 24L, 48L, 96L, 384L, 1536L)) {
    d <- .plate_dims(f)
    if (max_row <= d[1] && max_col <= d[2]) return(f)
  }
  NA_integer_
}

# Collapse a vector of integers into a compact "1, 3-11, 12" style string.
.collapse_int_ranges <- function(x) {
  x <- sort(unique(stats::na.omit(as.integer(x))))
  if (length(x) == 0) return("")
  breaks <- c(0L, which(diff(x) != 1L), length(x))
  parts <- vapply(seq_len(length(breaks) - 1L), function(i) {
    seg <- x[(breaks[i] + 1L):breaks[i + 1L]]
    if (length(seg) == 1L) as.character(seg) else
      paste0(seg[1], "-", seg[length(seg)])
  }, character(1))
  paste(parts, collapse = ", ")
}

#' Summarise how each plate in a run is set up
#'
#' Reports, for every plate in an `hts_raw` object, how the plate is laid out:
#' its physical format, the number of wells of each role (negative/positive
#' controls, blanks and compounds), how many distinct compounds it carries, and
#' which columns the controls and compounds occupy. The positions are **read
#' back from your own layout** (see [read_plate_layout()]) rather than assumed by
#' the package: because a screening run normally spans **several plates at
#' once**, this is the quickest way to confirm that every plate carries the
#' layout you designed before QC and normalisation.
#'
#' @param hts_raw An `hts_raw` object from [read_hts_plate()] (or any well-level
#'   data frame with plate, well-coordinate and well-type columns).
#' @param plate_col,type_col,compound_col,col_col Names of the plate,
#'   well-type, compound and column-index columns. Defaults match
#'   [read_hts_plate()] output.
#' @param nc_label,pc_label,blank_label,compound_label Well-type labels for the
#'   four roles.
#'
#' @return A data frame with one row per plate and the columns `plate_id`,
#'   `n_wells`, `plate_format`, `n_rows`, `n_cols`, `n_nc`, `n_pc`, `n_blank`,
#'   `n_compound`, `n_unique_compound`, `nc_columns`, `pc_columns`,
#'   `blank_columns` and `compound_columns` (control/compound positions given as
#'   compact column ranges such as `"3-11"`).
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' # One row per plate; the run holds three 96-well plates here.
#' summarize_plate_setup(raw)
#'
#' @seealso [read_hts_plate()], [check_control_label()], [calc_z_prime()]
#' @export
summarize_plate_setup <- function(hts_raw, plate_col = "plate_id",
                                  type_col = "well_type",
                                  compound_col = "compound_id",
                                  col_col = "col",
                                  nc_label = "NC", pc_label = "PC",
                                  blank_label = "Blank",
                                  compound_label = "compound") {
  df <- as.data.frame(hts_raw, stringsAsFactors = FALSE)
  for (col in c(plate_col, type_col)) {
    if (!col %in% names(df)) {
      stop("Column '", col, "' not found in `hts_raw`.", call. = FALSE)
    }
  }
  has_col <- col_col %in% names(df)
  plates <- unique(as.character(df[[plate_col]]))
  cols_for <- function(d, role) {
    if (!has_col) return("")
    .collapse_int_ranges(d[[col_col]][d[[type_col]] == role])
  }
  out <- lapply(plates, function(p) {
    d <- df[as.character(df[[plate_col]]) == p, , drop = FALSE]
    max_row <- if ("row" %in% names(d))
      max(match(toupper(d$row), .plate_row_labels(48)), na.rm = TRUE) else NA
    max_col <- if (has_col) max(.as_numeric(d[[col_col]]), na.rm = TRUE) else NA
    fmt <- if (is.finite(max_row) && is.finite(max_col))
      .infer_plate_format(max_row, max_col) else NA_integer_
    cpd <- if (compound_col %in% names(d))
      d[[compound_col]][d[[type_col]] == compound_label] else character(0)
    data.frame(
      plate_id = p,
      n_wells = nrow(d),
      plate_format = fmt,
      n_rows = if (is.finite(max_row)) max_row else NA_integer_,
      n_cols = if (is.finite(max_col)) as.integer(max_col) else NA_integer_,
      n_nc = sum(d[[type_col]] == nc_label),
      n_pc = sum(d[[type_col]] == pc_label),
      n_blank = sum(d[[type_col]] == blank_label),
      n_compound = sum(d[[type_col]] == compound_label),
      n_unique_compound = length(unique(stats::na.omit(cpd))),
      nc_columns = cols_for(d, nc_label),
      pc_columns = cols_for(d, pc_label),
      blank_columns = cols_for(d, blank_label),
      compound_columns = cols_for(d, compound_label),
      stringsAsFactors = FALSE
    )
  })
  res <- do.call(rbind, out)
  rownames(res) <- NULL
  res
}

#' Subtract the blank background signal from a plate
#'
#' Removes the per-plate instrument background by subtracting the mean blank
#' (no-cell) signal from every well, a standard first step for luminescence and
#' fluorescence readouts. The original signal is preserved in `signal_raw`.
#'
#' @param hts_raw An `hts_raw` object from [read_hts_plate()].
#' @param blank_label Well-type label identifying blank wells. Default
#'   `"Blank"`.
#' @param clip_zero Logical; if `TRUE` (default) background-subtracted signals
#'   are floored at zero.
#'
#' @return The input object with `signal` replaced by the background-subtracted
#'   value and a new `signal_raw` column holding the original signal.
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' bs <- baseline_subtract(raw)
#' head(bs[, c("well_type", "signal_raw", "signal")])
#'
#' @seealso [read_hts_plate()], [norm_by_control()]
#' @export
baseline_subtract <- function(hts_raw, blank_label = "Blank",
                              clip_zero = TRUE) {
  if (!"signal" %in% names(hts_raw)) {
    stop("`hts_raw` has no 'signal' column.", call. = FALSE)
  }
  df <- hts_raw
  df$signal_raw <- df$signal
  plates <- split(seq_len(nrow(df)), df$plate_id)
  for (idx in plates) {
    blanks <- df$signal_raw[idx][df$well_type[idx] == blank_label]
    bg <- if (length(blanks) > 0) mean(blanks, na.rm = TRUE) else 0
    df$signal[idx] <- df$signal_raw[idx] - bg
  }
  if (clip_zero) df$signal[df$signal < 0] <- 0
  .as_hts(df, "hts_raw")
}

#' Flag outlier wells by the IQR rule
#'
#' Detects anomalous wells (for example contaminated, bubble or crystallisation
#' artefacts) using the interquartile-range rule within groups of comparable
#' wells, and optionally removes them.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column to test. Default `"signal"`.
#' @param group_cols Columns defining comparable well groups within which the
#'   IQR is computed. Default `c("plate_id", "well_type")`.
#' @param k IQR multiplier for the fences. Default `1.5`.
#' @param filter Logical; if `TRUE` outlier wells are dropped instead of only
#'   flagged. Default `FALSE`.
#'
#' @return The input object with a logical `outlier` column added (or with
#'   outlier rows removed when `filter = TRUE`).
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' flagged <- detect_outlier_wells(raw)
#' sum(flagged$outlier)
#'
#' @seealso [calc_z_prime()], [filter_valid_plates()]
#' @export
detect_outlier_wells <- function(hts_raw, signal_col = "signal",
                                 group_cols = c("plate_id", "well_type"),
                                 k = 1.5, filter = FALSE) {
  if (!signal_col %in% names(hts_raw)) {
    stop("Column '", signal_col, "' not found in `hts_raw`.", call. = FALSE)
  }
  df <- hts_raw
  key <- interaction(df[group_cols], drop = TRUE)
  df$outlier <- FALSE
  for (g in levels(key)) {
    idx <- which(key == g)
    v <- .as_numeric(df[[signal_col]][idx])
    qs <- stats::quantile(v, c(0.25, 0.75), na.rm = TRUE)
    iqr <- qs[2] - qs[1]
    lo <- qs[1] - k * iqr
    hi <- qs[2] + k * iqr
    df$outlier[idx] <- is.finite(v) & (v < lo | v > hi)
  }
  if (filter) df <- df[!df$outlier, , drop = FALSE]
  .as_hts(df, "hts_raw")
}

#' Compute per-plate Z' factors
#'
#' Calculates the Z' (Z-prime) factor for each plate from its negative- and
#' positive-control populations and flags plates against an acceptance
#' threshold.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param z_prime_min Acceptance threshold. Default `0.5`.
#'
#' @return A data frame with one row per plate: `plate_id`, `mean_nc`,
#'   `mean_pc`, `sd_nc`, `sd_pc`, `z_prime` and a logical `pass`.
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' calc_z_prime(raw)
#'
#' @seealso [filter_valid_plates()], [plot_plate_qc()]
#' @export
calc_z_prime <- function(hts_raw, signal_col = "signal",
                         nc_label = "NC", pc_label = "PC",
                         z_prime_min = 0.5) {
  if (!signal_col %in% names(hts_raw)) {
    stop("Column '", signal_col, "' not found in `hts_raw`.", call. = FALSE)
  }
  plates <- unique(as.character(hts_raw$plate_id))
  out <- lapply(plates, function(p) {
    d <- hts_raw[hts_raw$plate_id == p, , drop = FALSE]
    nc <- .as_numeric(d[[signal_col]][d$well_type == nc_label])
    pc <- .as_numeric(d[[signal_col]][d$well_type == pc_label])
    m_nc <- mean(nc, na.rm = TRUE)
    m_pc <- mean(pc, na.rm = TRUE)
    s_nc <- stats::sd(nc, na.rm = TRUE)
    s_pc <- stats::sd(pc, na.rm = TRUE)
    data.frame(
      plate_id = p, mean_nc = m_nc, mean_pc = m_pc,
      sd_nc = s_nc, sd_pc = s_pc,
      z_prime = 1 - 3 * (s_nc + s_pc) / abs(m_nc - m_pc),
      stringsAsFactors = FALSE
    )
  })
  res <- do.call(rbind, out)
  res$pass <- res$z_prime >= z_prime_min
  rownames(res) <- NULL
  res
}

# Per-group location/scale summary used by the QC metrics.
.qc_group_stats <- function(x) {
  x <- .as_numeric(x)
  list(
    n = sum(is.finite(x)),
    mean = mean(x, na.rm = TRUE), sd = stats::sd(x, na.rm = TRUE),
    median = stats::median(x, na.rm = TRUE), mad = stats::mad(x, na.rm = TRUE)
  )
}

# Per-plate location/scale of the NC, PC and sample populations. Shared by every
# single-metric QC helper and by calc_plate_qc() so all formulas agree.
.plate_control_stats <- function(hts_raw, signal_col = "signal",
                                 nc_label = "NC", pc_label = "PC",
                                 sample_label = "compound") {
  if (!signal_col %in% names(hts_raw)) {
    stop("Column '", signal_col, "' not found in `hts_raw`.", call. = FALSE)
  }
  plates <- unique(as.character(hts_raw$plate_id))
  do.call(rbind, lapply(plates, function(p) {
    d <- hts_raw[as.character(hts_raw$plate_id) == p, , drop = FALSE]
    nc <- .qc_group_stats(d[[signal_col]][d$well_type == nc_label])
    pc <- .qc_group_stats(d[[signal_col]][d$well_type == pc_label])
    sm <- .qc_group_stats(d[[signal_col]][d$well_type == sample_label])
    data.frame(
      plate_id = p,
      mean_nc = nc$mean, mean_pc = pc$mean, sd_nc = nc$sd, sd_pc = pc$sd,
      median_nc = nc$median, median_pc = pc$median,
      mad_nc = nc$mad, mad_pc = pc$mad,
      mean_sample = sm$mean, sd_sample = sm$sd,
      stringsAsFactors = FALSE
    )
  }))
}

#' Robust Z'-factor per plate
#'
#' Median/MAD version of the Z'-factor. **What it is for:** it answers the same
#' question as [calc_z_prime()] — how cleanly the positive and negative controls
#' separate — but because it uses the median and the median absolute deviation
#' instead of the mean and SD, a couple of splashed or crystallised control
#' wells cannot single-handedly sink an otherwise good plate. Prefer it when
#' control replicates occasionally contain outliers.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param z_prime_min Acceptance threshold (`>= 0.5` is the usual cut-off for an
#'   excellent assay). Default `0.5`.
#'
#' @return A data frame with one row per plate: `plate_id`, `median_nc`,
#'   `median_pc`, `mad_nc`, `mad_pc`, `robust_z_prime` and a logical `pass`.
#'
#' @examples
#' calc_robust_z_prime(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_z_prime()], [calc_plate_qc()], [plot_plate_qc()]
#' @export
calc_robust_z_prime <- function(hts_raw, signal_col = "signal",
                                nc_label = "NC", pc_label = "PC",
                                z_prime_min = 0.5) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label)
  res <- data.frame(
    plate_id = s$plate_id,
    median_nc = s$median_nc, median_pc = s$median_pc,
    mad_nc = s$mad_nc, mad_pc = s$mad_pc,
    robust_z_prime = 1 - 3 * (s$mad_nc + s$mad_pc) /
      abs(s$median_nc - s$median_pc),
    stringsAsFactors = FALSE
  )
  res$pass <- res$robust_z_prime >= z_prime_min
  rownames(res) <- NULL
  res
}

#' Z-factor per plate (sample-aware)
#'
#' The screening-window quality factor computed from the **test-compound**
#' population against the negative control. **What it is for:** unlike the
#' Z'-factor (controls only), the Z-factor tells you whether the assay window is
#' large enough relative to the spread of the actual library wells. Values above
#' `0.5` indicate an excellent screen and `0`--`0.5` a marginal one; it is
#' routinely low or negative in a single-point primary screen, because most
#' library compounds are inactive and therefore sit at the negative-control
#' level — that is expected, not a failure.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,sample_label Well-type labels for the negative control and
#'   the test wells. Defaults `"NC"` and `"compound"`.
#' @param z_factor_min Acceptance threshold. Default `0.5`.
#'
#' @return A data frame with one row per plate: `plate_id`, `mean_sample`,
#'   `mean_nc`, `sd_sample`, `sd_nc`, `z_factor` and a logical `pass`.
#'
#' @examples
#' calc_z_factor(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_z_prime()], [calc_plate_qc()]
#' @export
calc_z_factor <- function(hts_raw, signal_col = "signal", nc_label = "NC",
                          sample_label = "compound", z_factor_min = 0.5) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label,
    sample_label = sample_label)
  res <- data.frame(
    plate_id = s$plate_id,
    mean_sample = s$mean_sample, mean_nc = s$mean_nc,
    sd_sample = s$sd_sample, sd_nc = s$sd_nc,
    z_factor = 1 - 3 * (s$sd_sample + s$sd_nc) /
      abs(s$mean_sample - s$mean_nc),
    stringsAsFactors = FALSE
  )
  res$pass <- res$z_factor >= z_factor_min
  rownames(res) <- NULL
  res
}

#' SSMD per plate (strictly standardised mean difference)
#'
#' The mean control difference divided by the pooled control spread. **What it
#' is for:** SSMD is the effect-size measure of control separation, and it is
#' the statistically principled companion to the Z'-factor for setting hit
#' cut-offs. Its magnitude grades the assay — roughly `|SSMD|` `>= 2` is
#' excellent, `1`--`2` good and `< 1` weak — independent of the number of
#' control replicates.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param ssmd_min Acceptance threshold applied to `abs(ssmd)`. Default `2`.
#'
#' @return A data frame with one row per plate: `plate_id`, `mean_nc`,
#'   `mean_pc`, `sd_nc`, `sd_pc`, `ssmd` and a logical `pass`. The sign of
#'   `ssmd` follows `mean_nc - mean_pc`.
#'
#' @examples
#' calc_ssmd(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_z_prime()], [calc_plate_qc()]
#' @export
calc_ssmd <- function(hts_raw, signal_col = "signal", nc_label = "NC",
                      pc_label = "PC", ssmd_min = 2) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label)
  res <- data.frame(
    plate_id = s$plate_id,
    mean_nc = s$mean_nc, mean_pc = s$mean_pc, sd_nc = s$sd_nc, sd_pc = s$sd_pc,
    ssmd = (s$mean_nc - s$mean_pc) / sqrt(s$sd_nc^2 + s$sd_pc^2),
    stringsAsFactors = FALSE
  )
  res$pass <- abs(res$ssmd) >= ssmd_min
  rownames(res) <- NULL
  res
}

#' Signal-to-background ratio per plate
#'
#' The ratio of the high-signal control mean to the low-signal control mean.
#' **What it is for:** S/B is the quickest sanity check that the assay actually
#' produced a usable dynamic range — that the untreated (high) and killed (low)
#' controls are far enough apart on the raw scale. It ignores variability, so it
#' complements but does not replace the Z'-factor; a common minimum is `>= 2`.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param sb_min Acceptance threshold. Default `2`.
#'
#' @return A data frame with one row per plate: `plate_id`, `mean_nc`,
#'   `mean_pc`, `sb` (high-mean / low-mean) and a logical `pass`.
#'
#' @examples
#' calc_sb_ratio(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_sn_ratio()], [calc_plate_qc()]
#' @export
calc_sb_ratio <- function(hts_raw, signal_col = "signal", nc_label = "NC",
                          pc_label = "PC", sb_min = 2) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label)
  hi <- pmax(s$mean_nc, s$mean_pc)
  lo <- pmin(s$mean_nc, s$mean_pc)
  res <- data.frame(
    plate_id = s$plate_id, mean_nc = s$mean_nc, mean_pc = s$mean_pc,
    sb = hi / lo, stringsAsFactors = FALSE
  )
  res$pass <- res$sb >= sb_min
  rownames(res) <- NULL
  res
}

#' Signal-to-noise ratio per plate
#'
#' The assay window divided by the noise of the low-signal control. **What it is
#' for:** S/N asks whether the separation between the two controls is large
#' compared with the background scatter, i.e. whether a real change would rise
#' above the noise floor. It is more informative than S/B because it accounts
#' for variability; higher is better, with `>= 3` a reasonable floor.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param sn_min Acceptance threshold. Default `3`.
#'
#' @return A data frame with one row per plate: `plate_id`, `mean_nc`,
#'   `mean_pc`, `sn` (window / low-control SD) and a logical `pass`.
#'
#' @examples
#' calc_sn_ratio(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_sb_ratio()], [calc_plate_qc()]
#' @export
calc_sn_ratio <- function(hts_raw, signal_col = "signal", nc_label = "NC",
                          pc_label = "PC", sn_min = 3) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label)
  hi_is_nc <- s$mean_nc >= s$mean_pc
  hi_mean <- ifelse(hi_is_nc, s$mean_nc, s$mean_pc)
  lo_mean <- ifelse(hi_is_nc, s$mean_pc, s$mean_nc)
  lo_sd <- ifelse(hi_is_nc, s$sd_pc, s$sd_nc)
  res <- data.frame(
    plate_id = s$plate_id, mean_nc = s$mean_nc, mean_pc = s$mean_pc,
    sn = ifelse(is.finite(lo_sd) & lo_sd > 0, (hi_mean - lo_mean) / lo_sd,
      NA_real_),
    stringsAsFactors = FALSE
  )
  res$pass <- res$sn >= sn_min
  rownames(res) <- NULL
  res
}

#' Control coefficient of variation per plate
#'
#' The percent CV (`100 * SD / mean`) of each control population. **What it is
#' for:** CV% is the direct measure of pipetting and reagent-handling
#' reproducibility within a plate. Low, stable control CVs are a precondition
#' for every other metric; a control drifting above ~15--20% flags a liquid-
#' handling or edge-effect problem even when the Z'-factor still passes.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param cv_max Acceptance threshold in percent, applied to both controls.
#'   Default `20`.
#'
#' @return A data frame with one row per plate: `plate_id`, `cv_nc`, `cv_pc`
#'   (percent) and a logical `pass` (both controls at or below `cv_max`).
#'
#' @examples
#' calc_cv(read_hts_plate(hts_primary_raw))
#'
#' @seealso [calc_plate_qc()], [plot_plate_qc()]
#' @export
calc_cv <- function(hts_raw, signal_col = "signal", nc_label = "NC",
                    pc_label = "PC", cv_max = 20) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label)
  res <- data.frame(
    plate_id = s$plate_id,
    cv_nc = 100 * s$sd_nc / s$mean_nc,
    cv_pc = 100 * s$sd_pc / s$mean_pc,
    stringsAsFactors = FALSE
  )
  res$pass <- res$cv_nc <= cv_max & res$cv_pc <= cv_max
  rownames(res) <- NULL
  res
}

#' Comprehensive per-plate quality assessment
#'
#' Computes the full panel of assay-quality metrics used to judge a
#' high-throughput screening plate, in a single per-plate table. Alongside the
#' classic Z'-factor this reports the median/MAD-based **robust Z'-factor**, the
#' sample-aware **Z-factor**, **SSMD**, the **signal-to-background (S/B)** and
#' **signal-to-noise (S/N)** ratios, and the **coefficient of variation (CV%)**
#' of each control population. All of these can then be visualised through the
#' single entry point [plot_plate_qc()].
#'
#' Each metric also has its own dedicated function, useful when you only need one
#' number (and each with its own acceptance threshold): [calc_z_prime()],
#' [calc_robust_z_prime()], [calc_z_factor()], [calc_ssmd()], [calc_sb_ratio()],
#' [calc_sn_ratio()] and [calc_cv()]. `calc_plate_qc()` uses the very same
#' formulas so the numbers agree.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param nc_label,pc_label Well-type labels for the negative and positive
#'   controls. Defaults `"NC"` and `"PC"`.
#' @param sample_label Well-type label for the test (compound) wells, used by
#'   the sample-aware Z-factor. Default `"compound"`.
#' @param z_prime_min Acceptance threshold applied to the Z'-factor to set the
#'   `pass` flag. Default `0.5`.
#'
#' @return A data frame with one row per plate and the columns `plate_id`,
#'   `mean_nc`, `mean_pc`, `sd_nc`, `sd_pc`, `cv_nc`, `cv_pc`, `signal_window`,
#'   `sb` (signal-to-background), `sn` (signal-to-noise), `z_prime`,
#'   `robust_z_prime`, `z_factor`, `ssmd` and a logical `pass`.
#'
#' @details What each metric tells you, with the high-signal control taken as
#'   whichever of NC/PC has the larger mean and the low-signal control as the
#'   other:
#'   \itemize{
#'     \item **CV%** \eqn{= 100\,\sigma/\mu} of each control — pipetting/reagent
#'       reproducibility; lower is better.
#'     \item **S/B** \eqn{= \mu_{hi}/\mu_{lo}} — raw dynamic range of the assay.
#'     \item **S/N** \eqn{= (\mu_{hi}-\mu_{lo})/\sigma_{lo}} — assay window
#'       relative to background noise.
#'     \item **Z'-factor** \eqn{= 1 - 3(\sigma_{nc}+\sigma_{pc})/|\mu_{nc}-\mu_{pc}|}
#'       — overall control-only assay quality (`>= 0.5` excellent).
#'     \item **Robust Z'-factor**: as Z'-factor but with medians and MADs,
#'       resistant to outlier control wells.
#'     \item **Z-factor** \eqn{= 1 - 3(\sigma_{s}+\sigma_{nc})/|\mu_{s}-\mu_{nc}|}
#'       using the sample population \eqn{s} — is the window large versus the
#'       library spread (often low/negative in primary screens).
#'     \item **SSMD** \eqn{= (\mu_{nc}-\mu_{pc})/\sqrt{\sigma_{nc}^2+\sigma_{pc}^2}}
#'       — effect-size measure of control separation for hit cut-offs.
#'   }
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' calc_plate_qc(raw)
#'
#' @seealso [plot_plate_qc()], [calc_z_prime()], [calc_well_zscore()]
#' @export
calc_plate_qc <- function(hts_raw, signal_col = "signal",
                          nc_label = "NC", pc_label = "PC",
                          sample_label = "compound", z_prime_min = 0.5) {
  s <- .plate_control_stats(hts_raw, signal_col, nc_label, pc_label,
    sample_label)
  window <- abs(s$mean_nc - s$mean_pc)
  hi_is_nc <- s$mean_nc >= s$mean_pc
  hi_mean <- ifelse(hi_is_nc, s$mean_nc, s$mean_pc)
  lo_mean <- ifelse(hi_is_nc, s$mean_pc, s$mean_nc)
  lo_sd <- ifelse(hi_is_nc, s$sd_pc, s$sd_nc)
  res <- data.frame(
    plate_id = s$plate_id,
    mean_nc = s$mean_nc, mean_pc = s$mean_pc,
    sd_nc = s$sd_nc, sd_pc = s$sd_pc,
    cv_nc = 100 * s$sd_nc / s$mean_nc,
    cv_pc = 100 * s$sd_pc / s$mean_pc,
    signal_window = window,
    sb = hi_mean / lo_mean,
    sn = ifelse(is.finite(lo_sd) & lo_sd > 0, (hi_mean - lo_mean) / lo_sd,
      NA_real_),
    z_prime = 1 - 3 * (s$sd_nc + s$sd_pc) / window,
    robust_z_prime = 1 - 3 * (s$mad_nc + s$mad_pc) /
      abs(s$median_nc - s$median_pc),
    z_factor = 1 - 3 * (s$sd_sample + s$sd_nc) / abs(s$mean_sample - s$mean_nc),
    ssmd = (s$mean_nc - s$mean_pc) / sqrt(s$sd_nc^2 + s$sd_pc^2),
    stringsAsFactors = FALSE
  )
  res$pass <- res$z_prime >= z_prime_min
  rownames(res) <- NULL
  res
}

#' Per-well standard and robust Z-scores
#'
#' Standardises every well's signal within its plate, adding both a classic
#' **Z-score** (mean/SD based) and a **robust Z-score** (median/MAD based, which
#' is the preferred choice in HTS because it is not distorted by the very hits
#' the screen is looking for). Scores are computed against a per-plate reference
#' population, by default the test-compound wells.
#'
#' @param hts_raw An `hts_raw` object.
#' @param signal_col Name of the signal column. Default `"signal"`.
#' @param reference One of `"compound"`, `"negative"` or `"all"`, selecting the
#'   per-plate population that defines the centre and spread. Default
#'   `"compound"`.
#' @param nc_label,sample_label Well-type labels for the negative controls and
#'   test wells, used to pick the reference population.
#'
#' @return The input object with two added columns: `zscore` (mean/SD based) and
#'   `robust_zscore` (median/MAD based, MAD scaled to the normal distribution).
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' scored <- calc_well_zscore(raw)
#' head(scored[, c("plate_id", "well", "zscore", "robust_zscore")])
#'
#' @seealso [calc_plate_qc()], [plot_plate_qc()]
#' @export
calc_well_zscore <- function(hts_raw, signal_col = "signal",
                             reference = c("compound", "negative", "all"),
                             nc_label = "NC", sample_label = "compound") {
  reference <- match.arg(reference)
  if (!signal_col %in% names(hts_raw)) {
    stop("Column '", signal_col, "' not found in `hts_raw`.", call. = FALSE)
  }
  df <- as.data.frame(hts_raw, stringsAsFactors = FALSE)
  df$zscore <- NA_real_
  df$robust_zscore <- NA_real_
  for (p in unique(as.character(df$plate_id))) {
    idx <- which(as.character(df$plate_id) == p)
    v <- .as_numeric(df[[signal_col]][idx])
    ref <- switch(reference,
      compound = v[df$well_type[idx] == sample_label],
      negative = v[df$well_type[idx] == nc_label],
      all = v
    )
    mu <- mean(ref, na.rm = TRUE)
    sdv <- stats::sd(ref, na.rm = TRUE)
    med <- stats::median(ref, na.rm = TRUE)
    madv <- stats::mad(ref, na.rm = TRUE)
    df$zscore[idx] <- if (is.finite(sdv) && sdv > 0) (v - mu) / sdv else NA_real_
    df$robust_zscore[idx] <- if (is.finite(madv) && madv > 0) {
      (v - med) / madv
    } else {
      NA_real_
    }
  }
  .as_hts(df, "hts_raw")
}

#' Keep only plates that pass the Z' threshold
#'
#' Removes whole plates whose Z' factor falls below the acceptance threshold,
#' returning a clean data set for downstream normalisation.
#'
#' @param hts_raw An `hts_raw` object.
#' @param z_prime_min Acceptance threshold. Default `0.5`.
#' @param signal_col,nc_label,pc_label Passed to [calc_z_prime()].
#'
#' @return The `hts_raw` object restricted to passing plates, with the Z'
#'   summary table stored in `attr(x, "zprime")` and the dropped plate ids in
#'   `attr(x, "dropped_plates")`.
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' clean <- filter_valid_plates(raw)
#' attr(clean, "dropped_plates")
#'
#' @seealso [calc_z_prime()]
#' @export
filter_valid_plates <- function(hts_raw, z_prime_min = 0.5,
                                signal_col = "signal",
                                nc_label = "NC", pc_label = "PC") {
  zp <- calc_z_prime(hts_raw, signal_col = signal_col, nc_label = nc_label,
    pc_label = pc_label, z_prime_min = z_prime_min)
  keep <- zp$plate_id[zp$pass]
  dropped <- zp$plate_id[!zp$pass]
  out <- hts_raw[as.character(hts_raw$plate_id) %in% keep, , drop = FALSE]
  out <- .as_hts(out, "hts_raw")
  attr(out, "zprime") <- zp
  attr(out, "dropped_plates") <- dropped
  out
}

#' Raw-signal plate heatmap
#'
#' Draws a single plate's raw (or background-subtracted) readouts in plate
#' layout, optionally marking flagged outlier wells. A thin wrapper around
#' [plot_plate_heatmap()] for `hts_raw` objects.
#'
#' @param hts_raw An `hts_raw` object.
#' @param plate Plate identifier to show. Defaults to the first plate.
#' @param signal_col Name of the signal column to colour by. Default
#'   `"signal"`.
#' @param mark_outliers Logical; if `TRUE` and an `outlier` column is present,
#'   flagged wells are labelled with `"X"`. Default `TRUE`.
#'
#' @return A [ggplot2::ggplot] object.
#'
#' @examples
#' raw <- detect_outlier_wells(read_hts_plate(hts_primary_raw))
#' plot_plate_heatmap_raw(raw, plate = "P01")
#'
#' @seealso [plot_plate_heatmap()], [plot_plate_qc()]
#' @export
plot_plate_heatmap_raw <- function(hts_raw, plate = NULL, signal_col = "signal",
                                   mark_outliers = TRUE) {
  if (is.null(plate)) plate <- as.character(hts_raw$plate_id)[1]
  df <- hts_raw
  lab <- NULL
  if (mark_outliers && "outlier" %in% names(df)) {
    df$.outlier_mark <- ifelse(df$outlier, "X", "")
    lab <- ".outlier_mark"
  }
  plot_plate_heatmap(df, fill = signal_col, well_col = "well",
    plate = plate, plate_col = "plate_id", label = lab,
    control_col = if ("well_type" %in% names(df)) "well_type" else NULL,
    title = paste0("Raw signal: ", plate))
}

# Label / acceptance-threshold / direction metadata for each plate QC metric.
.qc_metric_meta <- function(metric) {
  list(
    z_prime = list(label = "Z'-factor", threshold = 0.5, direction = "higher"),
    robust_z_prime = list(label = "Robust Z'-factor", threshold = 0.5,
      direction = "higher"),
    z_factor = list(label = "Z-factor", threshold = 0.5, direction = "higher"),
    ssmd = list(label = "SSMD", threshold = 2, direction = "abs"),
    sb = list(label = "Signal/Background (S/B)", threshold = 2,
      direction = "higher"),
    sn = list(label = "Signal/Noise (S/N)", threshold = 3, direction = "higher"),
    cv_nc = list(label = "CV% (NC)", threshold = 20, direction = "lower"),
    cv_pc = list(label = "CV% (PC)", threshold = 20, direction = "lower")
  )[[metric]]
}

#' Visualise any plate quality-control metric
#'
#' A single entry point for plotting the plate-quality metrics produced by
#' [calc_plate_qc()] (and the per-well scores from [calc_well_zscore()]). Choose
#' which assessment to draw through the `metric` argument; the acceptance
#' threshold, axis label and pass/fail colouring are set automatically for each
#' metric but can be overridden.
#'
#' @param qc A data frame from [calc_plate_qc()] for the plate-level metrics, or
#'   from [calc_well_zscore()] for the per-well `zscore`/`robust_zscore`
#'   distributions.
#' @param metric The metric to plot: one of `"z_prime"`, `"robust_z_prime"`,
#'   `"z_factor"`, `"ssmd"`, `"sb"`, `"sn"`, `"cv_nc"`, `"cv_pc"`, `"cv"` (both
#'   control CVs side by side), or `"zscore"`/`"robust_zscore"` for per-well
#'   distributions. Default `"z_prime"`.
#' @param threshold Optional acceptance threshold drawn as a reference line;
#'   defaults to the standard cut-off for the chosen metric.
#' @param plate_col Name of the plate-identifier column. Default `"plate_id"`.
#'
#' @return A [ggplot2::ggplot] object.
#'
#' @examples
#' raw <- read_hts_plate(hts_primary_raw)
#' qc <- calc_plate_qc(raw)
#' plot_plate_qc(qc, metric = "z_prime")
#' plot_plate_qc(qc, metric = "ssmd")
#' plot_plate_qc(qc, metric = "cv")
#' plot_plate_qc(calc_well_zscore(raw), metric = "robust_zscore")
#'
#' @seealso [calc_plate_qc()], [calc_well_zscore()], [plot_plate_heatmap_raw()]
#' @export
plot_plate_qc <- function(qc, metric = "z_prime", threshold = NULL,
                          plate_col = "plate_id") {
  df <- as.data.frame(qc, stringsAsFactors = FALSE)
  if (!plate_col %in% names(df)) {
    stop("Column '", plate_col, "' not found in `qc`.", call. = FALSE)
  }
  df$.plate <- as.character(df[[plate_col]])

  # Per-well distribution metrics.
  if (metric %in% c("zscore", "robust_zscore")) {
    if (!metric %in% names(df)) {
      stop("Column '", metric, "' not found; run calc_well_zscore() first.",
        call. = FALSE)
    }
    lab <- if (metric == "zscore") "Z-score" else "Robust Z-score"
    df$.value <- .as_numeric(df[[metric]])
    p <- ggplot2::ggplot(df, ggplot2::aes(x = .data$.plate,
      y = .data$.value)) +
      ggplot2::geom_hline(yintercept = 0, color = "#888888", linewidth = 0.4) +
      ggplot2::geom_boxplot(width = 0.55, fill = "#EAECEF", color = "#4C4C4C",
        linewidth = 0.5, outlier.color = "#4C4C4C", outlier.size = 0.9,
        outlier.alpha = 0.7) +
      ggplot2::labs(x = NULL, y = lab, title = paste0("Per-well ", lab)) +
      theme_hts(legend = "none")
    if (!is.null(threshold)) {
      p <- p + ggplot2::geom_hline(yintercept = c(-1, 1) * abs(threshold),
        linetype = "dashed", color = .hts_col("reference"), linewidth = 0.6)
    }
    return(p)
  }

  # Both control CVs side by side.
  if (metric == "cv") {
    if (!all(c("cv_nc", "cv_pc") %in% names(df))) {
      stop("`qc` must contain 'cv_nc' and 'cv_pc'; run calc_plate_qc() first.",
        call. = FALSE)
    }
    thr <- if (!is.null(threshold)) threshold else 20
    long <- data.frame(
      plate = rep(df$.plate, 2L),
      control = factor(rep(c("NC", "PC"), each = nrow(df)),
        levels = c("NC", "PC")),
      value = c(.as_numeric(df$cv_nc), .as_numeric(df$cv_pc)),
      stringsAsFactors = FALSE
    )
    long$label <- .fmt_qc(long$value)
    return(
      ggplot2::ggplot(long, ggplot2::aes(x = .data$plate, y = .data$value,
        fill = .data$control)) +
        ggplot2::geom_col(position = ggplot2::position_dodge(0.72),
          width = 0.68, color = "white", linewidth = 0.3) +
        ggplot2::geom_text(ggplot2::aes(label = .data$label),
          position = ggplot2::position_dodge(0.72), vjust = -0.4, size = 3,
          color = "#333333") +
        ggplot2::geom_hline(yintercept = thr, linetype = "dashed",
          color = .hts_col("reference"), linewidth = 0.6) +
        ggplot2::annotate("text", x = Inf, y = thr,
          label = paste0("\u2264 ", thr, "%"), hjust = 1.05, vjust = -0.4,
          size = 3, color = .hts_col("reference")) +
        ggplot2::scale_fill_manual(
          values = c(NC = .hts_col("NC"), PC = .hts_col("PC"))
        ) +
        ggplot2::scale_y_continuous(
          expand = ggplot2::expansion(mult = c(0.02, 0.12))
        ) +
        ggplot2::labs(x = NULL, y = "CV (%)", fill = "Control",
          title = "Plate QC: control CV%") +
        theme_hts(legend = "bottom")
    )
  }

  meta <- .qc_metric_meta(metric)
  if (is.null(meta)) {
    stop("Unknown metric '", metric, "'. See ?plot_plate_qc for valid values.",
      call. = FALSE)
  }
  if (!metric %in% names(df)) {
    stop("Column '", metric, "' not found; run calc_plate_qc() first.",
      call. = FALSE)
  }
  thr <- if (!is.null(threshold)) threshold else meta$threshold
  df$.value <- .as_numeric(df[[metric]])
  df$.pass <- factor(
    switch(meta$direction,
      higher = df$.value >= thr,
      lower = df$.value <= thr,
      abs = abs(df$.value) >= thr
    ),
    levels = c("TRUE", "FALSE")
  )
  df$.label <- .fmt_qc(df$.value)
  df$.vjust <- ifelse(df$.value >= 0, -0.45, 1.3)
  cut_label <- switch(meta$direction,
    higher = paste0("\u2265 ", thr),
    lower = paste0("\u2264 ", thr),
    abs = paste0("|", metric, "| \u2265 ", thr)
  )
  p <- ggplot2::ggplot(df, ggplot2::aes(x = .data$.plate, y = .data$.value,
    fill = .data$.pass)) +
    ggplot2::geom_col(width = 0.62, color = "white", linewidth = 0.3) +
    ggplot2::geom_text(ggplot2::aes(label = .data$.label,
      vjust = .data$.vjust), size = 3.2, color = "#333333") +
    ggplot2::scale_fill_manual(
      values = c(`TRUE` = .hts_col("pass"), `FALSE` = .hts_col("fail")),
      drop = FALSE, name = "QC pass", labels = c(`TRUE` = "Pass",
        `FALSE` = "Fail")
    ) +
    ggplot2::scale_y_continuous(
      expand = ggplot2::expansion(mult = c(0.06, 0.14))
    ) +
    ggplot2::labs(x = NULL, y = meta$label,
      title = paste0("Plate QC: ", meta$label)) +
    theme_hts(legend = "bottom")
  if (meta$direction == "abs") {
    p <- p +
      ggplot2::geom_hline(yintercept = c(-thr, thr), linetype = "dashed",
        color = .hts_col("reference"), linewidth = 0.6) +
      ggplot2::annotate("text", x = Inf, y = thr, label = cut_label,
        hjust = 1.05, vjust = -0.4, size = 3, color = .hts_col("reference"))
  } else {
    p <- p +
      ggplot2::geom_hline(yintercept = thr, linetype = "dashed",
        color = .hts_col("reference"), linewidth = 0.6) +
      ggplot2::annotate("text", x = Inf, y = thr, label = cut_label,
        hjust = 1.05, vjust = -0.4, size = 3, color = .hts_col("reference"))
  }
  p
}
