# Internal helpers shared across the HTS pipeline -----------------------------

# Canonical well-type labels used throughout the package.
.hts_well_types <- c(negative = "NC", positive = "PC", blank = "Blank",
                     compound = "compound")

# Read an HTS table from csv/txt/xlsx into a data frame.
.read_hts_file <- function(file, sep = "\t", sheet = 1) {
  ext <- tolower(tools::file_ext(file))
  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)
  }
}

# Reshape a plate-map grid (first column = row letters, remaining column names =
# column indices) into a long data frame with `row`, `col`, `well` and `value`.
.grid_to_long <- function(grid, value_name = "content") {
  grid <- as.data.frame(grid, stringsAsFactors = FALSE, check.names = FALSE)
  if (ncol(grid) < 2L) {
    stop("A plate-map grid needs a row-label column plus one column per plate ",
      "column.", call. = FALSE)
  }
  row_labels <- toupper(trimws(as.character(grid[[1]])))
  body <- grid[, -1, drop = FALSE]
  col_idx <- suppressWarnings(
    as.integer(sub("^[^0-9]*([0-9]+).*$", "\\1", names(body)))
  )
  if (anyNA(col_idx)) {
    stop("Plate-map column headers must contain the plate column numbers ",
      "(e.g. 1, 2, ... 24).", call. = FALSE)
  }
  long <- do.call(rbind, lapply(seq_len(nrow(body)), function(i) {
    data.frame(
      row = row_labels[i],
      col = col_idx,
      value = as.character(unlist(body[i, ], use.names = FALSE)),
      stringsAsFactors = FALSE
    )
  }))
  long$value <- trimws(long$value)
  long$well <- paste0(long$row, long$col)
  names(long)[names(long) == "value"] <- value_name
  long[, c("well", "row", "col", value_name)]
}

# Physical dimensions (rows x columns) of a standard microplate format.
.plate_dims <- function(format) {
  dims <- list(
    "6" = c(2L, 3L), "12" = c(3L, 4L), "24" = c(4L, 6L), "48" = c(6L, 8L),
    "96" = c(8L, 12L), "384" = c(16L, 24L), "1536" = c(32L, 48L)
  )
  key <- as.character(format)
  if (!key %in% names(dims)) {
    stop("Unsupported plate format '", format, "'. Use one of ",
      paste(names(dims), collapse = ", "), ".",
      call. = FALSE
    )
  }
  dims[[key]]
}

# Row labels for a plate with `n` rows: A..Z then AA..AF for 1536-well plates.
.plate_row_labels <- function(n) {
  labels <- c(LETTERS, paste0("A", LETTERS))
  labels[seq_len(n)]
}

# Attach the standard hts classes to a data frame.
.as_hts <- function(df, subclass) {
  class(df) <- unique(c(subclass, "hts_tbl", "data.frame"))
  df
}

#' Standard microplate coordinate map
#'
#' Builds the full row/column coordinate table for a standard microplate,
#' ordered by row then column. This is useful for joining plate readouts onto a
#' complete layout so that empty wells are represented explicitly.
#'
#' @param format Plate format (number of wells): one of `6`, `12`, `24`, `48`,
#'   `96`, `384` or `1536`. Default `96`.
#'
#' @return A data frame with one row per well and the columns `well` (for
#'   example `"A1"`), `row` (row letter), `col` (column number) and `row_index`
#'   (numeric row position, `A = 1`).
#'
#' @examples
#' head(plate_layout(96))
#' nrow(plate_layout(384))
#'
#' @seealso [plate_layout_384()], [plate_layout_1536()]
#' @export
plate_layout <- function(format = 96) {
  d <- .plate_dims(format)
  n_row <- d[1]
  n_col <- d[2]
  rows <- .plate_row_labels(n_row)
  grid <- expand.grid(col = seq_len(n_col), row = rows,
    KEEP.OUT.ATTRS = FALSE, stringsAsFactors = FALSE
  )
  grid <- grid[order(match(grid$row, rows), grid$col), , drop = FALSE]
  data.frame(
    well = paste0(grid$row, grid$col),
    row = grid$row,
    col = as.integer(grid$col),
    row_index = match(grid$row, rows),
    stringsAsFactors = FALSE,
    row.names = NULL
  )
}

