test_that("utils_hts helpers behave", {
  expect_equal(nrow(plate_layout(96)), 96L)
  expect_equal(nrow(plate_layout_384()), 384L)
  expect_equal(nrow(plate_layout_1536()), 1536L)
  expect_true(all(c("row", "col", "well") %in% names(plate_layout(96))))
  expect_equal(convert_conc_log10(c(1, 10, 100)), c(0, 1, 2))
  expect_equal(clean_compound_id(c("cpd 1!", "a-b")), c("CPD_1", "A-B"))
  chk <- suppressWarnings(check_control_label(
    data.frame(well_type = c("NC", "PC", "compound")),
    required = c("NC", "PC", "Blank")
  ))
  expect_false(chk$ok)
})

test_that("module 1 pre-QC runs end to end", {
  raw <- read_hts_plate(hts_primary_raw)
  expect_s3_class(raw, "hts_raw")
  raw <- baseline_subtract(raw)
  expect_true("signal_raw" %in% names(raw))
  raw <- detect_outlier_wells(raw)
  zp <- calc_z_prime(raw)
  expect_true(all(c("plate_id", "z_prime", "pass") %in% names(zp)))
  # P01/P02 good, P03 fails
  expect_equal(sum(zp$pass), 2L)
  valid <- filter_valid_plates(raw)
  expect_false("P03" %in% valid$plate_id)
})

test_that("calc_plate_qc reports the full QC panel per plate", {
  raw <- read_hts_plate(hts_primary_raw)
  qc <- calc_plate_qc(raw)
  expect_equal(nrow(qc), 3L)
  expect_true(all(c("cv_nc", "cv_pc", "sb", "sn", "z_prime", "robust_z_prime",
    "z_factor", "ssmd", "pass") %in% names(qc)))
  # Good plates separate the controls: high SSMD, low control CV, Z' >= 0.5.
  good <- qc[qc$plate_id %in% c("P01", "P02"), ]
  bad <- qc[qc$plate_id == "P03", ]
  expect_true(all(good$z_prime >= 0.5))
  expect_true(all(good$ssmd > bad$ssmd))
  expect_true(all(good$cv_nc < bad$cv_nc))
  expect_true(all(good$sb > 1))
  expect_equal(sum(qc$pass), 2L)
  # Z' and its robust variant agree on the pass/fail verdict here.
  expect_equal(qc$z_prime >= 0.5, qc$robust_z_prime >= 0.5)
})

test_that("standalone QC metric functions match calc_plate_qc", {
  raw <- read_hts_plate(hts_primary_raw)
  qc <- calc_plate_qc(raw)
  expect_equal(calc_robust_z_prime(raw)$robust_z_prime, qc$robust_z_prime)
  expect_equal(calc_z_factor(raw)$z_factor, qc$z_factor)
  expect_equal(calc_ssmd(raw)$ssmd, qc$ssmd)
  expect_equal(calc_sb_ratio(raw)$sb, qc$sb)
  expect_equal(calc_sn_ratio(raw)$sn, qc$sn)
  expect_equal(calc_cv(raw)$cv_nc, qc$cv_nc)
  expect_equal(calc_cv(raw)$cv_pc, qc$cv_pc)
  # Each returns one row per plate with its own pass flag.
  for (fn in list(calc_robust_z_prime, calc_ssmd, calc_sb_ratio, calc_sn_ratio,
    calc_cv)) {
    res <- fn(raw)
    expect_equal(nrow(res), 3L)
    expect_true("pass" %in% names(res))
  }
  # SSMD passes on magnitude, so the flag is direction-independent.
  expect_true(all(calc_ssmd(raw)$pass == (abs(qc$ssmd) >= 2)))
})

test_that("calc_well_zscore adds standard and robust per-well scores", {
  raw <- read_hts_plate(hts_primary_raw)
  sc <- calc_well_zscore(raw)
  expect_true(all(c("zscore", "robust_zscore") %in% names(sc)))
  expect_equal(nrow(sc), nrow(raw))
  # Reference population is roughly centred near zero within a plate.
  cpd <- sc[sc$plate_id == "P01" & sc$well_type == "compound", ]
  expect_lt(abs(stats::median(cpd$robust_zscore, na.rm = TRUE)), 1)
})

