#' Heatmap of differential AUC across conditions or cell lines
#'
#' Draws a diverging heatmap of differential (delta) AUC values with drugs on
#' one axis and conditions/cell lines on the other, using \pkg{ComplexHeatmap}.
#' Colours are centred at zero so that sensitising (negative) and antagonising
#' (positive) responses are visually separated.
#'
#' @param data A data frame with one row per drug: a drug identifier column and
#'   one numeric delta-AUC column per condition or cell line.
#' @param drug_col Name of the drug identifier column, as a string. Default
#'   `"drug_name"`.
#' @param value_cols Character vector of the delta-AUC column names to display.
#'   Defaults to every numeric column other than `drug_col`.
#' @param cell_types Optional named character vector mapping each column in
#'   `value_cols` to a group label (for example `"Cancer"` or `"Normal"`) used
#'   to annotate the heatmap.
#' @param cluster_rows,cluster_columns Logical, whether to cluster drugs and
#'   conditions respectively. Defaults `TRUE`.
#' @param name Legend title. Default `"delta AUC"`.
#'
#' @return A [ComplexHeatmap::Heatmap-class] object, which is drawn when
#'   printed. Rows with missing values are dropped before plotting.
#'
#' @details Requires the suggested packages \pkg{ComplexHeatmap} and
#'   \pkg{circlize}.
#'
#' @examples
#' if (requireNamespace("ComplexHeatmap", quietly = TRUE) &&
#'     requireNamespace("circlize", quietly = TRUE)) {
#'   ht <- plot_delta_auc_heatmap(
#'     screen_delta_auc,
#'     value_cols = c("A549", "H1299", "H1975", "MRC5")
#'   )
#' }
#'
#' @seealso [plot_condition_scatter()]
#' @export
plot_delta_auc_heatmap <- function(data, drug_col = "drug_name",
                                    value_cols = NULL, cell_types = NULL,
                                    cluster_rows = TRUE, cluster_columns = TRUE,
                                    name = "delta AUC") {
  if (!requireNamespace("ComplexHeatmap", quietly = TRUE) ||
    !requireNamespace("circlize", quietly = TRUE)) {
    stop(
      "Packages 'ComplexHeatmap' and 'circlize' are required for ",
      "`plot_delta_auc_heatmap()`.",
      call. = FALSE
    )
  }
  if (is.null(value_cols)) {
    value_cols <- setdiff(names(data)[vapply(data, is.numeric, logical(1))], drug_col)
  }

  mat <- as.matrix(data[, value_cols, drop = FALSE])
  rownames(mat) <- data[[drug_col]]
  mat <- mat[stats::complete.cases(mat), , drop = FALSE]

  col_fun <- circlize::colorRamp2(
    c(min(mat, na.rm = TRUE), 0, max(mat, na.rm = TRUE)),
    c("navy", "white", "firebrick3")
  )

  top_annotation <- NULL
  if (!is.null(cell_types)) {
    types <- cell_types[colnames(mat)]
    top_annotation <- ComplexHeatmap::HeatmapAnnotation(
      Type = types, which = "column",
      col = list(Type = c("Normal" = "#0072B2", "Cancer" = "#D55E00"))
    )
  }

  ComplexHeatmap::Heatmap(
    mat,
    name = name,
    col = col_fun,
    cluster_rows = cluster_rows,
    cluster_columns = cluster_columns,
    column_names_rot = 45,
    top_annotation = top_annotation,
    border = TRUE
  )
}

#' Scatter plot comparing two conditions or cell lines
#'
#' Plots a per-drug scatter of a score (for example delta AUC) under two
#' conditions or cell lines, colouring points by quadrant and labelling the most
#' extreme drugs in each quadrant. This is useful for spotting drugs that
#' behave differently between, say, a cancer and a normal cell line.
#'
#' @param data A data frame with one row per drug.
#' @param x,y Names of the two numeric score columns to place on the x and y
#'   axes, as strings.
#' @param drug_col Name of the drug identifier column, as a string. Default
#'   `"drug_name"`.
#' @param label_n Number of most-extreme drugs (by distance from the origin) to
#'   label per quadrant. Default `5`.
#'
#' @return A [ggplot2::ggplot] object.
#'
#' @details Point labelling uses \pkg{ggrepel} when available and falls back to
#'   [ggplot2::geom_text()] otherwise. The axes are centred on the origin with a
#'   fixed aspect ratio so the four quadrants are equal in size.
#'
#' @examples
#' df <- data.frame(
#'   drug_name = paste0("Drug", 1:8),
#'   MRC5 = c(-1, 0.5, -0.2, 1.2, -0.8, 0.3, -1.5, 0.9),
#'   A549 = c(-1.2, 0.1, 0.7, -0.9, -1.1, 1.0, -0.4, 0.6)
#' )
#' plot_condition_scatter(df, x = "MRC5", y = "A549")
#'
#' @seealso [plot_delta_auc_heatmap()]
#' @export
plot_condition_scatter <- function(data, x, y, drug_col = "drug_name",
                                    label_n = 5) {
  for (col in c(drug_col, x, y)) {
    if (!col %in% names(data)) {
      stop("Column '", col, "' not found in `data`.", call. = FALSE)
    }
  }

  df <- data.frame(
    drug_name = data[[drug_col]],
    x = data[[x]],
    y = data[[y]]
  )
  df <- df[stats::complete.cases(df), , drop = FALSE]

  df$quadrant <- ifelse(df$x >= 0 & df$y >= 0, "Q1",
    ifelse(df$x < 0 & df$y >= 0, "Q2",
      ifelse(df$x < 0 & df$y < 0, "Q3", "Q4")
    )
  )
  df$distance <- sqrt(df$x^2 + df$y^2)

  labels <- do.call(rbind, lapply(split(df, df$quadrant), function(d) {
    d[order(d$distance, decreasing = TRUE), , drop = FALSE][
      seq_len(min(label_n, nrow(d))), ,
      drop = FALSE
    ]
  }))

  quadrant_colors <- c(
    Q1 = "#1b9e77", Q2 = "#d95f02",
    Q3 = "#7570b3", Q4 = "#e7298a"
  )

  p <- ggplot2::ggplot(
    df,
    ggplot2::aes(x = .data$x, y = .data$y, color = .data$quadrant)
  ) +
    ggplot2::geom_hline(yintercept = 0, linewidth = 0.6, color = "#888888") +
    ggplot2::geom_vline(xintercept = 0, linewidth = 0.6, color = "#888888") +
    ggplot2::geom_point(size = 2.6, alpha = 0.8, stroke = 0.2) +
    ggplot2::scale_color_manual(values = quadrant_colors) +
    ggplot2::labs(x = x, y = y, color = "Quadrant") +
    theme_hts(grid = "both", legend = "right")

  ## Symmetric, equal-sized quadrants: centre the axes on the origin and use a
  ## fixed aspect so all four quadrants cover the same area.
  lim <- max(abs(c(df$x, df$y)), na.rm = TRUE)
  if (!is.finite(lim) || lim == 0) lim <- 1
  lim <- lim * 1.08
  p <- p + ggplot2::coord_fixed(
    xlim = c(-lim, lim), ylim = c(-lim, lim), clip = "off"
  )

  if (requireNamespace("ggrepel", quietly = TRUE)) {
    p <- p + ggrepel::geom_text_repel(
      data = labels,
      ggplot2::aes(label = .data$drug_name),
      size = 3, max.overlaps = 20
    )
  } else {
    p <- p + ggplot2::geom_text(
      data = labels,
      ggplot2::aes(label = .data$drug_name),
      size = 3, vjust = -0.6, show.legend = FALSE
    )
  }
  p
}