#' @rdname plate_layout
#' @export
plate_layout_384 <- function() plate_layout(384)

#' @rdname plate_layout
#' @export
plate_layout_1536 <- function() plate_layout(1536)

#' Convert concentrations to a log10 scale
#'
#' Transforms a concentration vector to `log10`, the scale on which
#' dose-response curves are usually drawn and fitted. Non-positive values (for
#' example the zero-dose controls) become `NA`.
#'
#' @param x Numeric vector of concentrations (factors/characters are coerced).
#' @param shift_min Logical; if `TRUE` the minimum finite `log10` value is
#'   subtracted so that the series starts at zero (matching the pipeline's
#'   `log10(conc) - min(log10(conc))` convention). Default `FALSE`.
#'
#' @return A numeric vector of `log10` concentrations.
#'
#' @examples
#' convert_conc_log10(c(0.01, 0.1, 1, 10, 100))
#' convert_conc_log10(c(0.01, 0.1, 1, 10, 100), shift_min = TRUE)
#'
#' @export
convert_conc_log10 <- function(x, shift_min = FALSE) {
  x <- .as_numeric(x)
  x[!is.finite(x) | x <= 0] <- NA_real_
  out <- log10(x)
  if (shift_min && any(is.finite(out))) {
    out <- out - min(out, na.rm = TRUE)
  }
  out
}

#' Validate control-well labelling
#'
#' Checks that a data frame contains the expected negative-, positive- and
#' blank-control labels in its well-type column, so that normalisation and QC
#' can be carried out. It is a light-weight guard to run right after import.
#'
#' @param data A data frame of well-level readouts.
#' @param type_col Name of the well-type column, as a string. Default
#'   `"well_type"`.
#' @param required Character vector of labels that must be present. Default
#'   `c("NC", "PC")`; add `"Blank"` if background subtraction is planned.
#'
#' @return Invisibly, a list with `ok` (logical), `present` and `missing`
#'   (character vectors) and `counts` (a table of well-type frequencies). A
#'   warning is emitted when any required label is missing.
#'
#' @examples
#' d <- data.frame(well_type = c("NC", "NC", "PC", "compound"))
#' check_control_label(d)
#'
#' @export
check_control_label <- function(data, type_col = "well_type",
                                required = c("NC", "PC")) {
  if (!type_col %in% names(data)) {
    stop("Column '", type_col, "' not found in `data`.", call. = FALSE)
  }
  present_labels <- unique(as.character(data[[type_col]]))
  missing <- setdiff(required, present_labels)
  if (length(missing) > 0) {
    warning("Missing required control label(s): ",
      paste(missing, collapse = ", "), ".",
      call. = FALSE
    )
  }
  invisible(list(
    ok = length(missing) == 0,
    present = intersect(required, present_labels),
    missing = missing,
    counts = table(as.character(data[[type_col]]))
  ))
}

#' Standardise compound identifiers
#'
#' Cleans free-text compound identifiers into a consistent form: trims
#' surrounding whitespace, collapses internal whitespace, and replaces illegal
#' or separator characters with underscores so that IDs can be matched and used
#' safely in file names.
#'
#' @param x A character vector of compound identifiers.
#' @param to_upper Logical; if `TRUE` (default) the result is upper-cased.
#'
#' @return A character vector of cleaned identifiers.
#'
#' @examples
#' clean_compound_id(c("  cpd 001 ", "Drug/A", "cpd-002!"))
#'
#' @export
clean_compound_id <- function(x, to_upper = TRUE) {
  x <- as.character(x)
  x <- trimws(x)
  x <- gsub("[[:space:]]+", "_", x)
  x <- gsub("[^A-Za-z0-9_.-]", "_", x)
  x <- gsub("_+", "_", x)
  x <- gsub("^_|_$", "", x)
  if (to_upper) x <- toupper(x)
  x
}