test_that("plot_plate_qc dispatches on metric", {
  raw <- read_hts_plate(hts_primary_raw)
  qc <- calc_plate_qc(raw)
  for (m in c("z_prime", "robust_z_prime", "z_factor", "ssmd", "sb", "sn",
    "cv_nc", "cv")) {
    expect_s3_class(plot_plate_qc(qc, metric = m), "ggplot")
  }
  sc <- calc_well_zscore(raw)
  expect_s3_class(plot_plate_qc(sc, metric = "robust_zscore"), "ggplot")
  expect_error(plot_plate_qc(qc, metric = "nonsense"), "Unknown metric")
  expect_error(plot_plate_qc(qc, metric = "zscore"), "calc_well_zscore")
})

test_that("summarize_plate_setup describes each plate's layout", {
  raw <- read_hts_plate(hts_primary_raw)
  s <- summarize_plate_setup(raw)
  # One row per plate; the run holds three 96-well plates.
  expect_equal(nrow(s), 3L)
  expect_true(all(s$plate_format == 96))
  expect_true(all(s$n_wells == 96))
  expect_true(all(s$n_unique_compound == 48))
  # Positions are read back from the data as authored: an empty "Blank"
  # evaporation-buffer ring, diagonal controls in the two inset columns (2 and
  # 11) and the 48 compounds in the interior columns 3-10.
  expect_true(all(s$nc_columns == "2, 11"))
  expect_true(all(s$pc_columns == "2, 11"))
  expect_true(all(s$compound_columns == "3-10"))
  # The realistic 384-well plate infers its format and diagonal inset controls.
  s384 <- summarize_plate_setup(hts_plate_384)
  expect_equal(s384$plate_format, 384)
  expect_equal(s384$pc_columns, "3, 22")
  expect_equal(s384$nc_columns, "3, 22")
})

test_that("read_plate_layout parses a user-defined grid plate map", {
  f <- system.file("extdata", "plate_layout_example.csv", package = "diffHTS")
  if (!nzchar(f)) {
    f <- testthat::test_path("..", "..", "inst", "extdata",
      "plate_layout_example.csv")
  }
  skip_if_not(file.exists(f))
  layout <- read_plate_layout(f, plate_id = "P01")
  expect_s3_class(layout, "hts_layout")
  expect_equal(nrow(layout), 96L)
  # User-defined roles: inset controls (not on the plate edge).
  expect_equal(sum(layout$well_type == "NC"), 6L)
  expect_equal(sum(layout$well_type == "PC"), 6L)
  expect_equal(sum(layout$well_type == "compound"), 48L)
  expect_equal(sum(layout$well_type == "Blank"), 36L)
  # Diagonal controls: NC and PC each sit in interior columns 2 and 11.
  expect_true(all(layout$col[layout$well_type == "NC"] %in% c(2L, 11L)))
  expect_true(all(layout$col[layout$well_type == "PC"] %in% c(2L, 11L)))
  expect_true(all(is.na(layout$compound_id[layout$well_type == "Blank"])))
})

test_that("apply_plate_layout stamps roles onto readings", {
  f <- system.file("extdata", "plate_layout_example.csv", package = "diffHTS")
  if (!nzchar(f)) {
    f <- testthat::test_path("..", "..", "inst", "extdata",
      "plate_layout_example.csv")
  }
  skip_if_not(file.exists(f))
  layout <- read_plate_layout(f, plate_id = "P01")
  readings <- data.frame(well = layout$well,
    signal = seq_len(nrow(layout)) * 10)
  raw <- apply_plate_layout(readings, layout, plate_id = "P01")
  expect_s3_class(raw, "hts_raw")
  expect_equal(nrow(raw), nrow(layout))
  expect_equal(table(raw$well_type), table(layout$well_type))
  expect_true(all(raw$plate_id == "P01"))
  # Missing wells in the layout raise a clear error.
  expect_error(
    apply_plate_layout(data.frame(well = "Z99", signal = 1), layout),
    "no matching well"
  )
})

test_that("module 1 plots return objects", {
  raw <- baseline_subtract(read_hts_plate(hts_primary_raw))
  expect_s3_class(plot_plate_heatmap_raw(raw, plate = "P01"), "ggplot")
  expect_s3_class(plot_plate_qc(calc_plate_qc(raw), metric = "z_prime"), "ggplot")
})