#' Quality-control boxplot of control wells across plates
#'
#' Draws grouped boxplots of a per-well readout across plates, split by control
#' type, to inspect assay consistency and separation between negative and
#' positive controls.
#'
#' @param data A long-format data frame of well-level readouts.
#' @param plate_col Name of the plate identifier column, as a string. Default
#'   `"plateID"`.
#' @param value_col Name of the numeric readout column, as a string. Default
#'   `"normalized_cell_count"`.
#' @param group_col Name of the control-type / grouping column, as a string.
#'   Default `"experiment_type"`.
#' @param y_title Axis title for the readout. Default
#'   `"Normalized cell count"`.
#'
#' @return A [ggplot2::ggplot] object.
#'
#' @examples
#' plot_qc_boxplot(screen_doseresponse)
#'
#' @seealso [calculate_qc_metrics()]
#' @export
plot_qc_boxplot <- function(data, plate_col = "plateID",
                            value_col = "normalized_cell_count",
                            group_col = "experiment_type",
                            y_title = "Normalized cell count") {
  for (col in c(plate_col, value_col, group_col)) {
    if (!col %in% names(data)) {
      stop("Column '", col, "' not found in `data`.", call. = FALSE)
    }
  }

  ggplot2::ggplot(
    data,
    ggplot2::aes(
      x = .data[[plate_col]],
      y = .data[[value_col]],
      fill = .data[[group_col]]
    )
  ) +
    ggplot2::geom_boxplot(outlier.shape = NA) +
    ggplot2::geom_point(
      position = ggplot2::position_jitterdodge(), alpha = 0.3, size = 0.6
    ) +
    ggplot2::scale_fill_manual(values = hts_pal()) +
    ggplot2::expand_limits(y = 0) +
    ggplot2::labs(x = NULL, y = y_title, fill = "Control") +
    theme_hts(legend = "bottom") +
    ggplot2::theme(
      axis.text.x = ggplot2::element_text(angle = 45, hjust = 1)
    )
}