test_that("module 2 normalisation anchors controls", {
  raw <- baseline_subtract(read_hts_plate(hts_primary_raw))
  norm <- norm_by_control(raw)
  expect_s3_class(norm, "hts_norm")
  # NC ~ 0% inhibition, PC ~ 100% inhibition on a good plate
  nc <- norm[norm$plate_id == "P01" & norm$well_type == "NC", ]
  pc <- norm[norm$plate_id == "P01" & norm$well_type == "PC", ]
  expect_lt(abs(mean(nc$inhibition)), 5)
  expect_lt(abs(mean(pc$inhibition) - 100), 5)
  merged <- merge_plate_data(norm, norm)
  expect_equal(nrow(merged), 2 * nrow(norm))
})

test_that("module 2 plots return objects", {
  norm <- norm_by_control(baseline_subtract(read_hts_plate(hts_primary_raw)))
  expect_s3_class(plot_plate_heatmap_inhibition(norm, plate = "P01"), "ggplot")
  expect_s3_class(plot_inhibition_hist(norm, plate = "P01"), "ggplot")
})

test_that("module 3 replicate consistency", {
  norm <- norm_by_control(baseline_subtract(read_hts_plate(hts_primary_raw)))
  cv <- calc_replicate_cv(norm)
  expect_true(all(c("compound_id", "cv") %in% names(cv)))
  cor <- calc_replicate_correlation(norm)
  expect_true(cor$r_squared[1] > 0.8)
  good <- filter_bad_replicate(cv, cv_max = 50)
  expect_true(nrow(good) <= nrow(cv))
  expect_s3_class(plot_replicate_scatter(norm, "P01", "P02"), "ggplot")
  # No pair requested -> all pairwise combinations faceted into one plot,
  # with points coloured by well type (controls included).
  p_all <- plot_replicate_scatter(norm)
  expect_s3_class(p_all, "ggplot")
  expect_equal(length(unique(p_all$data$pair)), 3L)
  expect_true(all(c("compound", "NC", "PC") %in% p_all$data$type))
  expect_s3_class(plot_cv_distribution(cv), "ggplot")
})

test_that("module 4 primary hit selection", {
  norm <- norm_by_control(baseline_subtract(read_hts_plate(hts_primary_raw)))
  ph <- select_primary_hit(norm, threshold = 50)
  expect_true(all(c("compound_id", "activity", "is_hit") %in% names(ph)))
  s <- summarize_primary_hit(ph)
  expect_equal(s$n_hits, sum(ph$is_hit))
  pct <- select_primary_hit(norm, method = "percentile", percentile = 0.9)
  expect_true(sum(pct$is_hit) >= 1)
  expect_s3_class(plot_primary_inhibition_rank(ph), "ggplot")
  expect_s3_class(plot_hit_bar_count(ph), "ggplot")
})

test_that("module 4 sigma-based hit selection", {
  norm <- norm_by_control(baseline_subtract(read_hts_plate(hts_primary_raw)))
  sh <- select_sigma_hits(norm, n_sigma = 3)
  expect_true(all(c("compound_id", "activity", "sigma", "sigma_level", "band",
    "is_hit") %in% names(sh)))
  expect_true(all(sh$sigma_level %in% 0:3))
  # is_hit is exactly the compounds clearing 3 sigma.
  expect_equal(sum(sh$is_hit), sum(sh$sigma_level >= 3, na.rm = TRUE))
  # Robust (median/MAD) estimation recovers a non-empty hit tail that the
  # SD-based estimate (inflated by the hits themselves) misses entirely.
  expect_gt(sum(sh$is_hit), 0)
  sd_based <- select_sigma_hits(norm, method = "sd", n_sigma = 3)
  expect_gte(sum(sh$is_hit), sum(sd_based$is_hit))
  ss <- summarize_sigma_hits(sh)
  expect_equal(ss$sigma, 1:3)
  expect_true(all(ss$n_beyond >= c(ss$n_beyond[-1], 0)))  # monotone non-increasing
  expect_s3_class(plot_sigma_hits(sh), "ggplot")
})

test_that("module 5 dose-response fitting recovers S-curves", {
  dr <- import_dose_response(hts_dose_response, response_col = "viability",
    group_cols = "cell_line")
  expect_s3_class(dr, "hts_dose")
  drc <- fit_4pl_curve(dr, group_cols = "cell_line")
  expect_s3_class(drc, "hts_drc")
  expect_equal(nrow(drc$meta), 30L)
  # Good simulated curves should fit well.
  expect_gt(median(drc$meta$rsq, na.rm = TRUE), 0.9)
  params <- extract_drc_params(drc)
  expect_true(all(c("ic50", "emax", "bottom", "hill") %in% names(params)))
  drc <- calc_drc_auc(drc)
  expect_true("auc" %in% names(drc$meta))
  expect_true(all(is.finite(drc$meta$auc)))
  clean <- filter_low_quality_curve(drc, rsq_min = 0.85)
  expect_true(nrow(clean$meta) <= nrow(drc$meta))
})