#' Plot dose-response curves for two conditions
#'
#' Fits a four-parameter logistic model to each condition of a single drug and
#' overlays the observed responses with the smooth fitted curves, coloured by
#' condition. This is the key visual for comparing drug sensitivity between two
#' conditions (for example irradiated versus non-irradiated cells), making a
#' potency shift between conditions immediately apparent.
#'
#' The styling reproduces the plots used throughout the screening pipeline: a
#' logarithmic concentration axis, distinct colour and point shape per
#' condition, a horizontal 50 % reference line, and the fitted IC50/AUC of each
#' condition reported in the subtitle.
#'
#' @param data A long-format data frame with one row per well/replicate,
#'   containing drug, condition, concentration and response columns.
#' @param drug Optional drug name to display. If `NULL` (default) `data` is
#'   assumed to already contain a single drug.
#' @param response Name of the (normalised) response column, as a string.
#'   Default `"normalized_cell_count"`.
#' @param concentration Name of the concentration column, as a string. Default
#'   `"concentration"`.
#' @param condition Name of the condition column, as a string. Default
#'   `"condition"`.
#' @param drug_col Name of the drug identifier column, as a string. Default
#'   `"drug_name"`.
#' @param n_points Number of points used to draw each smooth fitted curve.
#'   Default `200`.
#' @param colors Character vector of colours, one per condition (recycled from
#'   the pipeline palette by default).
#' @param shapes Numeric vector of point shapes, one per condition.
#' @param ref_line Numeric response level at which to draw a horizontal dashed
#'   reference line (for example the 50 % effect level). Set to `NA` to omit.
#' @param legend_title Legend title. Default `"Condition"`.
#'
#' @return A [ggplot2::ggplot] object with a base-10 logarithmic concentration
#'   axis, observed points and fitted 4PL curves coloured by condition.
#'
#' @details Only concentrations that are finite and strictly positive are used
#'   (a log scale cannot show zero or negative doses). A condition with fewer
#'   than four usable observations is plotted as points only, with a warning,
#'   and no curve is fitted for it. Fitting is performed with
#'   [fit_dose_response()], so no external fitting package is required. The
#'   figure uses the package's modern-academic [theme_hts()] look with a
#'   colourblind-safe condition palette.
#'
#' @examples
#' one <- subset(
#'   screen_doseresponse,
#'   drug_name == "DrugA" & experiment_type == "sample"
#' )
#' plot_dose_response_curves(one, drug = "DrugA")
#'
#' @seealso [fit_dose_response()], [four_pl()], [compute_auc()]
#' @export
plot_dose_response_curves <- function(data, drug = NULL,
                                      response = "normalized_cell_count",
                                      concentration = "concentration",
                                      condition = "condition",
                                      drug_col = "drug_name",
                                      n_points = 200,
                                      colors = c("#0072B2", "#D55E00",
                                                 "#009E73", "#CC79A7"),
                                      shapes = c(19, 15, 17, 18),
                                      ref_line = 0.5,
                                      legend_title = "Condition") {
  for (col in c(response, concentration, condition)) {
    if (!col %in% names(data)) {
      stop("Column '", col, "' not found in `data`.", call. = FALSE)
    }
  }

  if (!is.null(drug)) {
    if (!drug_col %in% names(data)) {
      stop("Column '", drug_col, "' not found in `data`.", call. = FALSE)
    }
    data <- data[data[[drug_col]] == drug, , drop = FALSE]
    if (nrow(data) == 0) {
      stop("No rows found for drug '", drug, "'.", call. = FALSE)
    }
  }

  obs <- data.frame(
    concentration = .as_numeric(data[[concentration]]),
    response = .as_numeric(data[[response]]),
    condition = as.character(data[[condition]]),
    stringsAsFactors = FALSE
  )
  obs <- obs[is.finite(obs$concentration) & obs$concentration > 0 &
    is.finite(obs$response), , drop = FALSE]
  if (nrow(obs) == 0) {
    stop("No finite, positive-concentration observations to plot.",
      call. = FALSE
    )
  }

  conditions <- sort(unique(obs$condition))
  obs$condition <- factor(obs$condition, levels = conditions)

  curves <- list()
  summaries <- character(0)
  for (cond in conditions) {
    sub <- obs[obs$condition == cond, , drop = FALSE]
    if (nrow(sub) < 4) {
      warning("Condition '", cond, "' has fewer than 4 usable observations; ",
        "plotting points only.",
        call. = FALSE
      )
      next
    }
    fit <- fit_dose_response(sub, "response", "concentration")
    cf <- stats::coef(fit)
    grid <- 10^seq(log10(min(sub$concentration)), log10(max(sub$concentration)),
      length.out = n_points
    )
    curves[[cond]] <- data.frame(
      concentration = grid,
      response = stats::predict(fit, newdata = grid),
      condition = factor(cond, levels = conditions),
      stringsAsFactors = FALSE
    )
    auc <- compute_auc(
      min(sub$concentration), max(sub$concentration),
      top = cf[["top"]], ic50 = cf[["ic50"]],
      hill = cf[["hill"]], bottom = cf[["bottom"]]
    )
    summaries <- c(summaries, sprintf(
      "%s  IC50: %.2f  AUC: %.2f", cond, cf[["ic50"]], auc
    ))
  }

  n_cond <- length(conditions)
  cond_colors <- stats::setNames(
    rep(colors, length.out = n_cond)[seq_len(n_cond)], conditions
  )
  cond_shapes <- stats::setNames(
    rep(shapes, length.out = n_cond)[seq_len(n_cond)], conditions
  )

  p <- ggplot2::ggplot() +
    ggplot2::geom_point(
      data = obs,
      ggplot2::aes(
        x = .data$concentration, y = .data$response,
        color = .data$condition, shape = .data$condition
      ),
      size = 3
    )

  if (length(curves) > 0) {
    curve_df <- do.call(rbind, curves)
    p <- p + ggplot2::geom_line(
      data = curve_df,
      ggplot2::aes(
        x = .data$concentration, y = .data$response,
        color = .data$condition
      ),
      linewidth = 1.2
    )
  }
  if (!is.na(ref_line)) {
    p <- p + ggplot2::geom_hline(
      yintercept = ref_line, linetype = "dashed", color = "#888888",
      linewidth = 0.6
    )
  }

  x_lab <- if (!is.null(drug)) {
    bquote("[" * .(drug) * "] Concentration" ~ (mu * M))
  } else {
    bquote("Concentration" ~ (mu * M))
  }
  subtitle <- if (length(summaries) > 0) paste(summaries, collapse = "\n") else NULL

  p <- p +
    ggplot2::expand_limits(y = 0) +
    ggplot2::scale_x_log10() +
    ggplot2::scale_color_manual(name = legend_title, values = cond_colors) +
    ggplot2::scale_shape_manual(name = legend_title, values = cond_shapes) +
    ggplot2::labs(
      x = x_lab, y = "Relative cell proliferation", subtitle = subtitle
    ) +
    ggplot2::geom_hline(yintercept = 0, color = "grey80", linewidth = 0.3) +
    theme_hts(base_size = 14, legend = "right")

  p + ggplot2::theme(
    plot.subtitle = ggplot2::element_text(size = 12, hjust = 0.5,
      color = "#333333"),
    legend.title = ggplot2::element_text(size = 13, color = "#333333")
  )
}