test_that("module 5 plots return objects", {
  drc <- calc_drc_auc(fit_4pl_curve(import_dose_response(hts_dose_response,
    response_col = "viability", group_cols = "cell_line"),
    group_cols = "cell_line"))
  expect_s3_class(plot_single_drc(drc, drc$meta$curve_id[1]), "ggplot")
  expect_s3_class(plot_batch_drc_overlay(drc, drc$meta$curve_id[1:3]), "ggplot")
  pp <- merge(extract_drc_params(drc), drc$meta[, c("curve_id", "auc")])
  expect_s3_class(plot_ic50_auc_cor(pp), "ggplot")
})

test_that("module 6 AUC matrix and clustering", {
  drc <- calc_drc_auc(fit_4pl_curve(import_dose_response(hts_dose_response,
    response_col = "viability", group_cols = "cell_line"),
    group_cols = "cell_line"))
  m <- build_auc_matrix(drc$meta)
  expect_equal(dim(m), c(10L, 3L))
  mz <- build_auc_matrix(drc$meta, zscore = "row")
  expect_equal(attr(mz, "zscore"), "row")
  cl <- cluster_auc_matrix(m, k = 2)
  expect_equal(cl$k, 2L)
  expect_equal(length(cl$clusters), nrow(m))
  hits <- extract_cluster_hit(m, cl)
  expect_true(length(hits) >= 1)
})

test_that("module 6 plots draw without error", {
  skip_if_not_installed("ComplexHeatmap")
  drc <- calc_drc_auc(fit_4pl_curve(import_dose_response(hts_dose_response,
    response_col = "viability", group_cols = "cell_line"),
    group_cols = "cell_line"))
  m <- build_auc_matrix(drc$meta)
  pdf(tempfile(fileext = ".pdf"))
  on.exit(dev.off())
  expect_invisible(plot_auc_heatmap(m))
  expect_invisible(plot_cluster_tree(cluster_auc_matrix(m, k = 2)))
})

test_that("module 7 ranking, annotation and export", {
  drc <- calc_drc_auc(fit_4pl_curve(import_dose_response(hts_dose_response,
    response_col = "viability", group_cols = "cell_line"),
    group_cols = "cell_line"))
  pp <- merge(extract_drc_params(drc), drc$meta[, c("curve_id", "auc")])
  ranked <- rank_hit_compound(pp)
  expect_true(all(c("score", "tier") %in% names(ranked)))
  expect_true(is.ordered(ranked$tier))
  ann <- annotate_hit_info(ranked, hts_compound_meta)
  expect_true("compound_name" %in% names(ann))
  expect_equal(nrow(ann), nrow(ranked))
  f <- export_hit_table(ann, tempfile(fileext = ".csv"))
  expect_true(file.exists(f))
  expect_s3_class(plot_hit_stratify_bar(ranked), "ggplot")
})

test_that("module 7 primary-secondary correlation plot", {
  norm <- norm_by_control(baseline_subtract(read_hts_plate(hts_primary_raw)))
  ph <- select_primary_hit(norm)
  drc <- calc_drc_auc(fit_4pl_curve(import_dose_response(hts_dose_response,
    response_col = "viability", group_cols = "cell_line"),
    group_cols = "cell_line"))
  sec <- aggregate(auc ~ compound_id, drc$meta, mean)
  expect_s3_class(plot_primary_secondary_cor(ph, sec), "ggplot")
})

test_that("theme_hts and hts_pal are exported and well-formed", {
  expect_s3_class(theme_hts(), "theme")
  expect_s3_class(theme_hts(grid = "both", legend = "right"), "theme")
  pal <- hts_pal()
  expect_type(pal, "character")
  expect_true(length(pal) >= 8)
  expect_true(all(grepl("^#[0-9A-Fa-f]{6}$", pal)))
  expect_length(hts_pal(3), 3)
  library(ggplot2)
  g <- ggplot(mtcars, aes(factor(cyl), mpg)) + geom_boxplot() + theme_hts()
  expect_s3_class(g, "ggplot")
})