#' Scatter plot of control readouts across plates
#'
#' Draws, for each plate, the individual negative- and positive-control well
#' readouts (for example normalised cell counts) so that plate-to-plate control
#' behaviour and separation can be inspected at a glance. This is the standard
#' QC view used to confirm that negative controls stay high and positive
#' controls stay low across an entire screen.
#'
#' @param data A data frame with one row per plate and several negative- and
#'   positive-control replicate columns (wide format, such as
#'   [screen_plate_qc]).
#' @param plate_col Name of the plate identifier column, as a string. Default
#'   `"plateID"`.
#' @param neg_cols,pos_cols Character vectors naming the negative- and
#'   positive-control replicate columns. By default columns matching
#'   `"^neg_ctrl"` and `"^pos_ctrl"` are used.
#' @param colors Named character vector giving the point colours for the two
#'   control groups.
#' @param y_title Y-axis title. Default `"Normalized cell count"`.
#'
#' @return A [ggplot2::ggplot] object: control readouts (points, dodged and
#'   jittered) per plate, coloured by control type, with the y-axis expanded to
#'   include zero.
#'
#' @examples
#' plot_control_scatter(screen_plate_qc)
#'
#' @seealso [plot_qc_boxplot()], [plot_qc_circular_heatmap()]
#' @export
plot_control_scatter <- function(data, plate_col = "plateID",
                                 neg_cols = grep("^neg_ctrl", names(data),
                                   value = TRUE),
                                 pos_cols = grep("^pos_ctrl", names(data),
                                   value = TRUE),
                                 colors = c("Negative control" = "#0072B2",
                                            "Positive control" = "#D55E00"),
                                 y_title = "Normalized cell count") {
  if (!plate_col %in% names(data)) {
    stop("Column '", plate_col, "' not found in `data`.", call. = FALSE)
  }
  if (length(neg_cols) == 0 || length(pos_cols) == 0) {
    stop("No negative- or positive-control columns found; supply `neg_cols` ",
      "and `pos_cols`.",
      call. = FALSE
    )
  }

  plate <- factor(data[[plate_col]], levels = unique(data[[plate_col]]))
  long <- rbind(
    data.frame(
      plate = rep(plate, length(neg_cols)),
      value = .as_numeric(unlist(data[neg_cols], use.names = FALSE)),
      control = "Negative control",
      stringsAsFactors = FALSE
    ),
    data.frame(
      plate = rep(plate, length(pos_cols)),
      value = .as_numeric(unlist(data[pos_cols], use.names = FALSE)),
      control = "Positive control",
      stringsAsFactors = FALSE
    )
  )

  ggplot2::ggplot(
    long,
    ggplot2::aes(x = .data$plate, y = .data$value, color = .data$control)
  ) +
    ggplot2::geom_point(
      position = ggplot2::position_jitterdodge(
        jitter.width = 0.15, dodge.width = 0.6
      ),
      alpha = 0.7, size = 1.8
    ) +
    ggplot2::scale_color_manual(values = colors) +
    ggplot2::expand_limits(y = 0) +
    ggplot2::labs(x = NULL, y = y_title, color = "Control") +
    theme_hts(legend = "bottom") +
    ggplot2::theme(
      axis.text.x = ggplot2::element_text(angle = 45, hjust = 1)
    )
}

#' Trend plot of a QC metric across plates
#'
#' Plots a single QC metric (for example Z', Z-factor or negative-control CV)
#' for every plate in acquisition order, colouring each plate by a pass/fail
#' flag and marking the overall mean. This mirrors the per-metric QC review
#' plots used in the screening pipeline and makes drifting or failing plates
#' easy to spot.
#'
#' @param data A data frame with one row per plate, such as [screen_plate_qc].
#' @param metric Name of the metric column to plot, as a string. Default
#'   `"z_prime"`.
#' @param plate_col Name of the plate identifier column, as a string. Default
#'   `"plateID"`.
#' @param flag_col Optional name of a column of `"Bad"`/`"Normal"` flags used to
#'   colour points. If `NULL` (default) plates are flagged on the fly using
#'   `threshold` and `direction`.
#' @param threshold Numeric cut-off used to flag plates when `flag_col` is
#'   `NULL`. Default `0.5`.
#' @param direction Either `"min"` (values below `threshold` fail, the default)
#'   or `"max"` (values above `threshold` fail).
#' @param colors Named character vector giving the colours for `"Bad"` and
#'   `"Normal"` plates.
#'
#' @return A [ggplot2::ggplot] object: one bar per plate in plate order,
#'   coloured by flag, with a dashed line at the mean and a solid line at
#'   `threshold`.
#'
#' @examples
#' plot_qc_metric_trend(screen_plate_qc, metric = "z_prime")
#'
#' @seealso [calculate_qc_metrics()], [flag_qc_plates()]
#' @export
plot_qc_metric_trend <- function(data, metric = "z_prime",
                                 plate_col = "plateID", flag_col = NULL,
                                 threshold = 0.5, direction = c("min", "max"),
                                 colors = c(Bad = "#D55E00", Normal = "#0072B2")) {
  direction <- match.arg(direction)
  for (col in c(metric, plate_col)) {
    if (!col %in% names(data)) {
      stop("Column '", col, "' not found in `data`.", call. = FALSE)
    }
  }

  df <- data.frame(
    plate = factor(data[[plate_col]], levels = unique(data[[plate_col]])),
    value = .as_numeric(data[[metric]]),
    stringsAsFactors = FALSE
  )
  if (!is.null(flag_col)) {
    if (!flag_col %in% names(data)) {
      stop("Column '", flag_col, "' not found in `data`.", call. = FALSE)
    }
    df$flag <- ifelse(as.character(data[[flag_col]]) == "Bad", "Bad", "Normal")
  } else {
    fail <- if (direction == "min") df$value < threshold else df$value > threshold
    df$flag <- ifelse(fail, "Bad", "Normal")
  }
  df$flag <- factor(df$flag, levels = c("Bad", "Normal"))
  mean_val <- mean(df$value, na.rm = TRUE)

  ggplot2::ggplot(
    df,
    ggplot2::aes(x = .data$plate, y = .data$value, fill = .data$flag)
  ) +
    ggplot2::geom_col(width = 0.7, color = "white", linewidth = 0.3) +
    ggplot2::geom_hline(yintercept = threshold, color = "#888888",
      linewidth = 0.6) +
    ggplot2::geom_hline(
      yintercept = mean_val, linetype = "dashed",
      color = .hts_col("reference"), linewidth = 0.6
    ) +
    ggplot2::scale_fill_manual(values = colors, drop = FALSE, name = NULL) +
    ggplot2::labs(x = NULL, y = metric) +
    theme_hts(legend = "bottom") +
    ggplot2::theme(
      axis.text.x = ggplot2::element_text(angle = 45, hjust = 1)
    )
}

#' Circular plot of a per-plate QC metric
#'
#' Draws a circular (\pkg{circlize}) scatter of a QC metric with one sector per
#' experiment and one point per plate, coloured by whether the plate passes a
#' threshold. This reproduces the whole-screen Z'-factor overview used in the
#' pipeline, where many plates across several experiments are compared on a
#' single ring.
#'
#' @param data A data frame with one row per plate, such as [screen_plate_qc].
#' @param metric Name of the metric column, as a string. Default `"z_prime"`.
#' @param sector_col Name of the column that defines the sectors (experiments),
#'   as a string. Default `"experimentID"`.
#' @param x_col Name of the numeric within-sector position column (plate order),
#'   as a string. Default `"plateNumber"`.
#' @param label_col Name of the column holding the plate identifier printed on
#'   the outside of each bar, as a string. Default `"plateID"`; if the column is
#'   absent the values of `x_col` are used instead.
#' @param threshold Numeric pass/fail cut-off. Plates whose metric is below
#'   `threshold` are drawn in `bad_color`. Default `0.5`.
#' @param transform Logical; if `TRUE` (default) strongly negative metric values
#'   (`< -1`) are compressed with `-log10(-x)` for display, matching the
#'   pipeline's Z'-factor plot.
#' @param colors Named character vector with entries `"Bad"` and `"Normal"`
#'   giving the bar colours.
#' @param bar_lwd Line width of each plate bar. Default `4`.
#' @param x_margin Numeric angular padding (in `x_col` units) added to each side
#'   of every sector so the first and last plate bars do not touch the sector
#'   edges. Default `0.8`.
#' @param show_labels Logical; if `TRUE` (default) the plate identifier from
#'   `label_col` is printed radially just outside each bar.
#' @param center_label Text drawn in the centre of the ring. Defaults to
#'   `metric`.
#'
#' @return Invisibly `NULL`. Called for the side effect of drawing to the active
#'   graphics device.
#'
#' @details Requires the suggested package \pkg{circlize}. Each experiment is a
#'   sector and each plate a thin vertical bar drawn at its own `plateNumber`
#'   position, rising from a zero baseline to the metric value, so the bars never
#'   overlap. An angular `x_margin` keeps the first and last bars clear of the
#'   sector edges, the plate identifier is printed radially just outside each
#'   bar, and a short Y-axis on the first sector plus a dashed reference line at
#'   `threshold` in every sector give the scale. The design scales to large
#'   campaigns of roughly ten plates per experiment.
#'
#' @examples
#' if (requireNamespace("circlize", quietly = TRUE)) {
#'   plot_qc_metric_circular(screen_plate_qc, metric = "z_prime")
#' }
#'
#' @seealso [plot_qc_metric_trend()], [plot_qc_circular_heatmap()]
#' @export
plot_qc_metric_circular <- function(data, metric = "z_prime",
                                    sector_col = "experimentID",
                                    x_col = "plateNumber",
                                    label_col = "plateID",
                                    threshold = 0.5, transform = TRUE,
                                    colors = c(Bad = "#D55E00",
                                               Normal = "#0072B2"),
                                    bar_lwd = 4,
                                    x_margin = 0.8,
                                    show_labels = TRUE,
                                    center_label = NULL) {
  if (!requireNamespace("circlize", quietly = TRUE)) {
    stop("Package 'circlize' is required for `plot_qc_metric_circular()`.",
      call. = FALSE
    )
  }
  for (col in c(metric, sector_col, x_col)) {
    if (!col %in% names(data)) {
      stop("Column '", col, "' not found in `data`.", call. = FALSE)
    }
  }

  compress <- function(v) ifelse(v > -1, v, -log10(-v))
  value <- .as_numeric(data[[metric]])
  df <- data.frame(
    sector = as.character(data[[sector_col]]),
    x = .as_numeric(data[[x_col]]),
    value = value,
    y = if (transform) compress(value) else value,
    label = if (label_col %in% names(data)) {
      as.character(data[[label_col]])
    } else {
      as.character(data[[x_col]])
    },
    stringsAsFactors = FALSE
  )
  df$barcol <- ifelse(df$value < threshold, colors[["Bad"]], colors[["Normal"]])
  thr_disp <- if (transform) compress(threshold) else threshold
  if (is.null(center_label)) center_label <- metric

  yr <- range(c(df$y, thr_disp, 0), na.rm = TRUE)
  pad <- diff(yr) * 0.12
  if (!is.finite(pad) || pad == 0) pad <- 0.1
  ylim <- c(min(yr[1], 0) - pad, yr[2] + pad)
  first_sector <- df$sector[1]
  y_at <- unique(round(c(0, thr_disp, yr[2]), 2))

  ## Give each sector an explicit x range with a margin so the first and last
  ## bars are inset from the sector edges instead of overlapping them.
  secs <- unique(df$sector)
  xlim <- do.call(rbind, lapply(secs, function(s) {
    xs <- df$x[df$sector == s]
    c(min(xs) - x_margin, max(xs) + x_margin)
  }))

  on.exit(circlize::circos.clear(), add = TRUE)
  circlize::circos.clear()
  circlize::circos.par("track.height" = 0.5, gap.degree = 4,
    cell.padding = c(0.02, 0.1, 0.02, 0.1),
    points.overflow.warning = FALSE,
    canvas.xlim = c(-1.35, 1.35), canvas.ylim = c(-1.35, 1.35)
  )
  circlize::circos.initialize(secs, xlim = xlim)
  circlize::circos.track(ylim = ylim,
    panel.fun = function(x, y) {
      sec <- circlize::CELL_META$sector.index
      d <- df[df$sector == sec, , drop = FALSE]
      ## Zero baseline (the x-axis) that every bar rises from.
      circlize::circos.lines(
        circlize::CELL_META$cell.xlim, c(0, 0),
        col = "#555555", lwd = 1.1
      )
      lab_y <- circlize::CELL_META$cell.ylim[2] + circlize::mm_y(2)
      for (i in seq_len(nrow(d))) {
        circlize::circos.lines(
          c(d$x[i], d$x[i]), c(0, d$y[i]),
          col = d$barcol[i], lwd = bar_lwd, straight = TRUE
        )
        if (isTRUE(show_labels)) {
          circlize::circos.text(
            d$x[i], lab_y, d$label[i], cex = 0.5, col = "#555555",
            facing = "clockwise", niceFacing = TRUE, adj = c(0, 0.5)
          )
        }
      }
      circlize::circos.lines(
        circlize::CELL_META$cell.xlim, c(thr_disp, thr_disp),
        col = "#666666", lty = 2
      )
      if (sec == first_sector) {
        circlize::circos.yaxis(
          side = "left", at = y_at, labels.cex = 0.55, col = "#888888",
          labels.col = "#555555"
        )
      }
    }
  )
  graphics::text(0, 0, center_label, cex = 1.3, col = "#222222")
  invisible(NULL)
}

#' Circular heatmap of control readouts across plates
#'
#' Draws a large circular (\pkg{circlize}) heatmap of the control-well readouts
#' for every plate, grouped into sectors by experiment. Each ring is one plate
#' and each cell one control well, so systematic control problems across a whole
#' screen become visible at once. This mirrors the summary `circos.heatmap`
#' used in the pipeline to display negative- and positive-control cell counts.
#'
#' @param data A data frame with one row per plate and numeric control columns,
#'   such as [screen_plate_qc].
#' @param value_cols Character vector of the control-value columns to display.
#'   By default all columns matching `"_ctrl_"` are used.
#' @param plate_col Name of the plate identifier column (used as row names), as
#'   a string. Default `"plateID"`.
#' @param split_col Optional name of a grouping column (experiment) used to
#'   split the ring into sectors. Default `"experimentID"`.
#' @param col_breaks,col_colors Numeric breakpoints and matching colours passed
#'   to [circlize::colorRamp2()]. Defaults map `0`/`0.5`/`1` to
#'   navy/white/firebrick.
#' @param name Legend title. Default `"Norm cell counts"`.
#' @param show_sector_labels Logical, whether to label each sector. Default
#'   `TRUE`.
#'
#' @return Invisibly `NULL`. Called for the side effect of drawing to the active
#'   graphics device.
#'
#' @details Requires the suggested package \pkg{circlize}; when
#'   \pkg{ComplexHeatmap} is also installed a colour legend is added.
#'
#' @examples
#' \donttest{
#' if (requireNamespace("circlize", quietly = TRUE)) {
#'   plot_qc_circular_heatmap(screen_plate_qc)
#' }
#' }
#'
#' @seealso [plot_control_scatter()], [plot_delta_auc_heatmap()]
#' @export
plot_qc_circular_heatmap <- function(data,
                                     value_cols = grep("_ctrl_", names(data),
                                       value = TRUE),
                                     plate_col = "plateID",
                                     split_col = "experimentID",
                                     col_breaks = c(0, 0.5, 1),
                                     col_colors = c("navy", "white",
                                       "firebrick3"),
                                     name = "Norm cell counts",
                                     show_sector_labels = TRUE) {
  if (!requireNamespace("circlize", quietly = TRUE)) {
    stop("Package 'circlize' is required for `plot_qc_circular_heatmap()`.",
      call. = FALSE
    )
  }
  if (!plate_col %in% names(data)) {
    stop("Column '", plate_col, "' not found in `data`.", call. = FALSE)
  }
  if (length(value_cols) == 0) {
    stop("No control-value columns found; supply `value_cols`.", call. = FALSE)
  }

  mat <- as.matrix(data[, value_cols, drop = FALSE])
  storage.mode(mat) <- "double"
  rownames(mat) <- data[[plate_col]]
  split <- if (!is.null(split_col) && split_col %in% names(data)) {
    factor(data[[split_col]])
  } else {
    NULL
  }
  col_fun <- circlize::colorRamp2(col_breaks, col_colors)

  on.exit(circlize::circos.clear(), add = TRUE)
  circlize::circos.clear()
  circlize::circos.heatmap(mat, split = split, col = col_fun,
    track.height = 0.4, bg.border = "#CCCCCC", bg.lwd = 0.6, bg.lty = 1,
    rownames.side = "outside", show.sector.labels = show_sector_labels
  )
  circlize::circos.clear()

  if (requireNamespace("ComplexHeatmap", quietly = TRUE) &&
    requireNamespace("grid", quietly = TRUE)) {
    lgd <- ComplexHeatmap::Legend(title = name, col_fun = col_fun)
    grid::grid.draw(lgd)
  }
  invisible(NULL)
}

#' Plate-layout heatmap of well readouts
#'
#' Draws a microplate as a grid of wells (rows lettered top-to-bottom, columns
#' numbered left-to-right) coloured by a per-well readout such as normalised
#' cell count, so that drug-treated wells and their positive/negative controls
#' can be inspected in their physical plate positions. Missing wells are shown
#' as empty cells, making layout errors and edge effects easy to spot. This
#' abstracts the per-plate `heatmap.2()` plate maps used in the screening
#' pipeline, but is built on \pkg{ggplot2} and needs no external heatmap
#' package.
#'
#' @param data A long-format data frame with one row per well.
#' @param fill Name of the column mapped to tile colour, as a string. Default
#'   `"normalized_cell_count"`. Numeric columns use a continuous viridis scale;
#'   character/factor columns use a discrete viridis scale (useful for showing
#'   the well-type layout).
#' @param well_col Optional name of a single well-identifier column such as
#'   `"A1"` or `"B12"`. If supplied it is split into a row letter and column
#'   number and `row_col`/`col_col` are ignored.
#' @param row_col,col_col Names of the row (letter) and column (number)
#'   columns used when `well_col` is `NULL`. Defaults `"plate_rows"` and
#'   `"plate_columns"`. Any non-digit characters in the column values (for
#'   example an `"X"` prefix) are stripped.
#' @param label Optional name of a column whose values are printed inside each
#'   tile (for example a drug name or a QC flag). Default `NULL`.
#' @param control_col Optional name of a column identifying each well's role
#'   (for example `"well_type"` or `"experiment_type"`). When supplied, the
#'   negative- and positive-control wells are circled on the plot so that they
#'   can be told apart from the drug-treated wells at a glance. Default `NULL`
#'   (no wells circled).
#' @param nc_values,pc_values Character vectors of the labels in `control_col`
#'   that mark negative and positive controls. Defaults cover the package's
#'   canonical labels (`"NC"`/`"PC"`) and the common long forms
#'   (`"negative_ctrl"`/`"positive_ctrl"`).
#' @param circle_size Size of the control-marking circles. When `NULL`
#'   (default) it is scaled to the plate so that 96- and 384-well plates are
#'   both legible.
#' @param plate Optional plate identifier; if supplied `data` is filtered to
#'   this plate. When `NULL` and several plates are present the plot is
#'   facetted by `plate_col`.
#' @param plate_col Name of the plate identifier column, as a string. Default
#'   `"plateID"`.
#' @param plate_type Plate format, either `96` or `384`, controlling the size
#'   of the drawn grid. When `NULL` (default) it is inferred from the observed
#'   rows and columns.
#' @param title Optional plot title.
#' @param na_value Colour used for wells with no data. Default `"grey90"`.
#'
#' @return A [ggplot2::ggplot] object: one tile per well, coloured by `fill`,
#'   with row A at the top and column 1 at the top-left, drawn with a fixed
#'   aspect ratio. When `control_col` is supplied the negative- and
#'   positive-control wells are outlined with coloured circles.
#'
#' @details Duplicate wells (for example replicates sharing a position) are
#'   summarised by their mean for numeric `fill` and concatenated for
#'   categorical `fill`/`label`.
#'
#' @examples
#' # A single 96-well plate coloured by normalised cell count.
#' plot_plate_heatmap(subset(screen_plate_layout, plateID == "EXP99_Gy0"))
#'
#' # Show the well-type layout instead, both plates side by side.
#' plot_plate_heatmap(screen_plate_layout, fill = "experiment_type")
#'
#' # Circle the negative and positive controls on a raw 96-well plate.
#' plot_plate_heatmap(
#'   subset(hts_primary_raw, plate_id == "P01"),
#'   fill = "signal", well_col = "well", plate_col = "plate_id",
#'   control_col = "well_type"
#' )
#'
#' # A realistic 384-well plate (the outer 2-well ring is a compound-free buffer
#' # that reads at the low, no-cell level; controls sit on a diagonal in the two
#' # inset columns 3 and 22).
#' plot_plate_heatmap(hts_plate_384, fill = "signal", well_col = "well",
#'   control_col = "well_type", plate_col = "plate_id", plate_type = 384)
#'
#' @seealso [plot_qc_boxplot()], [plot_control_scatter()]
#' @export
plot_plate_heatmap <- function(data, fill = "normalized_cell_count",
                               well_col = NULL, row_col = "plate_rows",
                               col_col = "plate_columns", label = NULL,
                               control_col = NULL,
                               nc_values = c("NC", "negative_ctrl"),
                               pc_values = c("PC", "positive_ctrl"),
                               circle_size = NULL,
                               plate = NULL, plate_col = "plateID",
                               plate_type = NULL, title = NULL,
                               na_value = "grey90") {
  if (!fill %in% names(data)) {
    stop("Column '", fill, "' not found in `data`.", call. = FALSE)
  }
  df <- data

  facet <- FALSE
  if (!is.null(plate)) {
    if (!plate_col %in% names(df)) {
      stop("Column '", plate_col, "' not found in `data`.", call. = FALSE)
    }
    df <- df[as.character(df[[plate_col]]) == plate, , drop = FALSE]
    if (nrow(df) == 0) {
      stop("No rows found for plate '", plate, "'.", call. = FALSE)
    }
  } else if (plate_col %in% names(df) &&
    length(unique(df[[plate_col]])) > 1) {
    facet <- TRUE
  }

  if (!is.null(well_col)) {
    if (!well_col %in% names(df)) {
      stop("Column '", well_col, "' not found in `data`.", 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))
  } else {
    for (col in c(row_col, col_col)) {
      if (!col %in% names(df)) {
        stop("Column '", col, "' not found in `data`.", call. = FALSE)
      }
    }
    rows <- toupper(gsub("[^A-Za-z]", "", as.character(df[[row_col]])))
    cols <- as.integer(gsub("\\D", "", as.character(df[[col_col]])))
  }

  numeric_fill <- is.numeric(df[[fill]])
  plot_df <- data.frame(
    row = rows, col = cols,
    value = if (numeric_fill) .as_numeric(df[[fill]]) else as.character(df[[fill]]),
    stringsAsFactors = FALSE
  )
  if (!is.null(label)) {
    if (!label %in% names(df)) {
      stop("Column '", label, "' not found in `data`.", call. = FALSE)
    }
    plot_df$lab <- as.character(df[[label]])
  }
  if (!is.null(control_col)) {
    if (!control_col %in% names(df)) {
      stop("Column '", control_col, "' not found in `data`.", call. = FALSE)
    }
    cvals <- as.character(df[[control_col]])
    ctrl <- rep(NA_character_, length(cvals))
    ctrl[cvals %in% nc_values] <- "Negative control"
    ctrl[cvals %in% pc_values] <- "Positive control"
    plot_df$ctrl <- ctrl
  }
  if (facet) plot_df$plate <- as.character(df[[plate_col]])
  plot_df <- plot_df[!is.na(plot_df$row) & !is.na(plot_df$col), , drop = FALSE]
  if (nrow(plot_df) == 0) {
    stop("No wells with a valid row and column could be parsed.", call. = FALSE)
  }

  by_cols <- c("row", "col", if (facet) "plate")
  collapse_chr <- function(z) paste(unique(z[!is.na(z)]), collapse = "/")
  agg_one <- function(col, fun) {
    a <- stats::aggregate(plot_df[[col]],
      by = plot_df[by_cols], FUN = fun
    )
    names(a)[ncol(a)] <- col
    a
  }
  agg <- agg_one("value", if (numeric_fill) {
    function(z) mean(z, na.rm = TRUE)
  } else {
    collapse_chr
  })
  if (!is.null(label)) {
    agg <- merge(agg, agg_one("lab", collapse_chr), by = by_cols)
  }
  if (!is.null(control_col)) {
    ctrl_agg <- agg_one("ctrl", collapse_chr)
    ctrl_agg$ctrl[ctrl_agg$ctrl == ""] <- NA
    agg <- merge(agg, ctrl_agg, by = by_cols)
  }

  max_row <- max(match(agg$row, LETTERS), na.rm = TRUE)
  max_col <- max(agg$col, na.rm = TRUE)
  if (is.null(plate_type)) {
    plate_type <- if (max_row <= 8 && max_col <= 12) 96L else 384L
  }
  dims <- switch(as.character(plate_type),
    "96" = c(8L, 12L), "384" = c(16L, 24L),
    c(max(max_row, 1L), max(max_col, 1L))
  )
  n_row <- max(dims[1], max_row)
  n_col <- max(dims[2], max_col)
  row_levels <- LETTERS[seq_len(n_row)]
  col_levels <- seq_len(n_col)

  base_grid <- expand.grid(row = row_levels, col = col_levels,
    KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE
  )
  if (facet) {
    plates <- unique(agg$plate)
    grid_df <- do.call(rbind, lapply(plates, function(p) {
      cbind(base_grid, plate = p)
    }))
  } else {
    grid_df <- base_grid
  }
  full <- merge(grid_df, agg, by = by_cols, all.x = TRUE)

  full$row <- factor(full$row, levels = rev(row_levels))
  full$col <- factor(full$col, levels = col_levels)

  p <- ggplot2::ggplot(
    full,
    ggplot2::aes(x = .data$col, y = .data$row, fill = .data$value)
  ) +
    ggplot2::geom_tile(color = "grey70", linewidth = 0.3)

  if (!is.null(control_col) && any(!is.na(full$ctrl))) {
    if (is.null(circle_size)) {
      circle_size <- max(1.4, min(6, 90 / n_col))
    }
    ctrl_df <- full[!is.na(full$ctrl), , drop = FALSE]
    p <- p +
      ggplot2::geom_point(
        data = ctrl_df,
        ggplot2::aes(color = .data$ctrl),
        shape = 21, fill = NA, size = circle_size, stroke = 1.3,
        inherit.aes = TRUE, show.legend = TRUE
      ) +
      ggplot2::scale_color_manual(
        values = c("Negative control" = "#CC79A7",
          "Positive control" = "#D55E00"),
        name = "Control", na.translate = FALSE
      )
  }

  if (!is.null(label)) {
    p <- p + ggplot2::geom_text(
      ggplot2::aes(label = .data$lab), size = 2, na.rm = TRUE
    )
  }

  fill_scale <- if (numeric_fill) {
    ggplot2::scale_fill_viridis_c(na.value = na_value)
  } else {
    ggplot2::scale_fill_viridis_d(na.value = na_value)
  }

  p +
    fill_scale +
    ggplot2::scale_x_discrete(position = "top") +
    ggplot2::coord_fixed() +
    (if (facet) ggplot2::facet_wrap(ggplot2::vars(.data$plate)) else NULL) +
    ggplot2::labs(x = NULL, y = NULL, fill = fill, title = title) +
    ggplot2::theme_minimal(base_size = 11) +
    ggplot2::theme(
      panel.grid = ggplot2::element_blank(),
      plot.background = ggplot2::element_rect(fill = "#FFFFFF", color = NA),
      text = ggplot2::element_text(color = "#333333"),
      plot.title = ggplot2::element_text(face = "bold", color = "#222222"),
      axis.text = ggplot2::element_text(size = 8, color = "#555555")
    )
}
